15d7f5e873
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity - Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys - Visual view: tables, note embeds, %% comments, wrapping property chips - Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin - Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
144 lines
7.2 KiB
Rust
144 lines
7.2 KiB
Rust
//! The read-only calendar commands over a fixture vault (SPEC 26).
|
|
|
|
mod common;
|
|
|
|
use common::{seed, temp_vault};
|
|
use notescraft_engine::{Registry, Session, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
const STANDUP: &str = "---\ntitle: Standup\nstart: 2026-10-19T09:30\nend: 2026-10-19T09:45\nrepeat: 'FREQ=WEEKLY;BYDAY=MO,WE'\nexceptions: [2026-10-21T09:30]\ncalendar: Work\ntags: [team]\nreminders: [10]\n---\n";
|
|
const LUNCH: &str = "---\ntitle: Lunch\nstart: 2026-10-20T12:00\nend: 2026-10-20T13:00\ncalendar: Home\npriority: high\n---\nBody.\n";
|
|
const CLASH: &str = "---\ntitle: Dentist\nstart: 2026-10-20T12:30\nend: 2026-10-20T13:30\ncalendar: Home\n---\n";
|
|
const BROKEN: &str = "---\ntitle: Broken\nstart: 2026-02-30\n---\n";
|
|
const PLAN: &str = "- [ ] Pay rent \u{1F4C5} 2026-10-21\n- [x] Send form \u{1F4C5} 2026-10-22\n";
|
|
|
|
fn fixture(name: &str) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
seed(
|
|
vault.path(),
|
|
&[
|
|
("Calendar/standup.md", STANDUP),
|
|
("Calendar/lunch.md", LUNCH),
|
|
("Calendar/clash.md", CLASH),
|
|
("Calendar/broken.md", BROKEN),
|
|
("Notes/plan.md", PLAN),
|
|
],
|
|
);
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register");
|
|
let session =
|
|
Session::open_with(vault.path(), notescraft_engine::SessionConfig { week_start: notescraft_templates::Weekday::Monday, ..Default::default() })
|
|
.expect("open");
|
|
(registry, session, vault)
|
|
}
|
|
|
|
fn run(name: &str, params: &Value) -> Value {
|
|
let (registry, mut session, _vault) = fixture(name);
|
|
registry.run(name, &mut session, params).unwrap_or_else(|error| panic!("{name}: {error:?}"))
|
|
}
|
|
|
|
fn titles(groups: &Value) -> Vec<String> {
|
|
groups
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.flat_map(|g| g["items"].as_array().unwrap().iter().map(|i| format!("{} {}", i["start"].as_str().unwrap(), i["title"].as_str().unwrap())))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn agenda_expands_repeats_skips_exceptions_and_reports_broken_files() {
|
|
let out = run("calendar.agenda", &json!({ "from": "2026-10-19", "to": "2026-10-25" }));
|
|
assert_eq!(
|
|
titles(&out["groups"]),
|
|
["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch", "2026-10-20T12:30 Dentist", "2026-10-21 Pay rent", "2026-10-22 Send form",]
|
|
);
|
|
assert_eq!(out["count"], 5);
|
|
assert_eq!(out["groups"][0]["items"][0]["end"], "2026-10-19T09:45", "an occurrence keeps its length");
|
|
assert!(out["skipped"][0].as_str().unwrap().contains("Calendar/broken.md"), "{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn agenda_filters_sorts_and_groups() {
|
|
let (registry, mut session, _vault) = fixture("calendar-filter");
|
|
let mut ask = |extra: Value| {
|
|
let mut params = json!({ "from": "2026-10-19", "to": "2026-10-31" });
|
|
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
|
registry.run("calendar.agenda", &mut session, ¶ms).unwrap()
|
|
};
|
|
assert_eq!(titles(&ask(json!({ "filter": { "kinds": ["task"] } }))["groups"]), ["2026-10-21 Pay rent", "2026-10-22 Send form"]);
|
|
assert_eq!(titles(&ask(json!({ "filter": { "calendars": ["Home"] }, "sort": "title" }))["groups"]), ["2026-10-20T12:30 Dentist", "2026-10-20T12:00 Lunch"]);
|
|
assert_eq!(ask(json!({ "filter": { "has_reminder": true } }))["count"], 3, "standup on the 19th, 26th and 28th... only the window");
|
|
let grouped = ask(json!({ "group": "calendar" }));
|
|
let labels: Vec<&str> = grouped["groups"].as_array().unwrap().iter().map(|g| g["label"].as_str().unwrap()).collect();
|
|
assert_eq!(labels, ["", "Home", "Work"]);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_params_are_errors() {
|
|
let (registry, mut session, _vault) = fixture("calendar-bad");
|
|
for params in [
|
|
json!({ "from": "2026-10-19", "to": "2026-10-01" }),
|
|
json!({ "from": "2000-01-01", "to": "2100-01-01" }),
|
|
json!({ "from": "nope" }),
|
|
json!({ "sort": "colour" }),
|
|
json!({ "filter": { "kinds": ["banana"] } }),
|
|
json!({ "descending": "yes" }),
|
|
] {
|
|
assert!(registry.run("calendar.agenda", &mut session, ¶ms).is_err(), "{params}");
|
|
}
|
|
assert!(registry.run("calendar.next", &mut session, &json!({ "limit": 0 })).is_err());
|
|
assert!(registry.run("calendar.next", &mut session, &json!({ "limit": 51 })).is_err());
|
|
assert!(registry.run("calendar.free", &mut session, &json!({ "start_minute": 900, "end_minute": 600 })).is_err());
|
|
assert!(registry.run("calendar.year", &mut session, &json!({ "year": 99_999 })).is_err());
|
|
assert!(registry.run("calendar.review", &mut session, &json!({ "week": "x" })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn next_returns_upcoming_items_in_order() {
|
|
let out = run("calendar.next", &json!({ "limit": 3 }));
|
|
assert!(out["count"].as_u64().unwrap() <= 3);
|
|
}
|
|
|
|
#[test]
|
|
fn conflicts_free_time_year_review_and_parse() {
|
|
let conflicts = run("calendar.conflicts", &json!({ "from": "2026-10-19", "to": "2026-10-25" }));
|
|
assert_eq!(conflicts["count"], 1);
|
|
assert_eq!((conflicts["conflicts"][0]["a"]["title"].as_str(), conflicts["conflicts"][0]["b"]["title"].as_str()), (Some("Lunch"), Some("Dentist")));
|
|
|
|
let free = run("calendar.free", &json!({ "day": "2026-10-20", "start_minute": 540, "end_minute": 1080 }));
|
|
let slots: Vec<(u64, u64)> = free["slots"].as_array().unwrap().iter().map(|s| (s["start"].as_u64().unwrap(), s["end"].as_u64().unwrap())).collect();
|
|
assert_eq!(slots, [(540, 720), (810, 1080)]);
|
|
|
|
let year = run("calendar.year", &json!({ "year": 2026 }));
|
|
assert_eq!(year["counts"].as_array().unwrap().len(), 365);
|
|
assert_eq!(year["counts"][292], 2, "20 October 2026 is day 293: lunch and dentist");
|
|
assert_eq!(year["levels"][292], 2);
|
|
|
|
let review = run("calendar.review", &json!({ "week": "2026-10-22" }));
|
|
assert_eq!(review["review"]["week_start"], "2026-10-19");
|
|
assert_eq!(review["review"]["tasks_done"].as_array().unwrap().len(), 1);
|
|
assert_eq!(review["review"]["tasks_open"].as_array().unwrap().len(), 1);
|
|
assert_eq!(review["review"]["events"].as_array().unwrap().len(), 3, "standup on the 19th (the 21st is an exception), lunch, dentist");
|
|
|
|
let parsed = run("calendar.parse", &json!({ "text": "Lunch tomorrow at 12:30 for 1h #food" }));
|
|
assert_eq!(parsed["event"]["title"], "Lunch");
|
|
assert_eq!(parsed["event"]["tags"][0], "food");
|
|
let (registry, mut session, vault) = fixture("calendar-parse-nowrite");
|
|
assert!(registry.run("calendar.parse", &mut session, &json!({ "text": "Lunch at 25:00" })).is_err());
|
|
assert_eq!(std::fs::read_dir(vault.path().join("Calendar")).unwrap().count(), 4, "parse writes nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn the_commands_run_on_a_read_only_session() {
|
|
let vault = temp_vault("calendar-read-only");
|
|
seed(vault.path(), &[("Calendar/lunch.md", LUNCH)]);
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register");
|
|
let mut session = Session::open_read_only(vault.path()).expect("open");
|
|
for id in ["calendar.agenda", "calendar.next", "calendar.free", "calendar.conflicts", "calendar.review"] {
|
|
registry.run(id, &mut session, &json!({})).unwrap_or_else(|error| panic!("{id}: {error:?}"));
|
|
}
|
|
assert!(registry.list(&session).iter().any(|info| info.id == "calendar.agenda"));
|
|
}
|