576 lines
16 KiB
Rust
576 lines
16 KiB
Rust
//! UAT: Milestone 1, Phase 1 — Core Type System and Schema
|
|
//!
|
|
//! Exercises every m1p1 acceptance criterion from the public API.
|
|
//! Does NOT duplicate the unit tests in `schema/entity.rs`, `schema/signal.rs`,
|
|
//! `schema/error.rs`, `schema/score.rs`, or `schema/validation.rs`.
|
|
//! Instead it verifies from the *user* perspective: import `tidaldb::schema::*`,
|
|
//! construct types, and assert the documented guarantees hold.
|
|
|
|
#![allow(clippy::unwrap_used, unused_must_use)]
|
|
|
|
use std::collections::HashSet;
|
|
use std::time::Duration;
|
|
|
|
use tidaldb::schema::{
|
|
DecaySpec, EntityId, EntityKind, SchemaBuilder, SchemaError, Score, Window, WindowSet,
|
|
};
|
|
|
|
// ── UAT-01: EntityId — u64 newtype with Display, Hash, Eq, Ord, to_be_bytes ──
|
|
|
|
#[test]
|
|
fn uat01_entity_id_display() {
|
|
let id = EntityId::new(42);
|
|
assert_eq!(id.to_string(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn uat01_entity_id_hash_eq() {
|
|
let a = EntityId::new(7);
|
|
let b = EntityId::new(7);
|
|
let c = EntityId::new(8);
|
|
|
|
// Eq
|
|
assert_eq!(a, b);
|
|
assert_ne!(a, c);
|
|
|
|
// Hash: equal entities produce equal hashes (insert into HashSet, verify dedup)
|
|
let mut set = HashSet::new();
|
|
set.insert(a);
|
|
set.insert(b);
|
|
set.insert(c);
|
|
assert_eq!(set.len(), 2, "HashSet should deduplicate equal EntityIds");
|
|
}
|
|
|
|
#[test]
|
|
fn uat01_entity_id_ord() {
|
|
let ids: Vec<EntityId> = vec![
|
|
EntityId::new(100),
|
|
EntityId::new(1),
|
|
EntityId::new(50),
|
|
EntityId::new(0),
|
|
EntityId::new(u64::MAX),
|
|
];
|
|
let mut sorted = ids.clone();
|
|
sorted.sort();
|
|
let vals: Vec<u64> = sorted.iter().map(|id| id.as_u64()).collect();
|
|
assert_eq!(vals, vec![0, 1, 50, 100, u64::MAX]);
|
|
}
|
|
|
|
#[test]
|
|
fn uat01_entity_id_be_bytes_preserves_ordering() {
|
|
// The acceptance criterion: big-endian encoding preserves numeric ordering.
|
|
let pairs = [
|
|
(0_u64, 1_u64),
|
|
(1, 2),
|
|
(255, 256),
|
|
(u64::MAX - 1, u64::MAX),
|
|
(0, u64::MAX),
|
|
];
|
|
for (a, b) in pairs {
|
|
let bytes_a = EntityId::new(a).to_be_bytes();
|
|
let bytes_b = EntityId::new(b).to_be_bytes();
|
|
assert!(
|
|
bytes_a < bytes_b,
|
|
"be_bytes ordering violated: {a} < {b} but bytes {bytes_a:?} >= {bytes_b:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── UAT-02: EntityKind — Item, User, Creator ─────────────────────────────────
|
|
|
|
#[test]
|
|
fn uat02_entity_kind_variants_exist() {
|
|
let _item = EntityKind::Item;
|
|
let _user = EntityKind::User;
|
|
let _creator = EntityKind::Creator;
|
|
}
|
|
|
|
#[test]
|
|
fn uat02_entity_kind_display() {
|
|
assert_eq!(EntityKind::Item.to_string(), "item");
|
|
assert_eq!(EntityKind::User.to_string(), "user");
|
|
assert_eq!(EntityKind::Creator.to_string(), "creator");
|
|
}
|
|
|
|
// ── UAT-03: SignalTypeDef via SchemaBuilder ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn uat03_signal_type_def_captures_all_fields() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(604_800),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour, Window::TwentyFourHours])
|
|
.velocity(true)
|
|
.add();
|
|
|
|
let schema = builder.build().unwrap();
|
|
let view = schema.signal("view").unwrap();
|
|
|
|
assert_eq!(view.name(), "view");
|
|
assert_eq!(view.target(), EntityKind::Item);
|
|
assert!(view.velocity_enabled());
|
|
assert_eq!(view.windows().len(), 2);
|
|
assert!(view.windows().contains(&Window::OneHour));
|
|
assert!(view.windows().contains(&Window::TwentyFourHours));
|
|
assert!(view.decay().lambda().is_some());
|
|
assert!(view.decay().half_life().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn uat03_signal_type_def_user_target() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal("follow", EntityKind::User, DecaySpec::Permanent)
|
|
.add();
|
|
let schema = builder.build().unwrap();
|
|
let follow = schema.signal("follow").unwrap();
|
|
assert_eq!(follow.target(), EntityKind::User);
|
|
}
|
|
|
|
#[test]
|
|
fn uat03_signal_type_def_creator_target() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal("subscribe", EntityKind::Creator, DecaySpec::Permanent)
|
|
.add();
|
|
let schema = builder.build().unwrap();
|
|
let sub = schema.signal("subscribe").unwrap();
|
|
assert_eq!(sub.target(), EntityKind::Creator);
|
|
}
|
|
|
|
// ── UAT-04: DecayModel — pre-computed lambda, no division on hot path ────────
|
|
|
|
#[test]
|
|
fn uat04_exponential_decay_precomputes_lambda() {
|
|
let half_life_secs = 7.0 * 24.0 * 3600.0; // 7 days
|
|
let expected_lambda = std::f64::consts::LN_2 / half_life_secs;
|
|
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(604_800),
|
|
},
|
|
)
|
|
.windows(&[Window::AllTime])
|
|
.add();
|
|
let schema = builder.build().unwrap();
|
|
let view = schema.signal("view").unwrap();
|
|
|
|
let lambda = view.decay().lambda().unwrap();
|
|
assert!(
|
|
(lambda - expected_lambda).abs() < 1e-15,
|
|
"lambda should be precomputed as ln(2)/half_life: got {lambda}, expected {expected_lambda}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uat04_linear_decay_has_no_lambda() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(
|
|
"impression",
|
|
EntityKind::Item,
|
|
DecaySpec::Linear {
|
|
lifetime: Duration::from_secs(86_400),
|
|
},
|
|
)
|
|
.windows(&[Window::TwentyFourHours])
|
|
.add();
|
|
let schema = builder.build().unwrap();
|
|
let sig = schema.signal("impression").unwrap();
|
|
assert!(sig.decay().lambda().is_none());
|
|
assert!(sig.decay().half_life().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn uat04_permanent_decay_has_no_lambda() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal("block", EntityKind::User, DecaySpec::Permanent)
|
|
.add();
|
|
let schema = builder.build().unwrap();
|
|
let sig = schema.signal("block").unwrap();
|
|
assert!(sig.decay().lambda().is_none());
|
|
assert!(sig.decay().half_life().is_none());
|
|
}
|
|
|
|
// ── UAT-05: Window enum — all variants, duration(), label(), duration_secs_f64() ─
|
|
|
|
#[test]
|
|
fn uat05_window_variants_and_methods() {
|
|
let windows = [
|
|
(Window::OneHour, Duration::from_secs(3_600), "1h", 3_600.0),
|
|
(
|
|
Window::TwentyFourHours,
|
|
Duration::from_secs(86_400),
|
|
"24h",
|
|
86_400.0,
|
|
),
|
|
(
|
|
Window::SevenDays,
|
|
Duration::from_secs(604_800),
|
|
"7d",
|
|
604_800.0,
|
|
),
|
|
(
|
|
Window::ThirtyDays,
|
|
Duration::from_secs(2_592_000),
|
|
"30d",
|
|
2_592_000.0,
|
|
),
|
|
];
|
|
|
|
for (window, expected_dur, expected_label, expected_secs) in windows {
|
|
assert_eq!(
|
|
window.duration(),
|
|
expected_dur,
|
|
"duration mismatch for {expected_label}"
|
|
);
|
|
assert_eq!(window.label(), expected_label);
|
|
assert!(
|
|
(window.duration_secs_f64() - expected_secs).abs() < 1e-10,
|
|
"duration_secs_f64 mismatch for {expected_label}"
|
|
);
|
|
}
|
|
|
|
// AllTime is special
|
|
assert_eq!(Window::AllTime.duration(), Duration::MAX);
|
|
assert_eq!(Window::AllTime.label(), "all");
|
|
assert!(Window::AllTime.duration_secs_f64().is_infinite());
|
|
}
|
|
|
|
// ── UAT-06: WindowSet — deduplicates and sorts; empty() for permanent ────────
|
|
|
|
#[test]
|
|
fn uat06_window_set_deduplicates_and_sorts() {
|
|
let ws = WindowSet::new(&[
|
|
Window::AllTime,
|
|
Window::OneHour,
|
|
Window::SevenDays,
|
|
Window::OneHour, // duplicate
|
|
Window::AllTime, // duplicate
|
|
]);
|
|
assert_eq!(ws.len(), 3);
|
|
|
|
let windows: Vec<Window> = ws.iter().copied().collect();
|
|
assert_eq!(
|
|
windows,
|
|
vec![Window::OneHour, Window::SevenDays, Window::AllTime]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uat06_window_set_empty() {
|
|
let ws = WindowSet::empty();
|
|
assert!(ws.is_empty());
|
|
assert_eq!(ws.len(), 0);
|
|
}
|
|
|
|
// ── UAT-07: Error types — all TidalError variants ───────────────────────────
|
|
|
|
#[test]
|
|
fn uat07_tidal_error_variants_exist() {
|
|
use tidaldb::TidalError;
|
|
|
|
// Verify the variants exist and implement Display (via thiserror).
|
|
let errors: Vec<Box<dyn std::error::Error>> = vec![
|
|
Box::new(TidalError::internal("test", "test")),
|
|
Box::new(TidalError::NotFound {
|
|
kind: EntityKind::Item,
|
|
id: EntityId::new(1),
|
|
}),
|
|
Box::new(TidalError::Schema(SchemaError::NoSignalsDefined)),
|
|
];
|
|
|
|
for e in &errors {
|
|
// Display works
|
|
let msg = e.to_string();
|
|
assert!(!msg.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn uat07_schema_error_converts_to_tidal_error() {
|
|
use tidaldb::TidalError;
|
|
|
|
let schema_err = SchemaError::DuplicateSignalName("view".into());
|
|
let tidal_err: TidalError = schema_err.into();
|
|
assert!(matches!(
|
|
tidal_err,
|
|
TidalError::Schema(SchemaError::DuplicateSignalName(_))
|
|
));
|
|
}
|
|
|
|
// ── UAT-08: SchemaError — all validation error conditions ───────────────────
|
|
|
|
#[test]
|
|
fn uat08_rejects_duplicate_signal_names() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal("view", EntityKind::Item, DecaySpec::Permanent)
|
|
.add();
|
|
builder
|
|
.signal("view", EntityKind::Item, DecaySpec::Permanent)
|
|
.add();
|
|
let err = builder.build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::DuplicateSignalName(ref name) if name == "view"),
|
|
"expected DuplicateSignalName, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uat08_rejects_invalid_identifiers() {
|
|
let invalid_names = [
|
|
"",
|
|
"View",
|
|
"1view",
|
|
"view count",
|
|
"view-count",
|
|
"_view",
|
|
"view!",
|
|
];
|
|
for name in invalid_names {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(name, EntityKind::Item, DecaySpec::Permanent)
|
|
.add();
|
|
let err = builder.build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::InvalidSignalName(_)),
|
|
"expected InvalidSignalName for '{name}', got: {err}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn uat08_rejects_zero_half_life() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(
|
|
"bad",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::ZERO,
|
|
},
|
|
)
|
|
.windows(&[Window::AllTime])
|
|
.add();
|
|
let err = builder.build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::InvalidHalfLife { .. }),
|
|
"expected InvalidHalfLife, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uat08_rejects_zero_lifetime() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(
|
|
"bad",
|
|
EntityKind::Item,
|
|
DecaySpec::Linear {
|
|
lifetime: Duration::ZERO,
|
|
},
|
|
)
|
|
.windows(&[Window::AllTime])
|
|
.add();
|
|
let err = builder.build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::InvalidLifetime { .. }),
|
|
"expected InvalidLifetime, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uat08_rejects_empty_windows_for_non_permanent() {
|
|
// Exponential without windows
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(
|
|
"bad",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(3600),
|
|
},
|
|
)
|
|
.add();
|
|
let err = builder.build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::EmptyWindows { .. }),
|
|
"expected EmptyWindows for exponential, got: {err}"
|
|
);
|
|
|
|
// Linear without windows
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal(
|
|
"bad",
|
|
EntityKind::Item,
|
|
DecaySpec::Linear {
|
|
lifetime: Duration::from_secs(3600),
|
|
},
|
|
)
|
|
.add();
|
|
let err = builder.build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::EmptyWindows { .. }),
|
|
"expected EmptyWindows for linear, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uat08_accepts_empty_windows_for_permanent() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal("hide", EntityKind::Item, DecaySpec::Permanent)
|
|
.add();
|
|
assert!(builder.build().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn uat08_rejects_velocity_without_windows() {
|
|
let mut builder = SchemaBuilder::new();
|
|
builder
|
|
.signal("bad", EntityKind::Item, DecaySpec::Permanent)
|
|
.velocity(true)
|
|
.add();
|
|
let err = builder.build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::VelocityWithoutWindows { .. }),
|
|
"expected VelocityWithoutWindows, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uat08_rejects_empty_schema() {
|
|
let err = SchemaBuilder::new().build().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::NoSignalsDefined),
|
|
"expected NoSignalsDefined, got: {err}"
|
|
);
|
|
}
|
|
|
|
// ── UAT-09: SchemaBuilder — valid multi-signal schema ───────────────────────
|
|
|
|
#[test]
|
|
fn uat09_valid_multi_signal_schema() {
|
|
let mut builder = SchemaBuilder::new();
|
|
|
|
// Exponential decay with velocity
|
|
builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[
|
|
Window::OneHour,
|
|
Window::TwentyFourHours,
|
|
Window::SevenDays,
|
|
Window::ThirtyDays,
|
|
Window::AllTime,
|
|
])
|
|
.velocity(true)
|
|
.add();
|
|
|
|
// Linear decay
|
|
builder
|
|
.signal(
|
|
"impression",
|
|
EntityKind::Item,
|
|
DecaySpec::Linear {
|
|
lifetime: Duration::from_secs(86_400),
|
|
},
|
|
)
|
|
.windows(&[Window::TwentyFourHours])
|
|
.velocity(false)
|
|
.add();
|
|
|
|
// Permanent
|
|
builder
|
|
.signal("hide", EntityKind::Item, DecaySpec::Permanent)
|
|
.add();
|
|
|
|
// User signal
|
|
builder
|
|
.signal("follow", EntityKind::User, DecaySpec::Permanent)
|
|
.add();
|
|
|
|
// Creator signal
|
|
builder
|
|
.signal("subscribe", EntityKind::Creator, DecaySpec::Permanent)
|
|
.add();
|
|
|
|
let schema = builder.build().unwrap();
|
|
assert_eq!(schema.signal_count(), 5);
|
|
|
|
// Verify all signals are retrievable
|
|
assert!(schema.signal("view").is_some());
|
|
assert!(schema.signal("impression").is_some());
|
|
assert!(schema.signal("hide").is_some());
|
|
assert!(schema.signal("follow").is_some());
|
|
assert!(schema.signal("subscribe").is_some());
|
|
assert!(schema.signal("nonexistent").is_none());
|
|
|
|
// Verify iterate signals returns all
|
|
let names: HashSet<&str> = schema.signals().map(|s| s.name()).collect();
|
|
assert_eq!(names.len(), 5);
|
|
assert!(names.contains("view"));
|
|
assert!(names.contains("impression"));
|
|
assert!(names.contains("hide"));
|
|
assert!(names.contains("follow"));
|
|
assert!(names.contains("subscribe"));
|
|
}
|
|
|
|
// ── UAT-10: Score — rejects NaN/Inf, accepts finite, total ordering ─────────
|
|
|
|
#[test]
|
|
fn uat10_score_rejects_nan_and_inf() {
|
|
assert!(Score::new(f64::NAN).is_none());
|
|
assert!(Score::new(f64::INFINITY).is_none());
|
|
assert!(Score::new(f64::NEG_INFINITY).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn uat10_score_accepts_finite_values() {
|
|
let s = Score::new(0.75).unwrap();
|
|
assert!((s.as_f64() - 0.75).abs() < 1e-15);
|
|
|
|
let neg = Score::new(-10.0).unwrap();
|
|
assert!((neg.as_f64() - (-10.0)).abs() < 1e-15);
|
|
|
|
assert_eq!(Score::ZERO.as_f64(), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn uat10_score_total_ordering() {
|
|
let a = Score::new(-1.0).unwrap();
|
|
let b = Score::ZERO;
|
|
let c = Score::new(0.5).unwrap();
|
|
let d = Score::new(1.0).unwrap();
|
|
|
|
assert!(a < b);
|
|
assert!(b < c);
|
|
assert!(c < d);
|
|
assert_eq!(b, Score::new(0.0).unwrap());
|
|
|
|
// Sort works
|
|
let mut scores = vec![d, a, c, b];
|
|
scores.sort();
|
|
let vals: Vec<f64> = scores.iter().map(|s| s.as_f64()).collect();
|
|
assert_eq!(vals, vec![-1.0, 0.0, 0.5, 1.0]);
|
|
}
|