Files
Marcos Paulo 77d538dd97
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: Bases Cards, List, embeds, Map view, Maps settings and inline cell editing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 04:45:32 -03:00

171 lines
7.2 KiB
Rust

//! The read commands over a `.base` file: `base.list`, `base.read`, `base.properties`, `base.query`.
mod common;
use common::{seed, temp_vault};
use notescraft_engine::{Registry, Session, register_core};
use serde_json::{Value, json};
const BOOKS: &str = "filters:
and:
- 'file.ext == \"md\"'
- 'note.type == \"book\"'
properties:
note.year:
displayName: Year
views:
- type: table
name: Books
order: [file.name, note.year, note.rating]
sort:
- property: note.year
direction: DESC
summaries:
note.rating: Sum
- type: table
name: By status
order: [file.name, note.status]
groupBy:
property: note.status
direction: ASC
limit: 6
";
struct Fixture {
_vault: common::TempVault,
session: Session,
reg: Registry<Session>,
}
impl Fixture {
fn run(&mut self, id: &str, params: &Value) -> Value {
self.reg.run(id, &mut self.session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
}
fn fail(&mut self, id: &str, params: &Value) -> (String, String) {
let error = self.reg.run(id, &mut self.session, params).expect_err("must fail");
match error {
notescraft_engine::Error::Command { source, .. } => (source.code, source.message),
other => panic!("{id}: not a command error: {other:?}"),
}
}
}
fn fixture(name: &str) -> Fixture {
let vault = temp_vault(name);
let mut files: Vec<(String, String)> = (0..15)
.map(|i| {
let status = if i % 2 == 0 { "read" } else { "todo" };
(format!("Books/book-{i:02}.md"), format!("---\ntype: book\nyear: {}\nrating: {}\nstatus: {status}\n---\n# Book {i}\n", 1950 + i * 5, i % 5 + 1))
})
.collect();
files.extend((0..5).map(|i| (format!("Articles/art-{i}.md"), format!("---\ntype: article\nyear: {}\n---\nbody {i}\n", 2000 + i))));
files.push(("Pics/cover.png".into(), "x".into()));
files.push(("Pics/cover.png".into(), "x".into()));
files.push(("Books.base".into(), BOOKS.into()));
files.push(("Broken.base".into(), "views: [\n".into()));
let borrowed: Vec<(&str, &str)> = files.iter().map(|(p, c)| (p.as_str(), c.as_str())).collect();
seed(vault.path(), &borrowed);
let session = Session::open(vault.path()).expect("open");
let mut reg = Registry::new();
register_core(&mut reg).expect("register");
Fixture { _vault: vault, session, reg }
}
fn names(result: &Value) -> Vec<String> {
result["rows"].as_array().expect("rows").iter().map(|r| r["cells"][0].as_str().unwrap_or_default().to_owned()).collect()
}
#[test]
fn list_read_and_properties() {
let mut f = fixture("base_read");
let list = f.run("base.list", &Value::Null);
let list = list.as_array().expect("array");
assert_eq!(list.len(), 2);
assert_eq!(list[0]["path"], "Books.base");
assert_eq!(list[0]["views"], json!(["Books", "By status"]));
assert!(list[1]["error"].as_str().is_some(), "a broken base is listed with its error");
let model = f.run("base.read", &json!({ "path": "Books.base" }));
assert_eq!(model["views"][1]["limit"], 6);
assert_eq!(model["properties"]["note.year"]["displayName"], "Year");
assert_eq!(model["path"], "Books.base");
assert_eq!(model["text"], BOOKS, "the raw file text rides along for embeds");
let props = f.run("base.properties", &Value::Null);
let find = |name: &str| props["properties"].as_array().expect("array").iter().find(|p| p["name"] == name).cloned().expect("property");
assert_eq!(find("year"), json!({ "name": "year", "type": "number", "count": 20 }));
assert_eq!(find("status")["count"], 15);
assert_eq!(find("type")["type"], "text");
}
#[test]
fn filter_sort_summary() {
let mut f = fixture("base_query");
let result = f.run("base.query", &json!({ "path": "Books.base" }));
assert_eq!(result["total"], 15);
assert_eq!(result["columns"][1]["name"], "Year");
let first = names(&result);
assert_eq!(first.len(), 15);
assert_eq!(first[0], "book-14.md", "newest first");
assert_eq!(first[14], "book-00.md");
assert_eq!(result["rows"][0]["cells"][1], 2020);
// ratings 1..5 repeated three times
assert_eq!(result["summaries"][2], 45);
assert_eq!(result["errors"], 0);
}
#[test]
fn groups_view_by_name_and_slices() {
let mut f = fixture("base_groups");
let result = f.run("base.query", &json!({ "path": "Books.base", "view": "By status" }));
assert_eq!(result["total"], 15);
let groups = result["groups"].as_array().expect("groups");
assert_eq!(groups.len(), 1, "limit 6 cuts after the first group: read has 8 rows");
assert_eq!(groups[0]["key"], "read");
assert_eq!(groups[0]["count"], 6);
let slice = f.run("base.query", &json!({ "path": "Books.base", "offset": 2, "limit": 3 }));
assert_eq!(names(&slice), ["book-12.md", "book-11.md", "book-10.md"]);
let by_index = f.run("base.query", &json!({ "path": "Books.base", "view": 1, "limit": 2 }));
assert_eq!(by_index["rows"].as_array().expect("rows").len(), 2);
}
#[test]
fn inline_yaml_and_this() {
let mut f = fixture("base_inline");
let yaml = "filters: 'file.folder == \"Articles\"'\nviews:\n - type: table\n order: [file.name, note.year]\n";
let result = f.run("base.query", &json!({ "yaml": yaml }));
assert_eq!(result["total"], 5);
let by_this = "filters: 'note.year == this.year'\nviews:\n - type: table\n order: [file.name]\n";
let result = f.run("base.query", &json!({ "yaml": by_this, "this": "Articles/art-2.md" }));
assert_eq!(names(&result), ["art-2.md"]);
let sizes = "filters: 'file.size > 0'\nviews:\n - type: table\n order: [file.name]\n";
assert_eq!(f.run("base.query", &json!({ "yaml": sizes }))["total"], 20, "the 20 notes carry a size; other files are not stat-ed");
}
#[test]
fn bad_input_has_a_code() {
let mut f = fixture("base_errors");
let (code, message) = f.fail("base.query", &json!({ "yaml": "views:\n - type: [\n" }));
assert_eq!(code, "invalid_params");
assert!(message.contains("line"), "{message}");
assert_eq!(f.fail("base.query", &json!({ "path": "Nope.base" })).0, "not_found");
assert_eq!(f.fail("base.read", &json!({ "path": "Books/book-00.md" })).0, "invalid_path");
assert_eq!(f.fail("base.query", &json!({ "path": "Books.base", "limit": 2001 })).0, "invalid_params");
assert_eq!(f.fail("base.query", &json!({ "path": "Books.base", "view": "Missing" })).0, "invalid_params");
assert_eq!(f.fail("base.query", &json!({})).0, "invalid_params");
assert_eq!(f.fail("base.query", &json!({ "path": "Books.base", "yaml": "views: []" })).0, "invalid_params");
assert_eq!(f.fail("base.read", &json!({ "path": "Broken.base" })).0, "invalid_params");
}
#[test]
fn resolve_names_attachments_and_the_root() {
let mut f = fixture("base_resolve");
let result = f.run("base.resolve", &json!({ "from": "Books/book-00.md", "targets": ["cover.png", "Pics/cover.png", "nope.png", 3] }));
assert_eq!(result["paths"], json!(["Pics/cover.png", "Pics/cover.png", null, null]));
assert!(result["root"].as_str().is_some_and(|root| !root.is_empty()));
let too_many: Vec<Value> = (0..65).map(|i| json!(format!("{i}.png"))).collect();
assert_eq!(f.fail("base.resolve", &json!({ "from": "a.md", "targets": too_many })).0, "invalid_params");
}