Committing before copilot touches this
This commit is contained in:
commit
feefdb81b9
36 changed files with 12338 additions and 0 deletions
83
breadpad-shared/tests/classifier.rs
Normal file
83
breadpad-shared/tests/classifier.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use breadpad_shared::classifier::Classifier;
|
||||
use breadpad_shared::types::NoteType;
|
||||
use chrono::Timelike;
|
||||
|
||||
fn cl() -> Classifier {
|
||||
Classifier::load("auto", "08:00")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_is_cpu() {
|
||||
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback.
|
||||
let c = cl();
|
||||
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_falls_back_to_rule_based() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("buy milk");
|
||||
assert_eq!(r.note_type, NoteType::Todo);
|
||||
assert!(r.time.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_todo_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("fix the segfault").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reminder_via_fallback() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("call mum at 6pm");
|
||||
assert_eq!(r.note_type, NoteType::Reminder);
|
||||
assert!(r.time.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_idea_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("what if we added a calendar view").note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_question_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("why does this fail?").note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_note_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("meeting went well today").note_type, NoteType::Note);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_recurrence_via_fallback() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("standup every monday at 9am");
|
||||
assert!(r.rrule.is_some(), "expected rrule from fallback parser");
|
||||
assert_eq!(r.note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_custom_morning_time() {
|
||||
let mut c = Classifier::load("auto", "07:15");
|
||||
let r = c.classify("sync tomorrow morning");
|
||||
let t = r.time.expect("should have a time for tomorrow morning");
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
assert_eq!(local.hour(), 7);
|
||||
assert_eq!(local.minute(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_path_points_to_expected_location() {
|
||||
let c = cl();
|
||||
assert!(
|
||||
c.model_path.to_str().unwrap().contains("breadpad"),
|
||||
"model path: {:?}",
|
||||
c.model_path
|
||||
);
|
||||
assert!(c.model_path.to_str().unwrap().ends_with("classifier.onnx"));
|
||||
}
|
||||
198
breadpad-shared/tests/config.rs
Normal file
198
breadpad-shared/tests/config.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---- Default values ----
|
||||
|
||||
#[test]
|
||||
fn default_settings() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.default_type, "note");
|
||||
assert!(s.workspace_tag);
|
||||
assert_eq!(s.archive_after_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_snooze_options_contains_all_three() {
|
||||
let s = Settings::default();
|
||||
assert!(s.snooze_options.iter().any(|x| x == "15m"));
|
||||
assert!(s.snooze_options.iter().any(|x| x == "1h"));
|
||||
assert!(s.snooze_options.iter().any(|x| x == "tomorrow_morning"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_config() {
|
||||
let m = ModelConfig::default();
|
||||
assert_eq!(m.execution_provider, "auto");
|
||||
assert!(m.path.contains("classifier.onnx"));
|
||||
assert!(m.tokenizer.contains("tokenizer.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_reminders_config() {
|
||||
let r = RemindersConfig::default();
|
||||
assert_eq!(r.default_morning, "08:00");
|
||||
assert_eq!(r.missed_grace_minutes, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_composes_defaults() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
// ---- TOML deserialization ----
|
||||
|
||||
#[test]
|
||||
fn full_config_from_toml() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "todo"
|
||||
workspace_tag = false
|
||||
snooze_options = ["15m", "2h"]
|
||||
archive_after_days = 7
|
||||
|
||||
[model]
|
||||
path = "/tmp/classifier.onnx"
|
||||
tokenizer = "/tmp/tokenizer.json"
|
||||
execution_provider = "cpu"
|
||||
|
||||
[reminders]
|
||||
default_morning = "07:30"
|
||||
missed_grace_minutes = 30
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "todo");
|
||||
assert!(!cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]);
|
||||
assert_eq!(cfg.settings.archive_after_days, 7);
|
||||
assert_eq!(cfg.model.execution_provider, "cpu");
|
||||
assert_eq!(cfg.model.path, "/tmp/classifier.onnx");
|
||||
assert_eq!(cfg.reminders.default_morning, "07:30");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_toml_uses_all_defaults() {
|
||||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_only_settings_section() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "reminder"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "reminder");
|
||||
// Other sections should still have defaults
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_only_model_section() {
|
||||
let toml = r#"
|
||||
[model]
|
||||
execution_provider = "npu"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, "npu");
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_provider_variants_accepted() {
|
||||
for ep in &["auto", "npu", "vulkan", "cpu"] {
|
||||
let toml = format!("[model]\nexecution_provider = \"{}\"", ep);
|
||||
let cfg: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, *ep);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TOML serialization round-trip ----
|
||||
|
||||
#[test]
|
||||
fn default_config_serializes_to_valid_toml() {
|
||||
let cfg = Config::default();
|
||||
let serialized = toml::to_string_pretty(&cfg).unwrap();
|
||||
let reparsed: Config = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(reparsed.settings.default_type, cfg.settings.default_type);
|
||||
assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag);
|
||||
assert_eq!(reparsed.model.execution_provider, cfg.model.execution_provider);
|
||||
assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_config_round_trips() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "idea".into();
|
||||
cfg.settings.archive_after_days = 14;
|
||||
cfg.model.execution_provider = "vulkan".into();
|
||||
cfg.reminders.default_morning = "06:45".into();
|
||||
cfg.reminders.missed_grace_minutes = 120;
|
||||
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
let rt: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(rt.settings.default_type, "idea");
|
||||
assert_eq!(rt.settings.archive_after_days, 14);
|
||||
assert_eq!(rt.model.execution_provider, "vulkan");
|
||||
assert_eq!(rt.reminders.default_morning, "06:45");
|
||||
assert_eq!(rt.reminders.missed_grace_minutes, 120);
|
||||
}
|
||||
|
||||
// ---- Config::save + Config::load ----
|
||||
|
||||
#[test]
|
||||
fn save_and_load_round_trip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config_path = dir.path().join("breadpad.toml");
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "question".into();
|
||||
cfg.model.execution_provider = "cpu".into();
|
||||
cfg.reminders.missed_grace_minutes = 45;
|
||||
|
||||
// Manually save to a known path (Config::save uses the fixed XDG path,
|
||||
// so we use toml serialization + write here to test the round-trip logic)
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
std::fs::write(&config_path, &toml).unwrap();
|
||||
|
||||
let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert_eq!(loaded.settings.default_type, "question");
|
||||
assert_eq!(loaded.model.execution_provider, "cpu");
|
||||
assert_eq!(loaded.reminders.missed_grace_minutes, 45);
|
||||
}
|
||||
|
||||
// ---- The example from the README ----
|
||||
|
||||
#[test]
|
||||
fn readme_example_toml_parses() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "note"
|
||||
workspace_tag = true
|
||||
snooze_options = ["15m", "1h", "tomorrow_morning"]
|
||||
archive_after_days = 30
|
||||
|
||||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto"
|
||||
|
||||
[reminders]
|
||||
default_morning = "08:00"
|
||||
missed_grace_minutes = 60
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 60);
|
||||
}
|
||||
236
breadpad-shared/tests/pipeline.rs
Normal file
236
breadpad-shared/tests/pipeline.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// End-to-end pipeline tests: classify → save → reload
|
||||
//
|
||||
// These mirror what breadpad (capture) and breadman (display) do in production.
|
||||
// Both apps share the same Store path; we prove here that a note typed in the
|
||||
// popup survives the classify+save step and is visible to a fresh store handle,
|
||||
// exactly as breadman would see it on startup.
|
||||
|
||||
use breadpad_shared::classifier::Classifier;
|
||||
use breadpad_shared::store::Store;
|
||||
use breadpad_shared::types::{Note, NoteType};
|
||||
use chrono::Timelike;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Mirrors commit_note() in breadpad/src/main.rs.
|
||||
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
|
||||
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
|
||||
let mut classifier = Classifier::load("auto", "08:00");
|
||||
let result = classifier.classify(text);
|
||||
|
||||
let mut note = Note::new(text.into(), user_type.clone(), None);
|
||||
|
||||
// When the user left the type at the default, let the classifier override it.
|
||||
if user_type == NoteType::from_str("note") {
|
||||
note.note_type = result.note_type;
|
||||
}
|
||||
note.time = result.time;
|
||||
note.rrule = result.rrule;
|
||||
note.body = result.body;
|
||||
|
||||
store.save_note(¬e).unwrap();
|
||||
note
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, Store) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
// Open a second Store handle pointing at the same directory — this simulates
|
||||
// breadman reading from the path that breadpad wrote to.
|
||||
fn breadman_store(dir: &TempDir) -> Store {
|
||||
Store::from_dir(dir.path()).unwrap()
|
||||
}
|
||||
|
||||
// ---- basic round-trip ----
|
||||
|
||||
#[test]
|
||||
fn todo_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
let saved = capture(&store, "buy groceries", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].id, saved.id);
|
||||
assert_eq!(notes[0].note_type, NoteType::Todo);
|
||||
assert_eq!(notes[0].body, "buy groceries");
|
||||
assert!(!notes[0].done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "what if we added dark mode", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "why does the cache miss on cold start?", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "retro went well today", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].note_type, NoteType::Note);
|
||||
}
|
||||
|
||||
// ---- reminder with time ----
|
||||
|
||||
#[test]
|
||||
fn reminder_has_time_set() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
assert!(notes[0].time.is_some(), "reminder should have a scheduled time");
|
||||
let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into();
|
||||
assert_eq!(local.hour(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reminder_body_has_time_stripped() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert!(!notes[0].body.contains("6pm"), "time phrase should be removed from body");
|
||||
assert!(notes[0].body.contains("call mum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_duration_reminder_has_time() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "check on the build in 30 minutes", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
assert!(notes[0].time.is_some());
|
||||
}
|
||||
|
||||
// ---- recurring reminder ----
|
||||
|
||||
#[test]
|
||||
fn recurring_reminder_has_rrule() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "standup every monday at 9am", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
let rrule = notes[0].rrule.as_ref().expect("should have rrule");
|
||||
assert!(rrule.as_str().contains("FREQ=WEEKLY"));
|
||||
assert!(rrule.as_str().contains("BYDAY=MO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_reminder_has_rrule() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "drink water every day at 8am", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
assert!(notes[0].rrule.as_ref().unwrap().as_str().contains("FREQ=DAILY"));
|
||||
}
|
||||
|
||||
// ---- user-forced type is respected ----
|
||||
|
||||
#[test]
|
||||
fn user_selected_type_overrides_classifier() {
|
||||
let (dir, store) = setup();
|
||||
// Text would classify as Todo, but user explicitly chose Idea
|
||||
capture(&store, "fix the login bug", NoteType::Idea);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Idea, "user chip selection should win over classifier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_selected_reminder_overrides_classifier() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "team meeting notes from today", NoteType::Reminder);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
// ---- multiple notes all appear ----
|
||||
|
||||
#[test]
|
||||
fn three_notes_all_visible_to_breadman() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "buy milk", NoteType::from_str("note"));
|
||||
capture(&store, "what if we rewrote in Zig", NoteType::from_str("note"));
|
||||
capture(&store, "team standup went well", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 3);
|
||||
|
||||
let types: Vec<NoteType> = notes.iter().map(|n| n.note_type.clone()).collect();
|
||||
assert!(types.contains(&NoteType::Todo));
|
||||
assert!(types.contains(&NoteType::Idea));
|
||||
assert!(types.contains(&NoteType::Note));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_written_sequentially_all_survive() {
|
||||
let (dir, store) = setup();
|
||||
let n = 10u32;
|
||||
for i in 0..n {
|
||||
capture(&store, &format!("note number {}", i), NoteType::Note);
|
||||
}
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len() as u32, n);
|
||||
}
|
||||
|
||||
// ---- note fields are fully preserved ----
|
||||
|
||||
#[test]
|
||||
fn note_id_is_stable_after_reload() {
|
||||
let (dir, store) = setup();
|
||||
let saved = capture(&store, "check the logs", NoteType::Todo);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].id, saved.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_created_timestamp_preserved() {
|
||||
let (dir, store) = setup();
|
||||
let saved = capture(&store, "morning standup", NoteType::Note);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
// Timestamps should be equal within 1 second (serde round-trips subsecond precision)
|
||||
let diff = (notes[0].created - saved.created).num_seconds().abs();
|
||||
assert!(diff <= 1, "created timestamp drifted by {}s", diff);
|
||||
}
|
||||
|
||||
// ---- store isolation: two separate runs don't bleed ----
|
||||
|
||||
#[test]
|
||||
fn separate_store_dirs_are_isolated() {
|
||||
let (dir_a, store_a) = setup();
|
||||
let (dir_b, store_b) = setup();
|
||||
capture(&store_a, "note for session A", NoteType::Note);
|
||||
capture(&store_b, "note for session B", NoteType::Note);
|
||||
|
||||
let notes_a = breadman_store(&dir_a).load_all().unwrap();
|
||||
let notes_b = breadman_store(&dir_b).load_all().unwrap();
|
||||
assert_eq!(notes_a.len(), 1);
|
||||
assert_eq!(notes_b.len(), 1);
|
||||
assert_ne!(notes_a[0].id, notes_b[0].id);
|
||||
}
|
||||
382
breadpad-shared/tests/store.rs
Normal file
382
breadpad-shared/tests/store.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
use breadpad_shared::store::Store;
|
||||
use breadpad_shared::types::{Note, NoteType, RecurrenceRule};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn mk() -> (TempDir, Store) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn note(body: &str, nt: NoteType) -> Note {
|
||||
Note::new(body.into(), nt, None)
|
||||
}
|
||||
|
||||
// ---- Empty state ----
|
||||
|
||||
#[test]
|
||||
fn empty_store_loads_empty_vec() {
|
||||
let (_dir, store) = mk();
|
||||
let notes = store.load_all().unwrap();
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_archive_loads_empty_vec() {
|
||||
let (_dir, store) = mk();
|
||||
let archive = store.load_archive().unwrap();
|
||||
assert!(archive.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_id_returns_none_on_empty_store() {
|
||||
let (_dir, store) = mk();
|
||||
assert!(store.get_by_id("missing").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---- save_note + load_all ----
|
||||
|
||||
#[test]
|
||||
fn save_and_load_single() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("buy milk", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, n.id);
|
||||
assert_eq!(loaded[0].body, "buy milk");
|
||||
assert_eq!(loaded[0].note_type, NoteType::Todo);
|
||||
assert!(!loaded[0].done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_three_notes_all_loaded() {
|
||||
let (_dir, store) = mk();
|
||||
let a = note("alpha", NoteType::Idea);
|
||||
let b = note("beta", NoteType::Note);
|
||||
let c = note("gamma", NoteType::Question);
|
||||
store.save_note(&a).unwrap();
|
||||
store.save_note(&b).unwrap();
|
||||
store.save_note(&c).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 3);
|
||||
let bodies: Vec<&str> = loaded.iter().map(|n| n.body.as_str()).collect();
|
||||
assert!(bodies.contains(&"alpha"));
|
||||
assert!(bodies.contains(&"beta"));
|
||||
assert!(bodies.contains(&"gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_note_preserves_all_fields() {
|
||||
let (_dir, store) = mk();
|
||||
let mut n = Note::new("standup".into(), NoteType::Reminder, Some("2".into()));
|
||||
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO"));
|
||||
n.tags = vec!["work".into()];
|
||||
let t = Utc::now();
|
||||
n.time = Some(t);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.workspace, Some("2".into()));
|
||||
assert_eq!(loaded.rrule.unwrap().as_str(), "RRULE:FREQ=WEEKLY;BYDAY=MO");
|
||||
assert_eq!(loaded.tags, vec!["work"]);
|
||||
assert!(loaded.time.is_some());
|
||||
}
|
||||
|
||||
// ---- update_note ----
|
||||
|
||||
#[test]
|
||||
fn update_note_changes_body() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("original", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
let mut updated = n.clone();
|
||||
updated.body = "updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_note_changes_type() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("task", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
let mut updated = n.clone();
|
||||
updated.note_type = NoteType::Todo;
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_note_does_not_affect_other_notes() {
|
||||
let (_dir, store) = mk();
|
||||
let n1 = note("first", NoteType::Note);
|
||||
let n2 = note("second", NoteType::Todo);
|
||||
store.save_note(&n1).unwrap();
|
||||
store.save_note(&n2).unwrap();
|
||||
|
||||
let mut updated = n1.clone();
|
||||
updated.body = "first-updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let second = store.get_by_id(&n2.id).unwrap().unwrap();
|
||||
assert_eq!(second.body, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_nonexistent_id_leaves_store_intact() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("real", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut ghost = n.clone();
|
||||
ghost.id = "ghost1".into();
|
||||
ghost.body = "ghost".into();
|
||||
store.update_note(&ghost).unwrap();
|
||||
|
||||
let notes = store.load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].body, "real");
|
||||
}
|
||||
|
||||
// ---- mark_done via update ----
|
||||
|
||||
#[test]
|
||||
fn mark_done_persists_through_update() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("finish task", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut done = n.clone();
|
||||
done.mark_done();
|
||||
store.update_note(&done).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert!(loaded.done);
|
||||
assert!(loaded.completed.is_some());
|
||||
}
|
||||
|
||||
// ---- delete_note ----
|
||||
|
||||
#[test]
|
||||
fn delete_removes_only_target() {
|
||||
let (_dir, store) = mk();
|
||||
let keep = note("keep", NoteType::Note);
|
||||
let del = note("delete me", NoteType::Note);
|
||||
store.save_note(&keep).unwrap();
|
||||
store.save_note(&del).unwrap();
|
||||
|
||||
store.delete_note(&del.id).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, keep.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_all_leaves_empty_store() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("only note", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
store.delete_note(&n.id).unwrap();
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_id_is_noop() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("real note", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
store.delete_note("no-such-id").unwrap();
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// ---- get_by_id ----
|
||||
|
||||
#[test]
|
||||
fn get_by_id_finds_correct_note() {
|
||||
let (_dir, store) = mk();
|
||||
let a = note("alpha", NoteType::Idea);
|
||||
let b = note("beta", NoteType::Idea);
|
||||
store.save_note(&a).unwrap();
|
||||
store.save_note(&b).unwrap();
|
||||
|
||||
let found = store.get_by_id(&a.id).unwrap().unwrap();
|
||||
assert_eq!(found.body, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_id_returns_none_for_missing() {
|
||||
let (_dir, store) = mk();
|
||||
store.save_note(¬e("x", NoteType::Note)).unwrap();
|
||||
assert!(store.get_by_id("nope").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---- rotate_archive ----
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_moves_old_done_notes() {
|
||||
let (_dir, store) = mk();
|
||||
|
||||
let mut old_done = note("old task", NoteType::Todo);
|
||||
old_done.done = true;
|
||||
old_done.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&old_done).unwrap();
|
||||
|
||||
let mut recent_done = note("recent task", NoteType::Todo);
|
||||
recent_done.done = true;
|
||||
recent_done.completed = Some(Utc::now() - Duration::days(1));
|
||||
store.save_note(&recent_done).unwrap();
|
||||
|
||||
let active = note("active task", NoteType::Todo);
|
||||
store.save_note(&active).unwrap();
|
||||
|
||||
let moved = store.rotate_archive(30).unwrap();
|
||||
assert_eq!(moved, 1);
|
||||
|
||||
let remaining = store.load_all().unwrap();
|
||||
assert_eq!(remaining.len(), 2);
|
||||
let remaining_ids: Vec<&str> = remaining.iter().map(|n| n.id.as_str()).collect();
|
||||
assert!(!remaining_ids.contains(&old_done.id.as_str()), "old note should be archived");
|
||||
assert!(remaining_ids.contains(&recent_done.id.as_str()));
|
||||
assert!(remaining_ids.contains(&active.id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_writes_to_archive_file() {
|
||||
let (_dir, store) = mk();
|
||||
let mut old = note("archived task", NoteType::Todo);
|
||||
old.done = true;
|
||||
old.completed = Some(Utc::now() - Duration::days(35));
|
||||
store.save_note(&old).unwrap();
|
||||
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
let archived = store.load_archive().unwrap();
|
||||
assert_eq!(archived.len(), 1);
|
||||
assert_eq!(archived[0].id, old.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_appends_to_existing_archive() {
|
||||
let (_dir, store) = mk();
|
||||
|
||||
for i in 0..3u32 {
|
||||
let mut n = note(&format!("old {}", i), NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&n).unwrap();
|
||||
}
|
||||
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
// Add more old notes and rotate again
|
||||
for i in 3..5u32 {
|
||||
let mut n = note(&format!("old {}", i), NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&n).unwrap();
|
||||
}
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
let archived = store.load_archive().unwrap();
|
||||
assert_eq!(archived.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_zero_when_nothing_qualifies() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("active", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
|
||||
let (_dir, store) = mk();
|
||||
let mut n = note("old but undone", NoteType::Todo);
|
||||
n.done = false;
|
||||
// Set created to far past but not done
|
||||
n.completed = Some(Utc::now() - Duration::days(100));
|
||||
store.save_note(&n).unwrap();
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
}
|
||||
|
||||
// ---- Fault tolerance ----
|
||||
|
||||
#[test]
|
||||
fn malformed_jsonl_line_is_skipped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let notes_path = dir.path().join("notes.jsonl");
|
||||
|
||||
let valid = note("valid note", NoteType::Note);
|
||||
let valid_line = serde_json::to_string(&valid).unwrap();
|
||||
fs::write(
|
||||
¬es_path,
|
||||
format!("{}\n{{not valid json}}\n{}\n", valid_line, valid_line),
|
||||
).unwrap();
|
||||
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
let loaded = store.load_all().unwrap();
|
||||
// Two valid lines, one bad line skipped
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert!(loaded.iter().all(|n| n.body == "valid note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_in_jsonl_are_skipped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let notes_path = dir.path().join("notes.jsonl");
|
||||
|
||||
let n = note("hello", NoteType::Note);
|
||||
let line = serde_json::to_string(&n).unwrap();
|
||||
fs::write(¬es_path, format!("\n\n{}\n\n", line)).unwrap();
|
||||
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
}
|
||||
|
||||
// ---- Atomic write ----
|
||||
|
||||
#[test]
|
||||
fn no_tmp_file_left_after_update() {
|
||||
let (dir, store) = mk();
|
||||
let n = note("task", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut updated = n.clone();
|
||||
updated.body = "updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let tmp = dir.path().join("notes.tmp");
|
||||
assert!(!tmp.exists(), ".tmp file should be renamed after write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_writes_atomically_via_rename() {
|
||||
// Verify the file content is consistent after an update (no partial writes visible)
|
||||
let (_dir, store) = mk();
|
||||
for i in 0..10u32 {
|
||||
store.save_note(¬e(&format!("note {}", i), NoteType::Note)).unwrap();
|
||||
}
|
||||
|
||||
let first = store.load_all().unwrap()[0].clone();
|
||||
let mut updated = first.clone();
|
||||
updated.body = "modified".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 10);
|
||||
assert!(loaded.iter().any(|n| n.body == "modified"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue