9993a66fee
notescraft-bases (L2, no I/O): hand-written lossless YAML subset parser and emitter with limits (1 MiB, depth 12, 10000 nodes), value and date types with durations, month clamp and a total order across types (Bases tasks 1 and 4). Built by MiniMax M3.1 Flash (OpenCode Zen free route). Verified as part of the full cargo xtask ci run on the combined tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
9.5 KiB
Rust
238 lines
9.5 KiB
Rust
//! Values, types, dates and durations.
|
|
|
|
use std::cmp::Ordering;
|
|
|
|
use notescraft_bases::{Date, Duration, Value, add_duration, compare, loose_eq, parse_duration, sub_dates, truthy};
|
|
|
|
/// A date read from text, panicking on a bad fixture.
|
|
fn day(text: &str) -> Date {
|
|
Date::parse(text).expect("a date")
|
|
}
|
|
|
|
/// A duration read from text, panicking on a bad fixture.
|
|
fn span(text: &str) -> Duration {
|
|
parse_duration(text).expect("a duration")
|
|
}
|
|
|
|
#[test]
|
|
fn the_documentation_example_of_a_date_plus_durations() {
|
|
let start = day("2024-12-01");
|
|
let result = add_duration(start, span("1M")).expect("a date");
|
|
let result = add_duration(result, span("4h")).expect("a date");
|
|
let result = add_duration(result, span("3m")).expect("a date");
|
|
assert_eq!(result.format_iso(), "2025-01-01 04:03:00");
|
|
}
|
|
|
|
#[test]
|
|
fn the_documentation_example_of_a_day_between_two_dates() {
|
|
let now = Date::now(1_700_000_000_000);
|
|
let later = add_duration(now, span("1d")).expect("a date");
|
|
assert_eq!(sub_dates(later, now).expect("a difference"), 86_400_000);
|
|
}
|
|
|
|
#[test]
|
|
fn month_arithmetic_clamps_to_the_last_day() {
|
|
let leap = add_duration(day("2024-01-31"), span("1M")).expect("a date");
|
|
assert_eq!(leap.format_iso(), "2024-02-29");
|
|
let common = add_duration(day("2025-01-31"), span("1M")).expect("a date");
|
|
assert_eq!(common.format_iso(), "2025-02-28");
|
|
let forward = add_duration(day("2024-12-31"), span("1M")).expect("a date");
|
|
assert_eq!(forward.format_iso(), "2025-01-31");
|
|
let back = add_duration(day("2024-03-31"), span("-1M")).expect("a date");
|
|
assert_eq!(back.format_iso(), "2024-02-29");
|
|
}
|
|
|
|
#[test]
|
|
fn leap_years_and_impossible_dates() {
|
|
assert!(Date::parse("2024-02-29").is_ok());
|
|
assert!(Date::parse("2000-02-29").is_ok());
|
|
assert!(Date::parse("2023-02-29").is_err());
|
|
assert!(Date::parse("1900-02-29").is_err());
|
|
assert!(Date::parse("2024-13-01").is_err());
|
|
assert!(Date::parse("2024-12-32").is_err());
|
|
assert!(Date::parse("2024-12-00").is_err());
|
|
assert!(Date::parse("not a date").is_err());
|
|
assert!(Date::parse("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn durations_read_the_documented_units() {
|
|
let mixed = span("1M 4h");
|
|
assert_eq!(mixed.months(), 1);
|
|
assert_eq!(mixed.ms(), 4 * 60 * 60 * 1000);
|
|
assert_eq!(span("2 hours").ms(), 2 * 60 * 60 * 1000);
|
|
assert_eq!(span("1M4h").ms(), 4 * 60 * 60 * 1000);
|
|
assert_eq!(span("1y").months(), 12);
|
|
assert_eq!(span("2w").ms(), 14 * 24 * 60 * 60 * 1000);
|
|
assert_eq!(span("90m").ms(), 90 * 60 * 1000);
|
|
assert_eq!(span("30s").ms(), 30 * 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn negative_durations_move_backwards() {
|
|
let negative = span("-1d");
|
|
assert_eq!(negative.ms(), -86_400_000);
|
|
assert_eq!(negative.months(), 0);
|
|
let back = add_duration(day("2024-12-02"), negative).expect("a date");
|
|
assert_eq!(back.format_iso(), "2024-12-01");
|
|
let mixed = span("-2h");
|
|
assert_eq!(mixed.ms(), -2 * 60 * 60 * 1000);
|
|
assert_eq!(mixed.to_display_string(), "-2h");
|
|
assert_eq!(span("1M -2h").to_display_string(), "1M -2h");
|
|
}
|
|
|
|
#[test]
|
|
fn broken_durations_are_refused() {
|
|
assert!(parse_duration("1").is_err());
|
|
assert!(parse_duration("1x").is_err());
|
|
assert!(parse_duration("d").is_err());
|
|
assert!(parse_duration("").is_err());
|
|
assert!(parse_duration(" ").is_err());
|
|
assert!(parse_duration("1 hour banana").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn durations_scale_and_add() {
|
|
let doubled = span("5h").scaled(2).expect("a duration");
|
|
assert_eq!(doubled.ms(), 10 * 60 * 60 * 1000);
|
|
let sum = span("1d").plus(span("2h")).expect("a duration");
|
|
assert_eq!(sum.ms(), 26 * 60 * 60 * 1000);
|
|
assert!(span("0s").is_zero());
|
|
}
|
|
|
|
#[test]
|
|
fn the_order_runs_from_null_to_the_lists() {
|
|
let values = [
|
|
Value::Null,
|
|
Value::Bool(false),
|
|
Value::Bool(true),
|
|
Value::Number(1.0),
|
|
Value::Str("a".to_owned()),
|
|
Value::Date(day("2024-01-01")),
|
|
Value::List(vec![Value::Number(1.0)]),
|
|
Value::Object(vec![("a".to_owned(), Value::Number(1.0))]),
|
|
];
|
|
for pair in values.windows(2) {
|
|
if let [left, right] = pair {
|
|
assert_eq!(compare(left, right), Ordering::Less);
|
|
assert_eq!(compare(right, left), Ordering::Greater);
|
|
assert_eq!(compare(left, left), Ordering::Equal);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_not_a_number_sorts_last() {
|
|
let nan = Value::Number(f64::NAN);
|
|
assert_eq!(compare(&nan, &Value::Number(1.0)), Ordering::Greater);
|
|
assert_eq!(compare(&Value::Number(1.0), &nan), Ordering::Less);
|
|
assert_eq!(compare(&nan, &nan), Ordering::Equal);
|
|
assert_eq!(compare(&nan, &Value::Str("a".to_owned())), Ordering::Less);
|
|
}
|
|
|
|
#[test]
|
|
fn strings_compare_without_case_first() {
|
|
assert_eq!(compare(&Value::Str("apple".to_owned()), &Value::Str("Banana".to_owned())), Ordering::Less);
|
|
assert_eq!(compare(&Value::Str("A".to_owned()), &Value::Str("a".to_owned())), Ordering::Less);
|
|
assert_eq!(compare(&Value::Str("a".to_owned()), &Value::Str("A".to_owned())), Ordering::Greater);
|
|
}
|
|
|
|
#[test]
|
|
fn a_link_equals_the_file_it_points_at() {
|
|
let link = Value::Link { target: "note.md".to_owned(), display: "Note".to_owned() };
|
|
let file = Value::File { path: "note.md".to_owned() };
|
|
assert!(loose_eq(&link, &file));
|
|
assert!(loose_eq(&file, &link));
|
|
let other_file = Value::File { path: "other.md".to_owned() };
|
|
assert!(!loose_eq(&link, &other_file));
|
|
let same_target = Value::Link { target: "note.md".to_owned(), display: "Another name".to_owned() };
|
|
assert!(loose_eq(&link, &same_target));
|
|
let same_text = Value::Link { target: "elsewhere.md".to_owned(), display: "Note".to_owned() };
|
|
assert!(loose_eq(&link, &same_text));
|
|
}
|
|
|
|
#[test]
|
|
fn values_of_different_types_are_never_equal() {
|
|
assert!(loose_eq(&Value::Number(1.0), &Value::Number(1.0)));
|
|
assert!(!loose_eq(&Value::Number(f64::NAN), &Value::Number(f64::NAN)));
|
|
assert!(!loose_eq(&Value::Number(1.0), &Value::Str("1".to_owned())));
|
|
assert!(!loose_eq(&Value::Null, &Value::Bool(false)));
|
|
assert!(loose_eq(&Value::List(vec![Value::Number(1.0)]), &Value::List(vec![Value::Number(1.0)])));
|
|
assert!(!loose_eq(&Value::List(vec![Value::Number(1.0)]), &Value::List(vec![Value::Number(2.0)])));
|
|
}
|
|
|
|
#[test]
|
|
fn truthiness_follows_the_documentation() {
|
|
assert!(!truthy(&Value::Null));
|
|
assert!(!truthy(&Value::Bool(false)));
|
|
assert!(truthy(&Value::Bool(true)));
|
|
assert!(!truthy(&Value::Number(0.0)));
|
|
assert!(truthy(&Value::Number(2.0)));
|
|
assert!(!truthy(&Value::Str(String::new())));
|
|
assert!(truthy(&Value::Str("x".to_owned())));
|
|
assert!(!truthy(&Value::List(Vec::new())));
|
|
assert!(truthy(&Value::List(vec![Value::Null])));
|
|
assert!(!truthy(&Value::Error("boom".to_owned())));
|
|
}
|
|
|
|
#[test]
|
|
fn values_print_as_readable_text() {
|
|
assert_eq!(Value::Null.to_display_string(), "");
|
|
assert_eq!(Value::Bool(true).to_display_string(), "true");
|
|
assert_eq!(Value::Number(2.5).to_display_string(), "2.5");
|
|
assert_eq!(Value::Number(3.0).to_display_string(), "3");
|
|
assert_eq!(Value::Str("hi".to_owned()).to_display_string(), "hi");
|
|
assert_eq!(Value::Date(day("2024-12-01")).to_display_string(), "2024-12-01");
|
|
assert_eq!(Value::Date(day("2024-12-01 04:03:00")).to_display_string(), "2024-12-01 04:03:00");
|
|
assert_eq!(Value::Duration(span("1M 4h")).to_display_string(), "1M 4h");
|
|
assert_eq!(Value::Duration(span("2h")).to_display_string(), "2h");
|
|
assert_eq!(Value::List(vec![Value::Number(1.0), Value::Number(2.0)]).to_display_string(), "1, 2");
|
|
assert_eq!(Value::Object(vec![("a".to_owned(), Value::Number(1.0))]).to_display_string(), "a: 1");
|
|
assert_eq!(Value::Link { target: "note.md".to_owned(), display: "Note".to_owned() }.to_display_string(), "Note");
|
|
assert_eq!(Value::File { path: "note.md".to_owned() }.to_display_string(), "note.md");
|
|
assert_eq!(Value::Error("boom".to_owned()).to_display_string(), "boom");
|
|
}
|
|
|
|
#[test]
|
|
fn the_type_names_are_the_documented_ones() {
|
|
assert_eq!(Value::Null.type_name(), "null");
|
|
assert_eq!(Value::Bool(true).type_name(), "boolean");
|
|
assert_eq!(Value::Number(1.0).type_name(), "number");
|
|
assert_eq!(Value::Str("a".to_owned()).type_name(), "string");
|
|
assert_eq!(Value::Date(day("2024-01-01")).type_name(), "date");
|
|
assert_eq!(Value::Duration(span("1h")).type_name(), "duration");
|
|
assert_eq!(Value::List(Vec::new()).type_name(), "list");
|
|
assert_eq!(Value::Object(Vec::new()).type_name(), "object");
|
|
assert_eq!(Value::Link { target: "a".to_owned(), display: "a".to_owned() }.type_name(), "link");
|
|
assert_eq!(Value::File { path: "a.md".to_owned() }.type_name(), "file");
|
|
}
|
|
|
|
#[test]
|
|
fn dates_read_the_documented_shapes() {
|
|
let date = day("2024-12-01");
|
|
assert!(!date.has_time());
|
|
assert_eq!(date.format_iso(), "2024-12-01");
|
|
let time = day("2024-12-01 04:03:00");
|
|
assert!(time.has_time());
|
|
assert_eq!(time.format_iso(), "2024-12-01 04:03:00");
|
|
let iso = day("2024-12-01T04:03:00Z");
|
|
assert_eq!(iso.format_iso(), "2024-12-01 04:03:00");
|
|
let millis = day("2024-12-01 04:03:00.250");
|
|
assert_eq!(millis.format_iso(), "2024-12-01 04:03:00.250");
|
|
let offset = day("2024-12-01 04:03:00+02:00");
|
|
assert_eq!(offset.format_iso(), "2024-12-01 02:03:00");
|
|
assert_eq!(offset.ms(), time.ms() - 2 * 60 * 60 * 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn today_and_now_come_from_the_caller() {
|
|
let now_ms = 1_700_000_000_000;
|
|
let now = Date::now(now_ms);
|
|
assert_eq!(now.ms(), now_ms);
|
|
assert!(now.has_time());
|
|
let today = Date::today(now_ms);
|
|
assert!(!today.has_time());
|
|
assert_eq!(today.ms() % 86_400_000, 0);
|
|
assert_eq!(today.format_iso(), "2023-11-14");
|
|
}
|