Files
Marcos Paulo d7ed4b630d
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
merge: PDF export dialog, assistant settings (plugin tools, status), breadcrumb overflow; fix AM/PM time-of-day flake
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:16:31 -03:00

273 lines
14 KiB
Rust

//! Slash menu (SPEC 21): `slash.list` data, the trigger rule over the
//! registry, insert commands that produce valid markdown, and the palette.
mod common;
use common::temp_vault;
use notescraft_engine::{Error, Registry, Session, register_core};
use serde_json::{Value, json};
use notescraft_markdown::{ItemKind, parse};
/// Writable session plus core registry, and a seeded template.
fn fixture(name: &str, seed: &[(&str, &str)]) -> (common::TempVault, Session, Registry<Session>) {
let vault = temp_vault(name);
common::seed(vault.path(), seed);
let session = Session::open(vault.path()).unwrap_or_else(|error| panic!("{error}"));
let mut registry = Registry::new();
register_core(&mut registry).unwrap_or_else(|error| panic!("{error}"));
(vault, session, registry)
}
/// Runs a command, panicking on failure; returns its JSON result.
fn run(registry: &Registry<Session>, session: &mut Session, id: &str, params: &Value) -> Value {
match registry.run(id, session, params) {
Ok(value) => value,
Err(error) => panic!("{id}: {error}"),
}
}
/// The entries `slash.list` answers with.
fn listed(registry: &Registry<Session>, session: &mut Session, params: &Value) -> Vec<(String, String)> {
let value = run(registry, session, "slash.list", params);
value
.get("entries")
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| {
entry
.get("id")
.and_then(Value::as_str)
.zip(entry.get("label").and_then(Value::as_str).map(str::to_owned))
.map(|(id, label)| (id.to_owned(), label))
})
.collect()
})
.unwrap_or_default()
}
#[test]
fn the_menu_lists_only_slash_commands_with_data() {
let (_vault, mut session, registry) = fixture("slash-list", &[]);
let entries = listed(&registry, &mut session, &json!({}));
assert!(entries.len() >= 28, "{entries:?}");
let names: Vec<&str> = entries.iter().map(|(id, _)| id.as_str()).collect();
for wanted in [
"slash.heading1",
"slash.heading2",
"slash.heading3",
"slash.bullet",
"slash.numbered",
"slash.task",
"slash.quote",
"slash.divider",
"slash.code",
"slash.link_note",
"slash.embed_note",
"slash.embed_image",
"slash.table",
"slash.date",
"slash.template",
"slash.canvas",
] {
assert!(names.contains(&wanted), "`{wanted}` missing from {names:?}");
}
for kind in ["note", "abstract", "info", "todo", "tip", "success", "question", "warning", "failure", "danger", "bug", "example", "quote"] {
assert!(names.contains(&format!("slash.callout.{kind}").as_str()), "callout {kind} missing");
}
assert!(!names.contains(&"slash.list"), "plumbing is not on the menu");
assert!(!names.contains(&"slash.trigger_ok"), "plumbing is not on the menu");
}
#[test]
fn a_query_filters_by_prefix_then_fuzzy() {
let (_vault, mut session, registry) = fixture("slash-query", &[]);
let prefix = listed(&registry, &mut session, &json!({"query": "Head"}));
let ids: Vec<&str> = prefix.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids.first(), Some(&"slash.heading1"), "{ids:?}");
assert_eq!(&ids[..3], &["slash.heading1", "slash.heading2", "slash.heading3"], "prefix matches first, in menu order");
let fuzzy = listed(&registry, &mut session, &json!({"query": "hd1"}));
assert!(fuzzy.iter().any(|(id, _)| id == "slash.heading1"), "{fuzzy:?}");
let none = listed(&registry, &mut session, &json!({"query": "zzzzz"}));
assert_eq!(none, []);
}
#[test]
fn inside_a_code_fence_the_menu_is_empty() {
let (_vault, mut session, registry) = fixture("slash-fence", &[]);
assert_eq!(listed(&registry, &mut session, &json!({"in_code_fence": true})), []);
}
#[test]
fn every_insert_is_plain_markdown_the_parser_keeps() {
let (_vault, mut session, registry) = fixture("slash-parse", &[]);
for id in [
"slash.heading1",
"slash.heading2",
"slash.heading3",
"slash.bullet",
"slash.numbered",
"slash.task",
"slash.quote",
"slash.table",
"slash.code",
"slash.divider",
"slash.link_note",
"slash.embed_note",
"slash.embed_image",
"slash.callout.tip",
] {
let value = run(&registry, &mut session, id, &json!({}));
let text = value.get("text").and_then(Value::as_str).unwrap_or_else(|| panic!("{id}: no text in {value}"));
let cursor = value.get("cursor").and_then(Value::as_u64).unwrap_or_else(|| panic!("{id}: no cursor in {value}"));
assert!(cursor <= text.len() as u64, "{id}: cursor {cursor} past {text:?}");
let doc = parse(text);
assert_eq!(doc.write(), text, "{id}: the insert is lossless markdown");
}
}
#[test]
fn the_heading_task_and_link_inserts_are_recognised() {
let (_vault, mut session, registry) = fixture("slash-nodes", &[]);
let heading = run(&registry, &mut session, "slash.heading2", &json!({}));
let text = heading.get("text").and_then(Value::as_str).unwrap_or_default();
assert!(parse(text).items.iter().any(|item| matches!(item.kind, ItemKind::Heading { level: 2, .. })), "{text}");
let task = run(&registry, &mut session, "slash.task", &json!({}));
let text = task.get("text").and_then(Value::as_str).unwrap_or_default();
assert!(parse(text).items.iter().any(|item| matches!(item.kind, ItemKind::TaskItem { checked: false })), "{text}");
let link = run(&registry, &mut session, "slash.link_note", &json!({}));
let text = link.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(link.get("cursor").and_then(Value::as_u64), Some(2), "{text}");
assert_eq!(text, "[[]]", "the empty trigger the note picker fills in");
// With a target the parser recognises the wikilink.
assert!(parse("[[Note]]").items.iter().any(|item| matches!(item.kind, ItemKind::WikiLink { .. })));
let embed = run(&registry, &mut session, "slash.embed_note", &json!({}));
let text = embed.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(text, "![[]]");
let image = run(&registry, &mut session, "slash.embed_image", &json!({}));
let text = image.get("text").and_then(Value::as_str).unwrap_or_default();
assert!(parse(text).items.iter().any(|item| matches!(&item.kind, ItemKind::Embed { target, .. } if target == ".png")), "{text}");
assert_eq!(image.get("cursor").and_then(Value::as_u64), Some(4), "{text}");
}
#[test]
fn every_callout_kind_parses_back_as_a_callout() {
let (_vault, mut session, registry) = fixture("slash-callouts", &[]);
for kind in ["note", "abstract", "info", "todo", "tip", "success", "question", "warning", "failure", "danger", "bug", "example", "quote"] {
let id = format!("slash.callout.{kind}");
let value = run(&registry, &mut session, &id, &json!({}));
let text = value.get("text").and_then(Value::as_str).unwrap_or_else(|| panic!("{id}: {value}"));
let doc = parse(text);
let callouts: Vec<&ItemKind> = doc.items.iter().map(|item| &item.kind).filter(|kind| matches!(kind, ItemKind::Callout { .. })).collect();
assert_eq!(callouts.len(), 1, "{id}: {text:?} → {callouts:?}");
let Some(ItemKind::Callout { kind: parsed, .. }) = callouts.first().copied() else { continue };
assert_eq!(parsed.as_str(), kind, "{id}: {text:?}");
// The caret sits on the title placeholder, which the answer selects.
let cursor = value.get("cursor").and_then(Value::as_u64).unwrap_or_default();
let selection_end = value.get("selection_end").and_then(Value::as_u64).unwrap_or_default();
assert!(selection_end > cursor, "{id}: cursor {cursor} end {selection_end}");
}
}
#[test]
fn the_callout_command_validates_its_kind() {
let (_vault, mut session, registry) = fixture("slash-callout-param", &[]);
let value = run(&registry, &mut session, "slash.callout", &json!({"kind": "warning"}));
let text = value.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(text, "> [!warning] Title\n> ", "{text}");
match registry.run("slash.callout", &mut session, &json!({"kind": "nope"})) {
Err(Error::Command { source, .. }) => assert_eq!(source.code, "invalid_params", "{source}"),
other => panic!("expected a command error, got {other:?}"),
}
}
#[test]
fn the_table_insert_builds_the_requested_grid() {
let (_vault, mut session, registry) = fixture("slash-table", &[]);
let value = run(&registry, &mut session, "slash.table", &json!({"rows": 1, "cols": 2}));
let text = value.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(text, "| A | B |\n| --- | --- |\n| | |\n", "{text}");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.first().copied(), Some("| A | B |"), "header row");
assert_eq!(lines.get(1).copied(), Some("| --- | --- |"), "separator row");
assert_eq!(lines.get(2).copied(), Some("| | |"), "exactly one body row");
let defaults = run(&registry, &mut session, "slash.table", &json!({}));
let text = defaults.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(text.lines().count(), 4, "header, separator and two body rows: {text}");
for params in [json!({"rows": 101}), json!({"cols": 11}), json!({"cols": 0}), json!({"rows": -1})] {
assert!(matches!(registry.run("slash.table", &mut session, &params), Err(Error::Command { .. })), "{params}");
}
}
#[test]
fn the_date_insert_uses_the_daily_format() {
let (_vault, mut session, registry) = fixture("slash-date", &[]);
let value = run(&registry, &mut session, "slash.date", &json!({"utc_offset_minutes": 0}));
let text = value.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(text.len(), 10, "YYYY-MM-DD default daily format: {text}");
assert!(text.chars().filter(|c| *c == '-').count() == 2, "{text}");
let cursor = value.get("cursor").and_then(Value::as_u64).unwrap_or_default();
assert_eq!(cursor, text.len() as u64, "the caret follows the date");
}
#[test]
fn the_template_insert_expands_variables() {
let (_vault, mut session, registry) = fixture("slash-template", &[("Templates/Meeting.md", "# {{title}}\n\n{{time}} {{time:HH:MMA}}\n")]);
let value = run(&registry, &mut session, "slash.template", &json!({"name": "Templates/Meeting.md", "utc_offset_minutes": 0}));
let text = value.get("text").and_then(Value::as_str).unwrap_or_default();
assert!(text.contains("# Meeting"), "{text}");
assert!(!text.contains("{{title}}"), "the variable must be expanded: {text}");
assert!(text.contains("AM") || text.contains("PM"), "the {{time:HH:MMA}} hint expands: {text:?}");
assert!(!text.contains("{{"), "no variable is left unexpanded: {text:?}");
match registry.run("slash.template", &mut session, &json!({"name": "Templates/Nope.md"})) {
Err(Error::Command { source, .. }) => assert_eq!(source.code, "not_found", "{source}"),
other => panic!("expected not_found, got {other:?}"),
}
}
#[test]
fn the_canvas_insert_creates_the_canvas_file() {
let (vault, mut session, registry) = fixture("slash-canvas", &[]);
let first = run(&registry, &mut session, "slash.canvas", &json!({}));
let text = first.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(text, "![[Untitled.canvas]]", "{text}");
let second = run(&registry, &mut session, "slash.canvas", &json!({}));
let text = second.get("text").and_then(Value::as_str).unwrap_or_default();
assert_eq!(text, "![[Untitled 1.canvas]]", "a collision suffixes the name: {text}");
assert!(std::fs::exists(vault.path().join("Untitled.canvas")).unwrap_or(false));
assert!(std::fs::exists(vault.path().join("Untitled 1.canvas")).unwrap_or(false));
assert!(session.index().files().contains(&"Untitled.canvas"));
assert!(parse(text).items.iter().any(|item| matches!(&item.kind, ItemKind::Embed { target, .. } if target == "Untitled 1.canvas")), "{text}");
}
#[test]
fn insert_commands_are_on_the_insert_menu_of_the_palette() {
let (_vault, session, registry) = fixture("slash-palette", &[]);
let infos = registry.list(&session);
let heading = infos
.iter()
.find(|info| info.id == "slash.heading1")
.unwrap_or_else(|| panic!("slash.heading1 is missing: {:?}", infos.iter().map(|info| &info.id).collect::<Vec<_>>()));
assert_eq!(heading.menu_path, ["Insert".to_owned(), "Heading 1".to_owned()], "the palette lists it like every command");
assert!(heading.enabled, "it runs on a writable session");
let spec = registry.get("slash.heading1").unwrap_or_else(|| panic!("the spec is registered"));
assert!(spec.slash, "flagged as a slash entry");
assert!(registry.get("slash.list").is_some_and(|spec| !spec.slash), "plumbing is not flagged");
}
#[test]
fn the_trigger_command_reports_the_rule() {
let (_vault, mut session, registry) = fixture("slash-trigger", &[]);
let ok = run(&registry, &mut session, "slash.trigger_ok", &json!({"line": "a /", "col": 2}));
assert_eq!(ok.get("ok").and_then(Value::as_bool), Some(true));
let no = run(&registry, &mut session, "slash.trigger_ok", &json!({"line": "a/b", "col": 1}));
assert_eq!(no.get("ok").and_then(Value::as_bool), Some(false));
let code = run(&registry, &mut session, "slash.trigger_ok", &json!({"line": "`x /", "col": 6}));
assert_eq!(code.get("ok").and_then(Value::as_bool), Some(false));
assert!(
matches!(registry.run("slash.trigger_ok", &mut session, &json!({"line": "/", "col": -1})), Err(Error::Command { source, .. }) if source.code == "invalid_params")
);
}