//! Diary feature tests: //! - Date primitives, path conventions, nav helpers, IndexedPage diary //! classification. //! - Frequency wiring (daily/weekly/monthly/yearly): per-frequency //! path/URI shape, index recognition, and `next_period` / `prev_period` //! navigation across mixed-frequency entries. use std::path::PathBuf; use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod}; use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; use nuwiki_lsp::config::WikiConfig; use nuwiki_lsp::diary; use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex}; use tower_lsp::lsp_types::Url; fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { VimwikiSyntax::new().parse(src) } fn wiki_cfg(root: &str) -> WikiConfig { let mut cfg = WikiConfig::from_root(PathBuf::from(root)); cfg.diary_rel_path = "diary".into(); cfg.diary_index = "diary".into(); cfg } fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex { let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into())); for d in dates { let path = format!("{root}/diary/{d}.wiki"); let uri = Url::from_file_path(&path).unwrap(); idx.upsert(uri, &parse("= entry =\n")); } idx } // ===== DiaryDate parser ===== #[test] fn parse_accepts_canonical_iso8601() { let d = DiaryDate::parse("2026-05-11").unwrap(); assert_eq!(d.year, 2026); assert_eq!(d.month, 5); assert_eq!(d.day, 11); } #[test] fn parse_rejects_missing_zero_padding() { assert!(DiaryDate::parse("2026-5-11").is_none()); assert!(DiaryDate::parse("2026-05-1").is_none()); } #[test] fn parse_rejects_alternate_separators() { assert!(DiaryDate::parse("2026/05/11").is_none()); assert!(DiaryDate::parse("2026.05.11").is_none()); } #[test] fn parse_rejects_extra_whitespace() { assert!(DiaryDate::parse(" 2026-05-11").is_none()); assert!(DiaryDate::parse("2026-05-11 ").is_none()); } #[test] fn parse_rejects_invalid_calendar_dates() { assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13"); assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30"); assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31"); assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0"); assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0"); } #[test] fn parse_accepts_leap_day_in_leap_year() { assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap"); assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap"); assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400"); assert!( DiaryDate::parse("1900-02-29").is_none(), "div by 100 not 400" ); } #[test] fn format_round_trip() { let d = DiaryDate::from_ymd(2026, 5, 11).unwrap(); assert_eq!(d.format(), "2026-05-11"); assert_eq!(DiaryDate::parse(&d.format()), Some(d)); } // ===== Date arithmetic ===== #[test] fn next_day_wraps_month_boundary() { let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap(); assert_eq!( last_of_jan.next_day(), DiaryDate::from_ymd(2026, 2, 1).unwrap() ); } #[test] fn next_day_wraps_year_boundary() { let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap(); assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap()); } #[test] fn prev_day_walks_back_across_month() { let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap(); assert_eq!( first_of_mar.prev_day(), DiaryDate::from_ymd(2026, 2, 28).unwrap() ); } #[test] fn prev_day_walks_back_across_leap() { let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap(); assert_eq!( first_of_mar.prev_day(), DiaryDate::from_ymd(2024, 2, 29).unwrap() ); } #[test] fn ordering_is_chronological() { let a = DiaryDate::from_ymd(2026, 1, 1).unwrap(); let b = DiaryDate::from_ymd(2026, 1, 2).unwrap(); let c = DiaryDate::from_ymd(2026, 2, 1).unwrap(); let d = DiaryDate::from_ymd(2027, 1, 1).unwrap(); assert!(a < b); assert!(b < c); assert!(c < d); } #[test] fn today_utc_is_a_real_calendar_date() { let t = DiaryDate::today_utc(); assert!(t.year >= 2020 && t.year <= 2100); assert!((1..=12).contains(&t.month)); assert!((1..=31).contains(&t.day)); } // ===== WikiConfig diary paths ===== #[test] fn diary_dir_joins_root_and_rel_path() { let cfg = wiki_cfg("/tmp/wiki"); assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary")); } #[test] fn diary_index_path_uses_diary_index_filename_and_extension() { let cfg = wiki_cfg("/tmp/wiki"); assert_eq!( cfg.diary_index_path(), PathBuf::from("/tmp/wiki/diary/diary.wiki") ); } #[test] fn diary_path_for_uses_iso_date_as_stem() { let cfg = wiki_cfg("/tmp/wiki"); let date = DiaryDate::from_ymd(2026, 5, 11).unwrap(); assert_eq!( cfg.diary_path_for(&date), PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki") ); } #[test] fn diary_uri_for_date_is_file_url() { let cfg = wiki_cfg("/tmp/wiki"); let date = DiaryDate::from_ymd(2026, 5, 11).unwrap(); let uri = diary::uri_for_date(&cfg, &date).unwrap(); assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki")); } #[test] fn diary_index_uri_resolves() { let cfg = wiki_cfg("/tmp/wiki"); let uri = diary::index_uri(&cfg).unwrap(); assert!(uri.as_str().ends_with("/diary/diary.wiki")); } // ===== Index integration: diary_date on IndexedPage ===== #[test] fn upsert_marks_diary_entries() { let root = "/tmp/diary1"; let idx = build_index_with_entries(root, &["2026-05-11"]); let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap(); let page = idx.page(&uri).expect("page"); let date = page.diary_date.expect("diary_date"); assert_eq!(date.format(), "2026-05-11"); } #[test] fn upsert_does_not_mark_non_diary_files() { let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2"))) .with_diary_rel_path(Some("diary".into())); let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap(); idx.upsert(uri.clone(), &parse("= Home =\n")); assert!(idx.page(&uri).unwrap().diary_date.is_none()); } #[test] fn upsert_does_not_mark_dates_outside_diary_subdir() { // A file at root named like a date but not under diary/ → not a // diary entry. let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3"))) .with_diary_rel_path(Some("diary".into())); let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap(); idx.upsert(uri.clone(), &parse("= note =\n")); assert!(idx.page(&uri).unwrap().diary_date.is_none()); } // ===== Diary listing + navigation ===== #[test] fn list_entries_returns_sorted_ascending() { let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]); let entries = diary::list_entries(&idx); let dates: Vec = entries.iter().map(|e| e.date.format()).collect(); assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]); } #[test] fn list_entries_filtered_by_year_and_month() { let idx = build_index_with_entries( "/tmp/diary5", &["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"], ); let may = diary::list_entries_filtered(&idx, Some(2026), Some(5)); assert_eq!(may.len(), 2); let y_only = diary::list_entries_filtered(&idx, Some(2026), None); assert_eq!(y_only.len(), 3); } #[test] fn next_entry_finds_strictly_greater() { let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]); let from = DiaryDate::from_ymd(2026, 5, 11).unwrap(); let next = diary::next_entry(&idx, &from).unwrap(); assert_eq!(next.date.format(), "2026-05-12"); } #[test] fn next_entry_none_when_no_future_entries() { let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]); let from = DiaryDate::from_ymd(2026, 5, 11).unwrap(); assert!(diary::next_entry(&idx, &from).is_none()); } #[test] fn prev_entry_finds_strictly_lesser() { let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]); let from = DiaryDate::from_ymd(2026, 5, 13).unwrap(); let prev = diary::prev_entry(&idx, &from).unwrap(); assert_eq!(prev.date.format(), "2026-05-12"); } // ===== Index body generation ===== #[test] fn build_index_body_is_newest_first_grouped_by_year_month() { let idx = build_index_with_entries( "/tmp/diary9", &["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"], ); let entries = diary::list_entries(&idx); let body = diary::build_index_body(&entries, "diary", "Diary"); let lines: Vec<&str> = body.lines().collect(); assert_eq!(lines[0], "= Diary ="); // First date in output should be the latest: 2026-05-12 let earliest_2026_pos = body.find("2026-05-12").unwrap(); let latest_2025_pos = body.find("2025-12-31").unwrap(); assert!(earliest_2026_pos < latest_2025_pos, "newer entries first"); // Year headings appear assert!(body.contains("== 2026 ==")); assert!(body.contains("== 2025 ==")); // Month headings appear assert!(body.contains("=== May ===")); assert!(body.contains("=== December ===")); assert!(body.contains("=== April ===")); } #[test] fn build_index_body_empty_when_no_entries() { let body = diary::build_index_body(&[], "diary", "Diary"); assert_eq!(body, "= Diary =\n"); } #[test] fn render_index_body_round_trip() { let cfg = wiki_cfg("/tmp/diaryA"); let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]); let body = diary::render_index_body(&cfg, &idx); assert!(body.contains("= Diary =")); assert!(body.contains("[[diary/2026-05-11]]")); } // ===== is_in_diary ===== #[test] fn is_in_diary_recognises_subdir_paths() { let cfg = wiki_cfg("/tmp/wiki"); assert!(diary::is_in_diary( &cfg, &PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki") )); assert!(!diary::is_in_diary( &cfg, &PathBuf::from("/tmp/wiki/Home.wiki") )); } // ===== Serde ===== #[test] fn diary_entry_serializes_to_date_string_and_uri() { let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap(); let entry = diary::DiaryEntry { date: DiaryDate::from_ymd(2026, 5, 11).unwrap(), uri, }; let v = serde_json::to_value(&entry).unwrap(); assert_eq!(v["date"], "2026-05-11"); assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki")); } // ===== COMMANDS list completeness ===== #[test] fn commands_list_includes_diary_entries() { let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); for name in [ "nuwiki.diary.openToday", "nuwiki.diary.openYesterday", "nuwiki.diary.openTomorrow", "nuwiki.diary.openIndex", "nuwiki.diary.generateIndex", "nuwiki.diary.next", "nuwiki.diary.prev", "nuwiki.diary.listEntries", "nuwiki.diary.openForDate", ] { assert!(names.contains(&name), "missing: {name}"); } } // ===== Config wire format ===== #[test] fn wiki_config_defaults_match_vimwiki() { let cfg = WikiConfig::empty(); assert_eq!(cfg.diary_rel_path, "diary"); assert_eq!(cfg.diary_index, "diary"); } #[test] fn wiki_config_from_root_uses_default_diary_paths() { let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x")); assert_eq!(cfg.diary_rel_path, "diary"); } #[test] fn config_from_json_accepts_custom_diary_paths() { use nuwiki_lsp::config::config_from_json; let cfg = config_from_json(serde_json::json!({ "wikis": [ { "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" }, ], })); assert_eq!(cfg.wikis[0].diary_rel_path, "journal"); assert_eq!(cfg.wikis[0].diary_index, "index"); } // ===== Frequency wiring (daily / weekly / monthly / yearly) ===== fn cfg(root: &str, frequency: &str) -> WikiConfig { let mut c = WikiConfig::from_root(PathBuf::from(root)); c.diary_rel_path = "diary".into(); c.diary_frequency = frequency.into(); c } #[test] fn frequency_helper_picks_up_config_value() { assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily); assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly); assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly); assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly); } #[test] fn diary_path_for_period_picks_right_stem_per_frequency() { let c = cfg("/wiki", "daily"); let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()); let wk = DiaryPeriod::Week { iso_year: 2026, iso_week: 19, }; let mo = DiaryPeriod::Month { year: 2026, month: 5, }; let yr = DiaryPeriod::Year { year: 2026 }; assert_eq!( c.diary_path_for_period(&day), PathBuf::from("/wiki/diary/2026-05-12.wiki") ); assert_eq!( c.diary_path_for_period(&wk), PathBuf::from("/wiki/diary/2026-W19.wiki") ); assert_eq!( c.diary_path_for_period(&mo), PathBuf::from("/wiki/diary/2026-05.wiki") ); assert_eq!( c.diary_path_for_period(&yr), PathBuf::from("/wiki/diary/2026.wiki") ); } #[test] fn uri_for_period_produces_file_url_per_frequency() { let c = cfg("/wiki", "weekly"); let wk = DiaryPeriod::Week { iso_year: 2026, iso_week: 19, }; let u = diary::uri_for_period(&c, &wk).unwrap(); assert!( u.as_str().ends_with("/wiki/diary/2026-W19.wiki"), "got: {u}" ); } fn make_index_with(entries: &[&str]) -> WorkspaceIndex { let root = PathBuf::from("/wiki"); let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into())); for stem in entries { let path = format!("/wiki/diary/{stem}.wiki"); let uri = Url::from_file_path(&path).unwrap(); let ast = VimwikiSyntax::new().parse("= entry =\n"); idx.upsert(uri, &ast); } idx } #[test] fn index_recognises_all_four_frequency_stems() { let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]); for (stem, expected) in [ ( "2026-05-12", DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()), ), ( "2026-W19", DiaryPeriod::Week { iso_year: 2026, iso_week: 19, }, ), ( "2026-05", DiaryPeriod::Month { year: 2026, month: 5, }, ), ("2026", DiaryPeriod::Year { year: 2026 }), ] { let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap(); let page = idx.page(&uri).expect(stem); assert_eq!( page.diary_period, Some(expected), "stem {stem} should index as {expected:?}" ); } } #[test] fn index_only_sets_diary_date_for_daily_entries() { let idx = make_index_with(&["2026-05-12", "2026-W19"]); let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap(); let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap(); assert!(idx.page(&daily).unwrap().diary_date.is_some()); assert!(idx.page(&weekly).unwrap().diary_date.is_none()); assert!(idx.page(&weekly).unwrap().diary_period.is_some()); } #[test] fn diary_period_for_uri_rejects_paths_outside_diary_dir() { // Non-diary path with a date-looking stem — should NOT count. let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap(); let root = PathBuf::from("/wiki"); assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none()); } #[test] fn next_prev_period_navigate_at_the_same_frequency() { // Mix entries at different frequencies. Navigation should stay // within the same flavour as the pivot. let idx = make_index_with(&[ "2026-W18", "2026-W19", "2026-W20", // weekly "2026-04", "2026-05", "2026-06", // monthly "2026-05-10", "2026-05-12", // daily ]); let pivot_w = DiaryPeriod::Week { iso_year: 2026, iso_week: 19, }; let next_w = diary::next_period(&idx, &pivot_w).unwrap(); assert_eq!( next_w.period, DiaryPeriod::Week { iso_year: 2026, iso_week: 20 } ); let prev_w = diary::prev_period(&idx, &pivot_w).unwrap(); assert_eq!( prev_w.period, DiaryPeriod::Week { iso_year: 2026, iso_week: 18 } ); let pivot_m = DiaryPeriod::Month { year: 2026, month: 5, }; let next_m = diary::next_period(&idx, &pivot_m).unwrap(); assert_eq!( next_m.period, DiaryPeriod::Month { year: 2026, month: 6 } ); let prev_m = diary::prev_period(&idx, &pivot_m).unwrap(); assert_eq!( prev_m.period, DiaryPeriod::Month { year: 2026, month: 4 } ); } #[test] fn next_prev_period_returns_none_past_the_edge() { let idx = make_index_with(&["2026-W19"]); let only = DiaryPeriod::Week { iso_year: 2026, iso_week: 19, }; assert!(diary::next_period(&idx, &only).is_none()); assert!(diary::prev_period(&idx, &only).is_none()); } #[test] fn diary_period_for_uri_handles_each_frequency_under_diary_dir() { let root = PathBuf::from("/wiki"); for (stem, expected) in [ ( "2026-05-12", DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()), ), ( "2026-W19", DiaryPeriod::Week { iso_year: 2026, iso_week: 19, }, ), ( "2026-05", DiaryPeriod::Month { year: 2026, month: 5, }, ), ("2026", DiaryPeriod::Year { year: 2026 }), ] { let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap(); let got = diary_period_for_uri(&uri, Some(&root), Some("diary")); assert_eq!(got, Some(expected), "stem {stem}"); } }