Files
Marcos Paulo 4ea2e9fd5a
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: outliner list moves, notescraft:// URI resolve, periodic notes (MiniMax M3.1 Flash)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 05:54:18 -03:00

177 lines
8.5 KiB
Rust

//! Periodic notes: the weekly, monthly, quarterly and yearly notes
//! (`note.periodic`).
mod common;
use common::{on_disk, seed, temp_vault};
use notescraft_engine::{Error, PeriodKind, Registry, Session, SessionConfig, period_bounds, register_core};
use notescraft_templates::Date;
use serde_json::{Value, json};
/// A registry and a session; `config` picks the folders, formats and templates.
fn fixture(name: &str, config: SessionConfig) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
seed(vault.path(), &[("Templates/Week.md", "Week {{title}}\nFrom {{start}} to {{end}}\n")]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let session = Session::open_with(vault.path(), config).expect("open");
(registry, session, vault)
}
/// The path the command answered with.
fn path_of(answer: &Value) -> String {
answer.get("path").and_then(Value::as_str).expect("a path").to_owned()
}
/// The weekly note follows the ISO weeks, whose year differs from the
/// calendar year at the turn of the year.
#[test]
fn the_weekly_note_follows_the_iso_weeks() {
let (reg, mut session, vault) = fixture("periodic-week", SessionConfig::default());
let opened = reg.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2020-12-31" })).expect("note.periodic");
assert_eq!(path_of(&opened), "Weekly/2020-W53.md");
assert_eq!(opened.get("created"), Some(&json!(true)));
// 2021-01-03 is a Sunday of the same ISO week: the note is already there.
let same = reg.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2021-01-03" })).expect("note.periodic");
assert_eq!(path_of(&same), "Weekly/2020-W53.md");
assert_eq!(same.get("created"), Some(&json!(false)), "the week already has its note");
// The turn of 2026: 2026-12-31 and 2027-01-01 share the ISO week 53.
let edge = reg.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2026-12-31" })).expect("note.periodic");
assert_eq!(path_of(&edge), "Weekly/2026-W53.md");
let edge = reg.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2027-01-01" })).expect("note.periodic");
assert_eq!(path_of(&edge), "Weekly/2026-W53.md");
assert_eq!(edge.get("created"), Some(&json!(false)), "the same ISO week");
assert!(on_disk(vault.path(), "Weekly/2026-W53.md").is_empty(), "a note without a template starts empty");
}
/// The monthly, quarterly and yearly notes are named after their period.
#[test]
fn the_other_notes_are_named_after_their_period() {
let (reg, mut session, _vault) = fixture("periodic-periods", SessionConfig::default());
let monthly = reg.run("note.periodic", &mut session, &json!({ "kind": "monthly", "date": "2026-10-13" })).expect("note.periodic");
assert_eq!(path_of(&monthly), "Monthly/2026-10.md");
let quarterly = reg.run("note.periodic", &mut session, &json!({ "kind": "quarterly", "date": "2026-10-13" })).expect("note.periodic");
assert_eq!(path_of(&quarterly), "Quarterly/2026-Q4.md");
let yearly = reg.run("note.periodic", &mut session, &json!({ "kind": "yearly", "date": "2026-10-13" })).expect("note.periodic");
assert_eq!(path_of(&yearly), "Yearly/2026.md");
}
/// A periodic note is created once and never overwritten.
#[test]
fn a_periodic_note_is_created_once() {
let (reg, mut session, vault) = fixture("periodic-once", SessionConfig::default());
let first = reg.run("note.periodic", &mut session, &json!({ "kind": "monthly", "date": "2026-10-13" })).expect("note.periodic");
assert_eq!(first.get("created"), Some(&json!(true)));
let _ = on_disk(vault.path(), "Monthly/2026-10.md");
let second = reg.run("note.periodic", &mut session, &json!({ "kind": "monthly", "date": "2026-10-13" })).expect("note.periodic");
assert_eq!(second.get("created"), Some(&json!(false)), "idempotent second call");
assert_eq!(path_of(&second), "Monthly/2026-10.md");
}
/// The template of the kind is expanded, with the bounds of the period.
#[test]
fn a_periodic_note_is_created_from_its_template() {
let mut config = SessionConfig::default();
config.periodic.weekly.template = String::from("Templates/Week.md");
let (reg, mut session, vault) = fixture("periodic-template", config);
let opened = reg.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2026-10-14" })).expect("note.periodic");
assert_eq!(path_of(&opened), "Weekly/2026-W42.md");
let content = on_disk(vault.path(), "Weekly/2026-W42.md");
assert_eq!(content, "Week 2026-W42\nFrom 2026-10-12 to 2026-10-18\n", "the title and the bounds of the week");
}
/// The folder of the kind is created with the note.
#[test]
fn the_folder_of_a_periodic_note_is_created() {
let (reg, mut session, vault) = fixture("periodic-folder", SessionConfig::default());
assert!(!vault.path().join("Weekly").exists(), "the folder is not there yet");
reg.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2026-10-14" })).expect("note.periodic");
assert!(vault.path().join("Weekly").is_dir(), "the folder is created with the note");
assert!(vault.path().join("Weekly/2026-W42.md").is_file(), "and so is the note");
}
/// A format that cannot be used is a settings error: nothing is created.
#[test]
fn a_bad_format_is_refused_and_nothing_is_created() {
let vault = temp_vault("periodic-bad-format");
let mut empty = SessionConfig::default();
empty.periodic.weekly.format = String::from(" ");
match Session::open_with(vault.path(), empty) {
Err(Error::Config(message)) => assert!(message.contains("periodic.weekly.format"), "{message}"),
other => panic!("an empty format must be refused, got {other:?}"),
}
let mut escaping = SessionConfig::default();
escaping.periodic.weekly.format = String::from("../up");
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open_with(vault.path(), escaping).expect("open");
match registry.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2026-10-14" })) {
Err(Error::Command { source, .. }) => assert_eq!(source.code, "config", "{}", source.message),
other => panic!("a format that leaves the vault must be refused, got {other:?}"),
}
assert!(!vault.path().join("Weekly").exists(), "nothing was created");
}
/// A read-only session creates nothing.
#[test]
fn a_read_only_session_refuses_a_periodic_note() {
let vault = temp_vault("periodic-read-only");
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open_read_only(vault.path()).expect("open");
match registry.run("note.periodic", &mut session, &json!({ "kind": "weekly", "date": "2026-10-14" })) {
Err(Error::Disabled(id)) => assert_eq!(id, "note.periodic"),
other => panic!("a read-only session must refuse the command, got {other:?}"),
}
assert!(!vault.path().join("Weekly").exists(), "nothing was created");
}
/// A kind nobody knows is refused.
#[test]
fn an_unknown_kind_is_refused() {
let (reg, mut session, _vault) = fixture("periodic-kind", SessionConfig::default());
for bad in ["daily", "Weekly", ""] {
match reg.run("note.periodic", &mut session, &json!({ "kind": bad })) {
Err(Error::Command { source, .. }) => assert_eq!(source.code, "invalid_params", "`{bad}`: {}", source.message),
other => panic!("`{bad}` must be refused, got {other:?}"),
}
}
}
/// The bounds of a period are the ones the UI steps through.
#[test]
fn the_period_bounds_cover_the_period() {
let date = Date::new(2026, 10, 14).expect("a date");
let (start, end) = period_bounds(PeriodKind::Weekly, date).expect("the bounds of the week");
assert_eq!((start.year(), start.month(), start.day()), (2026, 10, 12));
assert_eq!((end.year(), end.month(), end.day()), (2026, 10, 18));
let (start, end) = period_bounds(PeriodKind::Monthly, date).expect("the bounds of the month");
assert_eq!((start.month(), start.day()), (10, 1));
assert_eq!((end.month(), end.day()), (10, 31));
let (start, end) = period_bounds(PeriodKind::Quarterly, date).expect("the bounds of the quarter");
assert_eq!((start.month(), start.day()), (10, 1));
assert_eq!((end.month(), end.day()), (12, 31));
let (start, end) = period_bounds(PeriodKind::Yearly, date).expect("the bounds of the year");
assert_eq!((start.month(), start.day()), (1, 1));
assert_eq!((end.month(), end.day()), (12, 31));
}