7d3cde7aba
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
268 lines
15 KiB
Rust
268 lines
15 KiB
Rust
//! Daily notes, templates, themes and `SessionConfig` (SPEC 6 items 8 and 12).
|
|
|
|
mod common;
|
|
|
|
use common::{on_disk, seed, temp_vault};
|
|
|
|
use notescraft_engine::{Error, Registry, Session, SessionConfig, register_core};
|
|
use notescraft_templates::parse_daily_name;
|
|
use serde_json::{Value, json};
|
|
|
|
/// A registry and a session; `config` picks the daily/template folders.
|
|
fn fixture(name: &str, config: SessionConfig) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
seed(vault.path(), &[("Templates/Day.md", "Day: {{title}}\nFor: {{date}}\n"), ("Templates/Meeting.md", "# {{title}}\n\n- {{time}}\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)
|
|
}
|
|
|
|
/// A daily note is created from the configured template and survives a second
|
|
/// call untouched.
|
|
#[test]
|
|
fn daily_note_is_created_once_from_the_template() {
|
|
let config = SessionConfig { daily_template: Some(String::from("Templates/Day.md")), ..SessionConfig::default() };
|
|
let (reg, mut session, vault) = fixture("daily", config);
|
|
|
|
let opened = reg.run("daily.open", &mut session, &json!({ "date": "2026-10-13" })).expect("daily.open");
|
|
assert_eq!(opened.get("created"), Some(&json!(true)));
|
|
let path = opened.get("path").and_then(Value::as_str).expect("path").to_owned();
|
|
assert_eq!(path, "Daily/2026-10-13.md");
|
|
let content = on_disk(vault.path(), "Daily/2026-10-13.md");
|
|
assert_eq!(content, "Day: 2026-10-13\nFor: 2026-10-13\n", "title and date are expanded, the template is honoured");
|
|
|
|
let again = reg.run("daily.open", &mut session, &json!({ "date": "2026-10-13" })).expect("second call");
|
|
assert_eq!(again.get("created"), Some(&json!(false)), "idempotent second call");
|
|
assert_eq!(on_disk(vault.path(), "Daily/2026-10-13.md"), content, "nothing is overwritten");
|
|
}
|
|
|
|
/// Without a `date` param the daily note of today (plus the offset) is opened.
|
|
#[test]
|
|
fn daily_open_without_a_date_uses_today() {
|
|
let (reg, mut session, vault) = fixture("daily-today", SessionConfig::default());
|
|
|
|
let opened = reg.run("daily.open", &mut session, &json!({ "utc_offset_minutes": 120 })).expect("daily.open");
|
|
assert_eq!(opened.get("created"), Some(&json!(true)));
|
|
let path = opened.get("path").and_then(Value::as_str).expect("path").to_owned();
|
|
assert!(path.starts_with("Daily/"), "{path}");
|
|
let format = session.config().daily_format.clone();
|
|
let stem = path.trim_start_matches("Daily/").trim_end_matches(".md");
|
|
assert!(parse_daily_name(&format, stem).is_some(), "`{path}` must be a daily note name");
|
|
assert!(std::fs::read_to_string(vault.path().join("Daily")).is_err(), "the value must be a file, not a folder");
|
|
}
|
|
|
|
/// A malformed `date` is refused, not repaired into something else.
|
|
#[test]
|
|
fn a_bad_date_is_rejected() {
|
|
let (reg, mut session, _vault) = fixture("daily-bad-date", SessionConfig::default());
|
|
|
|
for bad in ["2026-13-40", "tomorrow", "2026/10/13", "2026-1-1", "09999-01-01"] {
|
|
match reg.run("daily.open", &mut session, &json!({ "date": bad })) {
|
|
Err(Error::Command { source, .. }) => assert_eq!(source.code, "invalid_params", "`{bad}`: {}", source.message),
|
|
other => panic!("`{bad}` must be refused by daily.open, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `calendar.month` lays out October 2026 with Sunday first and marks the
|
|
/// days that have a daily note.
|
|
#[test]
|
|
fn calendar_month_marks_the_days_with_notes() {
|
|
let (reg, mut session, _vault) = fixture("calendar", SessionConfig::default());
|
|
|
|
for day in ["2026-10-05", "2026-10-14", "2026-09-30"] {
|
|
reg.run("daily.open", &mut session, &json!({ "date": day })).expect("create a daily note");
|
|
}
|
|
let month = reg.run("calendar.month", &mut session, &json!({ "year": 2026, "month": 10 })).expect("calendar.month");
|
|
|
|
// 1 October 2026 is a Thursday, so Sunday start puts it in column 4.
|
|
let weeks = month.get("weeks").and_then(Value::as_array).expect("weeks");
|
|
let first_week = weeks.first().and_then(Value::as_array).expect("first week");
|
|
assert_eq!(first_week, &[Value::Null, Value::Null, Value::Null, Value::Null, json!(1), json!(2), json!(3)]);
|
|
assert_eq!(month.get("week_start"), Some(&json!("sunday")));
|
|
|
|
let days = month.get("days_with_notes").and_then(Value::as_array).expect("days_with_notes");
|
|
assert_eq!(days, &[json!(5), json!(14)], "within the month, from the daily notes");
|
|
}
|
|
|
|
/// `calendar.month` refuses an impossible month.
|
|
#[test]
|
|
fn calendar_month_refuses_a_bad_month() {
|
|
let (reg, mut session, _vault) = fixture("calendar-bad", SessionConfig::default());
|
|
for bad in [json!({ "year": 2026, "month": 0 }), json!({ "year": 2026, "month": 13 }), json!({ "month": 5 })] {
|
|
match reg.run("calendar.month", &mut session, &bad) {
|
|
Err(Error::InvalidParams { .. }) => {}
|
|
Err(Error::Command { source, .. }) => assert_eq!(source.code, "invalid_params", "`{bad}`"),
|
|
other => panic!("`{bad}` must be refused by calendar.month, got {other:?}"),
|
|
}
|
|
}
|
|
assert!(reg.run("calendar.month", &mut session, &json!({ "year": 2026, "month": 1 })).is_ok());
|
|
}
|
|
|
|
/// `template.list` lists the templates folder only.
|
|
#[test]
|
|
fn templates_are_listed_from_the_folder() {
|
|
let (reg, mut session, _vault) = fixture("template-list", SessionConfig::default());
|
|
session.create_note("Elsewhere.md", "not a template\n").expect("create elsewhere");
|
|
|
|
let listed = reg.run("template.list", &mut session, &Value::Null).expect("template.list");
|
|
let names: Vec<&str> = listed.get("templates").and_then(Value::as_array).expect("templates").iter().filter_map(Value::as_str).collect();
|
|
assert_eq!(names, vec!["Templates/Day.md", "Templates/Meeting.md"], "sorted, folder only");
|
|
}
|
|
|
|
/// A note is created from a template with `{{title}}` expanded.
|
|
#[test]
|
|
fn a_note_is_created_from_a_template() {
|
|
let (reg, mut session, vault) = fixture("template-create", SessionConfig::default());
|
|
|
|
let created =
|
|
reg.run("template.create_note", &mut session, &json!({ "template": "Templates/Meeting.md", "path": "Meetings/Standing.md" })).expect("create");
|
|
assert_eq!(created.get("path"), Some(&json!("Meetings/Standing.md")));
|
|
let content = on_disk(vault.path(), "Meetings/Standing.md");
|
|
assert!(content.starts_with("# Standing\n"), "the title is the file stem: {content:?}");
|
|
assert!(!content.contains("{{time}}"), "a known variable expands: {content:?}");
|
|
|
|
let again = reg.run("template.create_note", &mut session, &json!({ "template": "Templates/Meeting.md", "path": "Meetings/Standing.md" }));
|
|
match again {
|
|
Err(Error::Command { source, .. }) => assert_eq!(source.code, "exists", "a second create is refused"),
|
|
other => panic!("creating over an existing note must be refused, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// An insert goes through the buffer, so undo takes it back.
|
|
#[test]
|
|
fn an_insert_is_undoable_through_the_buffer() {
|
|
let (reg, mut session, vault) = fixture("template-insert", SessionConfig::default());
|
|
session.create_note("Notes/Log.md", "Log start\n").expect("create the log");
|
|
|
|
reg.run("buffer.open", &mut session, &json!({ "path": "Notes/Log.md" })).expect("open buffer");
|
|
let inserted = reg.run("template.insert", &mut session, &json!({ "path": "Notes/Log.md", "template": "Templates/Day.md" })).expect("insert");
|
|
assert_eq!(inserted.get("dirty"), Some(&json!(true)));
|
|
assert!(inserted.get("inserted").and_then(Value::as_u64).is_some_and(|len| len > 0), "the template was expanded");
|
|
let opened = reg.run("buffer.open", &mut session, &json!({ "path": "Notes/Log.md" })).expect("read the buffer");
|
|
let text = opened.get("text").and_then(Value::as_str).expect("text");
|
|
assert!(text.starts_with("Log start\nDay: Log\nFor: "), "the insert lands at the end: {text:?}");
|
|
|
|
let undo = reg.run("buffer.undo", &mut session, &json!({ "path": "Notes/Log.md" })).expect("undo");
|
|
assert_eq!(undo.get("undone"), Some(&json!(true)), "the insert is in the undo stack");
|
|
let undone = reg.run("buffer.open", &mut session, &json!({ "path": "Notes/Log.md" })).expect("read the buffer");
|
|
assert_eq!(undone.get("text"), Some(&json!("Log start\n")), "undo took the insert back");
|
|
assert_eq!(on_disk(vault.path(), "Notes/Log.md"), "Log start\n", "a dirty buffer is never on disk");
|
|
|
|
reg.run("template.insert", &mut session, &json!({ "path": "Notes/Log.md", "template": "Templates/Day.md", "offset": 0 })).expect("insert at 0");
|
|
let opened = reg.run("buffer.open", &mut session, &json!({ "path": "Notes/Log.md" })).expect("read the buffer");
|
|
let text = opened.get("text").and_then(Value::as_str).expect("text");
|
|
assert!(text.starts_with("Day: Log\n"), "the offset param decides where it lands: {text:?}");
|
|
}
|
|
/// `theme.list` names every built-in; `theme.get` shows the whole palette.
|
|
#[test]
|
|
fn themes_are_listed_and_resolved() {
|
|
let (reg, mut session, _vault) = fixture("themes", SessionConfig::default());
|
|
|
|
let listed = reg.run("theme.list", &mut session, &Value::Null).expect("theme.list");
|
|
let ids: Vec<&str> = listed.get("themes").and_then(Value::as_array).expect("themes").iter().filter_map(Value::as_str).collect();
|
|
assert!(ids.contains(&"dark") && ids.contains(&"light") && ids.contains(&"catppuccin-mocha"), "{ids:?}");
|
|
assert_eq!(listed.get("count"), Some(&json!(ids.len())));
|
|
|
|
let theme = reg.run("theme.get", &mut session, &Value::Null).expect("theme.get");
|
|
assert_eq!(theme.get("id"), Some(&json!("catppuccin-mocha")), "the configured default");
|
|
assert_eq!(theme.get("variant"), Some(&json!("dark")));
|
|
let colors = theme.get("colors").and_then(Value::as_object).expect("colour tokens");
|
|
assert!(colors.get("background").and_then(Value::as_str).is_some_and(|hex| hex.starts_with('#')), "background must be a hex colour: {colors:?}");
|
|
assert_eq!(colors.len(), 17, "the full token set of SPEC 8: {colors:?}");
|
|
}
|
|
|
|
/// `theme.set` validates the id, switches and remembers the choice.
|
|
#[test]
|
|
fn themes_are_switched_and_a_bad_id_is_refused() {
|
|
let (reg, mut session, _vault) = fixture("theme-set", SessionConfig::default());
|
|
|
|
let set = reg.run("theme.set", &mut session, &json!({ "id": "catppuccin-latte" })).expect("set");
|
|
assert_eq!(set.get("theme"), Some(&json!("catppuccin-latte")));
|
|
assert_eq!(session.config().theme, "catppuccin-latte", "the config carries the choice");
|
|
let theme = reg.run("theme.get", &mut session, &Value::Null).expect("theme.get");
|
|
assert_eq!(theme.get("id"), Some(&json!("catppuccin-latte")));
|
|
assert_eq!(theme.get("variant"), Some(&json!("light")));
|
|
|
|
match reg.run("theme.set", &mut session, &json!({ "id": "no-theme" })) {
|
|
Err(Error::Command { source, .. }) => assert_eq!(source.code, "not_found", "{}", source.message),
|
|
other => panic!("an unknown theme is refused, got {other:?}"),
|
|
}
|
|
assert_eq!(session.config().theme, "catppuccin-latte", "the failed switch changed nothing");
|
|
}
|
|
|
|
/// An unknown configured id falls back to `dark` and records a diagnostic.
|
|
#[test]
|
|
fn an_unknown_theme_falls_back_with_a_diagnostic() {
|
|
let config = SessionConfig { theme: String::from("ghost"), ..SessionConfig::default() };
|
|
let (reg, mut session, _vault) = fixture("theme-fallback", config);
|
|
|
|
assert_eq!(session.diagnostics(), &[] as &[String], "opening is quiet");
|
|
let theme = reg.run("theme.get", &mut session, &Value::Null).expect("theme.get");
|
|
assert_eq!(theme.get("id"), Some(&json!("dark")), "the fallback");
|
|
assert_eq!(theme.get("variant"), Some(&json!("dark")));
|
|
assert_eq!(session.diagnostics().len(), 1, "{:?}", session.diagnostics());
|
|
assert!(session.diagnostics()[0].contains("ghost"), "{:?}", session.diagnostics());
|
|
let again = reg.run("theme.get", &mut session, &Value::Null).expect("theme.get again");
|
|
assert_eq!(again.get("id"), Some(&json!("dark")));
|
|
assert_eq!(session.diagnostics().len(), 1, "the diagnostic is recorded once");
|
|
}
|
|
|
|
/// A bad `SessionConfig` is refused at open and at set; a good one sticks.
|
|
#[test]
|
|
fn config_is_validated_where_it_enters_the_session() {
|
|
let vault = temp_vault("config-validate");
|
|
seed(vault.path(), &[("note.md", "hi\n")]);
|
|
for bad_json in [
|
|
r#"{"daily_folder": "../up"}"#,
|
|
r#"{"templates_folder": "/abs"}"#,
|
|
r#"{"attachment_folder": "c:/tmp"}"#,
|
|
r#"{"daily_folder": ".obsidian"}"#,
|
|
r#"{"daily_template": ".notescraft/seed.md"}"#,
|
|
r#"{"daily_format": " "}"#,
|
|
] {
|
|
let config: SessionConfig = serde_json::from_str(bad_json).expect("a struct the validator must reject");
|
|
match Session::open_with(vault.path(), config.clone()) {
|
|
Err(Error::Config(message)) => assert!(!message.is_empty(), "{bad_json}"),
|
|
Err(other) => panic!("`{bad_json}` must be a config error, got {other:?}"),
|
|
Ok(_) => panic!("`{bad_json}` must not open a vault"),
|
|
}
|
|
let mut session = Session::open(vault.path()).expect("open with defaults");
|
|
match session.set_config(config) {
|
|
Err(Error::Config(_)) => {}
|
|
other => panic!("`{bad_json}` must be refused by set_config, got {other:?}"),
|
|
}
|
|
assert_eq!(session.config().daily_folder, "Daily", "the refused config changed nothing");
|
|
}
|
|
|
|
let good: SessionConfig = serde_json::from_str(r#"{"daily_folder": "Journal", "week_start": "monday"}"#).expect("valid");
|
|
let session = Session::open_with(vault.path(), good).expect("open");
|
|
assert_eq!(session.config().daily_folder, "Journal");
|
|
assert_eq!(session.config().week_start.name(), "Monday");
|
|
}
|
|
|
|
/// The new write commands are disabled on a read-only session, the readers are not.
|
|
#[test]
|
|
fn a_read_only_session_disables_the_new_write_commands() {
|
|
let vault = temp_vault("read-only-slice3");
|
|
seed(vault.path(), &[("Templates/T.md", "{{title}}\n"), ("note.md", "x\n")]);
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register");
|
|
let mut session = Session::open_read_only(vault.path()).expect("open read-only");
|
|
|
|
for id in ["daily.open", "template.create_note", "template.insert", "theme.set"] {
|
|
assert!(!reg.is_enabled(id, &session), "{id} must be disabled");
|
|
assert!(matches!(reg.run(id, &mut session, &json!({ "template": "Templates/T.md", "path": "x.md", "id": "dark" })), Err(Error::Disabled(_))), "{id}");
|
|
}
|
|
for id in ["search", "calendar.month", "template.list", "theme.list", "theme.get"] {
|
|
assert!(reg.is_enabled(id, &session), "{id} must stay enabled");
|
|
}
|
|
assert!(reg.run("search", &mut session, &json!({ "query": "x" })).is_ok(), "search runs");
|
|
assert!(reg.run("calendar.month", &mut session, &json!({ "year": 2026, "month": 1 })).is_ok(), "calendar runs");
|
|
assert!(reg.run("template.list", &mut session, &Value::Null).is_ok(), "template.list runs");
|
|
assert!(reg.run("theme.list", &mut session, &Value::Null).is_ok(), "theme.list runs");
|
|
assert!(reg.run("theme.get", &mut session, &Value::Null).is_ok(), "theme.get runs");
|
|
assert!(matches!(session.daily_note(notescraft_templates::Date::new(2026, 10, 13).expect("date")), Err(Error::ReadOnly)));
|
|
}
|