aeac86ddd2
.base model (filters, formulas, properties, views; unknown keys kept in order; limits and invalid-value errors), evaluator with the D5 type rules, lazy if and boolean operators, memoised formulas with cycle errors, depth cap 200, string/number/object/list/date/link/file/regexp functions with lambdas (regex through notescraft-regex), and the map model (Web Mercator, tile ids, viewport tiles, grid clustering, coordinate and colour parsing, OSM attribution constants; Paris z10 is tile 518/352). Bases tasks 2, 5, 6 (Claude Sonnet) and 8 (MiniMax M3.1 Flash, OpenCode Zen free route). Verified: notescraft-bases suites all pass, clippy -D warnings, xtask layers and tree clean. Known gaps: toFixed rounds ties to even, list.unique is quadratic. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
313 lines
13 KiB
Rust
313 lines
13 KiB
Rust
//! Evaluator core: operators, laziness, formulas and the global, any, string, number and object
|
|
//! functions (normal, null and wrong-type case for each).
|
|
|
|
mod common;
|
|
|
|
use common::{TestRow, cases, ev, is_error, list, n, run, s};
|
|
use notescraft_bases::Value;
|
|
|
|
fn errors(sources: &[&str]) {
|
|
for src in sources {
|
|
assert!(is_error(&ev(src)), "{src} gave {:?}", ev(src));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn operators() {
|
|
cases(&[
|
|
("1 + 2 * 3", n(7.0)),
|
|
("(1 + 2) * 3", n(9.0)),
|
|
("7 % 4", n(3.0)),
|
|
("-(2)", n(-2.0)),
|
|
("!0", Value::Bool(true)),
|
|
("\"a\" + 1", s("a1")),
|
|
("1 + \"a\"", s("1a")),
|
|
("\"a\" + \"b\"", s("ab")),
|
|
("null + 1", Value::Null),
|
|
("1 < null", Value::Bool(false)),
|
|
("null == null", Value::Bool(true)),
|
|
("null != 1", Value::Bool(true)),
|
|
("\"a\" == \"a\"", Value::Bool(true)),
|
|
("1 == \"1\"", Value::Bool(false)),
|
|
("[1, 2] == [1, 2]", Value::Bool(true)),
|
|
("\"b\" > \"a\"", Value::Bool(true)),
|
|
("2 >= 2 && 1 <= 1", Value::Bool(true)),
|
|
("false && 1 / 0", Value::Bool(false)),
|
|
("true || 1 / 0", Value::Bool(true)),
|
|
("0 || \"x\"", Value::Bool(true)),
|
|
("date(\"2024-01-01\") < \"2024-02-01\"", Value::Bool(true)),
|
|
("date(\"2024-01-01\") == \"2024-01-01\"", Value::Bool(true)),
|
|
("(now() + \"1d\") - now()", n(86_400_000.0)),
|
|
("(date(\"2024-12-01\") + \"1M 4h 3m\").toString()", s("2025-01-01 04:03:00")),
|
|
("(date(\"2024-01-31\") + \"1M\").toString()", s("2024-02-29")),
|
|
("(date(\"2024-03-01\") - \"1d\").toString()", s("2024-02-29")),
|
|
("date(\"2024-03-02\") - date(\"2024-03-01\")", n(86_400_000.0)),
|
|
("(duration(\"5h\") * 2).toString()", s("10h")),
|
|
("(duration(\"1d\") + duration(\"2h\")).toString()", s("1d 2h")),
|
|
]);
|
|
errors(&["1 / 0", "5 % 0", "2 * duration(\"5h\")", "[1] + 1", "-\"a\"", "1 / 0 + 1", "date(\"2024-01-01\") + 3", "duration(\"1d\") * 1.5"]);
|
|
}
|
|
|
|
#[test]
|
|
fn if_is_lazy() {
|
|
cases(&[
|
|
("if(true, 1, 1 / 0)", n(1.0)),
|
|
("if(false, 1 / 0, 2)", n(2.0)),
|
|
("if(false, 1)", Value::Null),
|
|
("if(\"\", 1, 2)", n(2.0)),
|
|
("if([], 1, 2)", n(2.0)),
|
|
("if([0], 1, 2)", n(1.0)),
|
|
]);
|
|
errors(&["if(1 / 0, 1, 2)", "if(true)", "if(1, 2, 3, 4)"]);
|
|
}
|
|
|
|
#[test]
|
|
fn global_functions() {
|
|
cases(&[
|
|
("now().toString()", s("2024-12-01 12:00:00")),
|
|
("today().toString()", s("2024-12-01")),
|
|
("date(\"2024-12-01 04:03:00\").toString()", s("2024-12-01 04:03:00")),
|
|
("date(null)", Value::Null),
|
|
("duration(\"1M 4h\").toString()", s("1M 4h")),
|
|
("duration(null)", Value::Null),
|
|
("number(\"3.4\")", n(3.4)),
|
|
("number(true)", n(1.0)),
|
|
("number(false)", n(0.0)),
|
|
("number(date(\"1970-01-02\"))", n(86_400_000.0)),
|
|
("number(null)", Value::Null),
|
|
("min(3, 1, 2)", n(1.0)),
|
|
("max(3, 1, 2)", n(3.0)),
|
|
("max(null, 1)", Value::Null),
|
|
("list(\"a\")", list(vec![s("a")])),
|
|
("list([1, 2])", list(vec![n(1.0), n(2.0)])),
|
|
("list(null)", list(vec![])),
|
|
("link(\"[[Note|Shown]]\")", Value::Link { target: "Note".into(), display: "Shown".into() }),
|
|
("link(\"Note\", \"X\")", Value::Link { target: "Note".into(), display: "X".into() }),
|
|
("link(file)", Value::Link { target: "Folder/Note.md".into(), display: String::new() }),
|
|
("link(null)", Value::Null),
|
|
("escapeHTML(\"<a href=\\\"x\\\">&'</a>\")", s("<a href="x">&'</a>")),
|
|
("escapeHTML(null)", Value::Null),
|
|
("html(\"<b>x</b>\")", Value::Html("<b>x</b>".into())),
|
|
("image(\"a.png\")", Value::Image("a.png".into())),
|
|
("image(file)", Value::Image("Folder/Note.md".into())),
|
|
("icon(\"arrow-right\")", Value::Icon("arrow-right".into())),
|
|
]);
|
|
errors(&[
|
|
"date(\"nope\")",
|
|
"date(1)",
|
|
"duration(\"x\")",
|
|
"duration(1)",
|
|
"number(\"x\")",
|
|
"number([1])",
|
|
"min()",
|
|
"max(\"a\", 1)",
|
|
"link(1)",
|
|
"escapeHTML(1)",
|
|
"html(1)",
|
|
"icon(1)",
|
|
"now(1)",
|
|
"nosuch(1)",
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn random_is_seeded_and_in_range() {
|
|
let row = TestRow::new("a.md");
|
|
let one = run("random()", &row, &[], None, None);
|
|
assert_eq!(one, run("random()", &row, &[], None, None));
|
|
assert!(matches!(one, Value::Number(x) if (0.0..1.0).contains(&x)));
|
|
assert_ne!(run("random()", &TestRow::new("b.md"), &[], None, None), one);
|
|
assert_ne!(run("[random(), random()][0]", &row, &[], None, None), run("[random(), random()][1]", &row, &[], None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn any_functions() {
|
|
cases(&[
|
|
("1.isTruthy()", Value::Bool(true)),
|
|
("(0).isTruthy()", Value::Bool(false)),
|
|
("\"\".isTruthy()", Value::Bool(false)),
|
|
("null.isTruthy()", Value::Bool(false)),
|
|
("[].isTruthy()", Value::Bool(false)),
|
|
("\"example\".isType(\"string\")", Value::Bool(true)),
|
|
("true.isType(\"boolean\")", Value::Bool(true)),
|
|
("null.isType(\"null\")", Value::Bool(true)),
|
|
("[1].isType(\"string\")", Value::Bool(false)),
|
|
("123.toString()", s("123")),
|
|
("(1.5).toString()", s("1.5")),
|
|
("null.toString()", s("")),
|
|
("[1, \"a\"].toString()", s("1, a")),
|
|
("true.toString()", s("true")),
|
|
]);
|
|
errors(&["1.isType(2)", "1.isTruthy(1)"]);
|
|
}
|
|
|
|
#[test]
|
|
fn string_functions() {
|
|
cases(&[
|
|
("\"hello\".length", n(5.0)),
|
|
("\"hello\".contains(\"ell\")", Value::Bool(true)),
|
|
("\"hello\".contains(\"x\")", Value::Bool(false)),
|
|
("\"hello\".containsAll(\"h\", \"e\")", Value::Bool(true)),
|
|
("\"hello\".containsAll(\"h\", \"x\")", Value::Bool(false)),
|
|
("\"hello\".containsAny(\"x\", \"y\", \"e\")", Value::Bool(true)),
|
|
("\"hello\".containsAny(\"x\")", Value::Bool(false)),
|
|
("\"hello\".endsWith(\"lo\")", Value::Bool(true)),
|
|
("\"hello\".startsWith(\"he\")", Value::Bool(true)),
|
|
("\"hello\".startsWith(\"lo\")", Value::Bool(false)),
|
|
("\"\".isEmpty()", Value::Bool(true)),
|
|
("\"Hello world\".isEmpty()", Value::Bool(false)),
|
|
("null.isEmpty()", Value::Bool(true)),
|
|
("\"HeLLo\".lower()", s("hello")),
|
|
("\"a:b:c:d\".replace(\":\", \"-\")", s("a-b-c-d")),
|
|
("\"123\".repeat(2)", s("123123")),
|
|
("\"123\".repeat(0)", s("")),
|
|
("\"hello\".reverse()", s("olleh")),
|
|
("\"hello\".slice(1, 4)", s("ell")),
|
|
("\"hello\".slice(2)", s("llo")),
|
|
("\"hello\".slice(-3)", s("llo")),
|
|
("\"hello\".slice(3, 1)", s("")),
|
|
("\"a,b,c,d\".split(\",\", 3)", list(vec![s("a"), s("b"), s("c")])),
|
|
("\"a,b\".split(\",\")", list(vec![s("a"), s("b")])),
|
|
("\"ab\".split(\"\")", list(vec![s("a"), s("b")])),
|
|
("\"hello world\".title()", s("Hello World")),
|
|
("\" hi \".trim()", s("hi")),
|
|
("null.lower()", Value::Null),
|
|
("null.trim()", Value::Null),
|
|
("\"héllo\".length", n(5.0)),
|
|
("\"abc\"[1]", s("b")),
|
|
]);
|
|
errors(&[
|
|
"\"a\".contains(1)",
|
|
"\"a\".contains()",
|
|
"\"a\".repeat(-1)",
|
|
"\"a\".repeat(\"x\")",
|
|
"\"a\".repeat(2000000)",
|
|
"\"a\".replace(1, 2)",
|
|
"\"a\".slice(\"x\")",
|
|
"\"a\".split(1)",
|
|
"\"a\".nosuch()",
|
|
"\"a\".lower(1)",
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn number_functions() {
|
|
cases(&[
|
|
("(-5).abs()", n(5.0)),
|
|
("(2.1).ceil()", n(3.0)),
|
|
("(2.9).floor()", n(2.0)),
|
|
("(2.5).round()", n(3.0)),
|
|
("(2.3333).round(2)", n(2.33)),
|
|
("(3.14159).toFixed(2)", s("3.14")),
|
|
("(3).toFixed(0)", s("3")),
|
|
("(5).isEmpty()", Value::Bool(false)),
|
|
("null.abs()", Value::Null),
|
|
("null.toFixed(2)", Value::Null),
|
|
]);
|
|
errors(&["(1).toFixed(-1)", "(1).toFixed(101)", "(1).toFixed(\"x\")", "(1).round(99)", "(1).abs(1)", "\"a\".abs()"]);
|
|
}
|
|
|
|
#[test]
|
|
fn object_functions() {
|
|
cases(&[
|
|
("({\"a\": 1, \"b\": 2}).keys()", list(vec![s("a"), s("b")])),
|
|
("({\"a\": 1, \"b\": 2}).values()", list(vec![n(1.0), n(2.0)])),
|
|
("({}).isEmpty()", Value::Bool(true)),
|
|
("({\"a\": 1}).isEmpty()", Value::Bool(false)),
|
|
("({\"a\": 1}).a", n(1.0)),
|
|
("({\"a\": 1}).b", Value::Null),
|
|
("({\"a\": 1})[\"a\"]", n(1.0)),
|
|
("null.keys()", Value::Null),
|
|
]);
|
|
errors(&["({}).keys(1)", "({}).nosuch()"]);
|
|
}
|
|
|
|
fn book() -> TestRow {
|
|
let mut row = TestRow::new("Books/Dune.md").prop("price", n(10.0)).prop("age", n(4.0)).prop("x y", s("spaced")).tags(&["#book"]);
|
|
row.size = 42;
|
|
row
|
|
}
|
|
|
|
#[test]
|
|
fn properties_and_file_fields() {
|
|
let row = book();
|
|
let at = |src: &str| run(src, &row, &[], None, None);
|
|
assert_eq!(at("price"), n(10.0));
|
|
assert_eq!(at("note.price"), n(10.0));
|
|
assert_eq!(at("note[\"x y\"]"), s("spaced"));
|
|
assert_eq!(at("missing"), Value::Null);
|
|
assert_eq!(at("missing + 1"), Value::Null);
|
|
assert_eq!(at("file.name"), s("Dune.md"));
|
|
assert_eq!(at("file.basename"), s("Dune"));
|
|
assert_eq!(at("file.ext"), s("md"));
|
|
assert_eq!(at("file.folder"), s("Books"));
|
|
assert_eq!(at("file.path"), s("Books/Dune.md"));
|
|
assert_eq!(at("file.size"), n(42.0));
|
|
assert_eq!(at("file.tags"), list(vec![s("#book")]));
|
|
assert!(is_error(&at("file.nosuch")));
|
|
assert!(is_error(&at("formula")));
|
|
assert_eq!(at("this"), Value::Null);
|
|
assert_eq!(at("this.file"), Value::Null);
|
|
}
|
|
|
|
#[test]
|
|
fn this_points_at_the_given_row() {
|
|
let row = book();
|
|
let this = TestRow::new("Embed/Host.md").prop("title", s("Host"));
|
|
let at = |src: &str| run(src, &row, &[], None, Some(&this));
|
|
assert_eq!(at("this.file.name"), s("Host.md"));
|
|
assert_eq!(at("this.title"), s("Host"));
|
|
assert_eq!(at("this.nothing"), Value::Null);
|
|
assert_eq!(at("this == this"), Value::Bool(true));
|
|
assert_eq!(at("file.name"), s("Dune.md"));
|
|
}
|
|
|
|
#[test]
|
|
fn doc_formulas_ppu_and_formatted_price() {
|
|
let formulas = [("formatted_price", "if(price, price.toFixed(2) + \" dollars\")"), ("ppu", "(price / age).toFixed(2)")];
|
|
let row = book();
|
|
assert_eq!(run("formula.ppu", &row, &formulas, None, None), s("2.50"));
|
|
assert_eq!(run("formula.formatted_price", &row, &formulas, None, None), s("10.00 dollars"));
|
|
assert_eq!(run("formula[\"ppu\"]", &row, &formulas, None, None), s("2.50"));
|
|
let no_price = TestRow::new("a.md").prop("age", n(4.0));
|
|
assert_eq!(run("formula.formatted_price", &no_price, &formulas, None, None), Value::Null);
|
|
assert_eq!(run("formula.ppu", &no_price, &formulas, None, None), Value::Null);
|
|
let zero_age = TestRow::new("a.md").prop("price", n(1.0)).prop("age", n(0.0));
|
|
assert_eq!(run("formula.ppu", &zero_age, &formulas, None, None), Value::Error("division by zero".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn formula_cycles_unknowns_and_bad_sources() {
|
|
let formulas = [("a", "formula.b + 1"), ("b", "formula.a"), ("c", "formula.a"), ("d", "1 + 1"), ("self", "formula.self"), ("bad", "1 +")];
|
|
let row = TestRow::new("a.md");
|
|
let at = |src: &str| run(src, &row, &formulas, None, None);
|
|
for name in ["formula.a", "formula.b", "formula.self"] {
|
|
assert!(matches!(at(name), Value::Error(m) if m.contains("cycle")), "{name}");
|
|
}
|
|
assert!(matches!(at("formula.a"), Value::Error(m) if m == "formula cycle: a -> b -> a"));
|
|
assert!(is_error(&at("formula.c")));
|
|
assert_eq!(at("formula.d"), n(2.0));
|
|
assert!(matches!(at("formula.bad"), Value::Error(m) if m.contains("syntax")));
|
|
assert!(matches!(at("formula.nope"), Value::Error(m) if m.contains("unknown formula")));
|
|
}
|
|
|
|
#[test]
|
|
fn formulas_are_memoised_and_depth_is_bounded() {
|
|
let doubling: Vec<(String, String)> =
|
|
(0..60).map(|i| (format!("f{i}"), if i == 0 { "1".to_string() } else { format!("formula.f{} + formula.f{}", i - 1, i - 1) })).collect();
|
|
let borrowed: Vec<(&str, &str)> = doubling.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
|
|
let row = TestRow::new("a.md");
|
|
assert!(matches!(run("formula.f59", &row, &borrowed, None, None), Value::Number(x) if x > 5e17));
|
|
let chain: Vec<(String, String)> = (0..200).map(|i| (format!("g{i}"), if i == 0 { "1".to_string() } else { format!("formula.g{} + 1", i - 1) })).collect();
|
|
let borrowed: Vec<(&str, &str)> = chain.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
|
|
assert_eq!(run("formula.g50", &row, &borrowed, None, None), n(51.0));
|
|
assert!(is_error(&run("formula.g199", &row, &borrowed, None, None)));
|
|
}
|
|
|
|
#[test]
|
|
fn errors_propagate_and_never_panic() {
|
|
errors(&["(1 / 0) + 1", "[1 / 0]", "({\"a\": 1 / 0})", "(1 / 0).toString()", "\"a\".contains(1 / 0)", "max(1, 1 / 0)"]);
|
|
let deep = format!("{}1{}", "(".repeat(60), ")".repeat(60));
|
|
assert_eq!(ev(&deep), n(1.0));
|
|
}
|