Committing before copilot touches this

This commit is contained in:
Breadway 2026-05-25 19:53:50 +08:00
commit feefdb81b9
36 changed files with 12338 additions and 0 deletions

View 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"));
}

View 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);
}

View 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(&note).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);
}

View 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(&note("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(
&notes_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(&notes_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(&note(&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"));
}