//! 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, } 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 { 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 = (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"); }