mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-17 18:35:17 +02:00
Initial commit: Vestige v1.0.0 - Cognitive memory MCP server
FSRS-6 spaced repetition, spreading activation, synaptic tagging, hippocampal indexing, and 130 years of memory research. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
f9c60eb5a7
169 changed files with 97206 additions and 0 deletions
390
tests/e2e/src/harness/db_manager.rs
Normal file
390
tests/e2e/src/harness/db_manager.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
//! Test Database Manager
|
||||
//!
|
||||
//! Provides isolated database instances for testing:
|
||||
//! - Temporary databases that are automatically cleaned up
|
||||
//! - Pre-seeded databases with test data
|
||||
//! - Database snapshots and restoration
|
||||
//! - Concurrent test isolation
|
||||
|
||||
use vestige_core::{KnowledgeNode, Rating, Storage};
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper to create IngestInput (works around non_exhaustive)
|
||||
fn make_ingest_input(
|
||||
content: String,
|
||||
node_type: String,
|
||||
tags: Vec<String>,
|
||||
sentiment_score: f64,
|
||||
sentiment_magnitude: f64,
|
||||
source: Option<String>,
|
||||
valid_from: Option<chrono::DateTime<chrono::Utc>>,
|
||||
valid_until: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> vestige_core::IngestInput {
|
||||
let mut input = vestige_core::IngestInput::default();
|
||||
input.content = content;
|
||||
input.node_type = node_type;
|
||||
input.tags = tags;
|
||||
input.sentiment_score = sentiment_score;
|
||||
input.sentiment_magnitude = sentiment_magnitude;
|
||||
input.source = source;
|
||||
input.valid_from = valid_from;
|
||||
input.valid_until = valid_until;
|
||||
input
|
||||
}
|
||||
|
||||
/// Manager for test databases
|
||||
///
|
||||
/// Creates isolated database instances for each test to prevent interference.
|
||||
/// Automatically cleans up temporary databases when dropped.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut db = TestDatabaseManager::new_temp();
|
||||
///
|
||||
/// // Use the storage
|
||||
/// db.storage.ingest(IngestInput { ... });
|
||||
///
|
||||
/// // Database is automatically deleted when `db` goes out of scope
|
||||
/// ```
|
||||
pub struct TestDatabaseManager {
|
||||
/// The storage instance
|
||||
pub storage: Storage,
|
||||
/// Temporary directory (kept alive to prevent premature deletion)
|
||||
_temp_dir: Option<TempDir>,
|
||||
/// Path to the database file
|
||||
db_path: PathBuf,
|
||||
/// Snapshot data for restore operations
|
||||
snapshot: Option<Vec<KnowledgeNode>>,
|
||||
}
|
||||
|
||||
impl TestDatabaseManager {
|
||||
/// Create a new test database in a temporary directory
|
||||
///
|
||||
/// The database is automatically deleted when the manager is dropped.
|
||||
pub fn new_temp() -> Self {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let db_path = temp_dir.path().join("test_vestige.db");
|
||||
|
||||
let storage = Storage::new(Some(db_path.clone())).expect("Failed to create test storage");
|
||||
|
||||
Self {
|
||||
storage,
|
||||
_temp_dir: Some(temp_dir),
|
||||
db_path,
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a test database at a specific path
|
||||
///
|
||||
/// The database is NOT automatically deleted.
|
||||
pub fn new_at_path(path: PathBuf) -> Self {
|
||||
let storage = Storage::new(Some(path.clone())).expect("Failed to create test storage");
|
||||
|
||||
Self {
|
||||
storage,
|
||||
_temp_dir: None,
|
||||
db_path: path,
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the database path
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.db_path
|
||||
}
|
||||
|
||||
/// Check if the database is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.storage
|
||||
.get_stats()
|
||||
.map(|s| s.total_nodes == 0)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Get the number of nodes in the database
|
||||
pub fn node_count(&self) -> i64 {
|
||||
self.storage
|
||||
.get_stats()
|
||||
.map(|s| s.total_nodes)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SEEDING METHODS
|
||||
// ========================================================================
|
||||
|
||||
/// Seed the database with a specified number of test nodes
|
||||
pub fn seed_nodes(&mut self, count: usize) -> Vec<String> {
|
||||
let mut ids = Vec::with_capacity(count);
|
||||
|
||||
for i in 0..count {
|
||||
let input = make_ingest_input(
|
||||
format!("Test memory content {}", i),
|
||||
"fact".to_string(),
|
||||
vec![format!("test-{}", i % 5)],
|
||||
0.0,
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
if let Ok(node) = self.storage.ingest(input) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
/// Seed with diverse node types
|
||||
pub fn seed_diverse(&mut self, count_per_type: usize) -> Vec<String> {
|
||||
let types = ["fact", "concept", "procedure", "event", "code"];
|
||||
let mut ids = Vec::with_capacity(count_per_type * types.len());
|
||||
|
||||
for node_type in types {
|
||||
for i in 0..count_per_type {
|
||||
let input = make_ingest_input(
|
||||
format!("Test {} content {}", node_type, i),
|
||||
node_type.to_string(),
|
||||
vec![node_type.to_string()],
|
||||
0.0,
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
if let Ok(node) = self.storage.ingest(input) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
/// Seed with nodes having various retention states
|
||||
pub fn seed_with_retention_states(&mut self) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
|
||||
// New node (never reviewed)
|
||||
let input = make_ingest_input(
|
||||
"New memory - never reviewed".to_string(),
|
||||
"fact".to_string(),
|
||||
vec!["new".to_string()],
|
||||
0.0,
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
if let Ok(node) = self.storage.ingest(input) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
|
||||
// Well-learned node (multiple good reviews)
|
||||
let input = make_ingest_input(
|
||||
"Well-learned memory - reviewed multiple times".to_string(),
|
||||
"fact".to_string(),
|
||||
vec!["learned".to_string()],
|
||||
0.0,
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
if let Ok(node) = self.storage.ingest(input) {
|
||||
let _ = self.storage.mark_reviewed(&node.id, Rating::Good);
|
||||
let _ = self.storage.mark_reviewed(&node.id, Rating::Good);
|
||||
let _ = self.storage.mark_reviewed(&node.id, Rating::Easy);
|
||||
ids.push(node.id);
|
||||
}
|
||||
|
||||
// Struggling node (multiple lapses)
|
||||
let input = make_ingest_input(
|
||||
"Struggling memory - has lapses".to_string(),
|
||||
"fact".to_string(),
|
||||
vec!["struggling".to_string()],
|
||||
0.0,
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
if let Ok(node) = self.storage.ingest(input) {
|
||||
let _ = self.storage.mark_reviewed(&node.id, Rating::Again);
|
||||
let _ = self.storage.mark_reviewed(&node.id, Rating::Hard);
|
||||
let _ = self.storage.mark_reviewed(&node.id, Rating::Again);
|
||||
ids.push(node.id);
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
/// Seed with emotional memories (different sentiment magnitudes)
|
||||
pub fn seed_emotional(&mut self, count: usize) -> Vec<String> {
|
||||
let mut ids = Vec::with_capacity(count);
|
||||
|
||||
for i in 0..count {
|
||||
let magnitude = (i as f64) / (count as f64);
|
||||
let input = make_ingest_input(
|
||||
format!("Emotional memory with magnitude {:.2}", magnitude),
|
||||
"event".to_string(),
|
||||
vec!["emotional".to_string()],
|
||||
if i % 2 == 0 { 0.8 } else { -0.8 },
|
||||
magnitude,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
if let Ok(node) = self.storage.ingest(input) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SNAPSHOT/RESTORE
|
||||
// ========================================================================
|
||||
|
||||
/// Take a snapshot of current database state
|
||||
pub fn take_snapshot(&mut self) {
|
||||
let nodes = self
|
||||
.storage
|
||||
.get_all_nodes(10000, 0)
|
||||
.unwrap_or_default();
|
||||
self.snapshot = Some(nodes);
|
||||
}
|
||||
|
||||
/// Restore from the last snapshot
|
||||
///
|
||||
/// Note: This clears the database and re-inserts all nodes from snapshot.
|
||||
/// IDs will NOT be preserved (new UUIDs are generated).
|
||||
pub fn restore_snapshot(&mut self) -> bool {
|
||||
if let Some(nodes) = self.snapshot.take() {
|
||||
// Clear current data by recreating storage
|
||||
// Delete the database file first
|
||||
let _ = std::fs::remove_file(&self.db_path);
|
||||
self.storage = Storage::new(Some(self.db_path.clone()))
|
||||
.expect("Failed to recreate storage for restore");
|
||||
|
||||
// Re-insert nodes
|
||||
for node in nodes {
|
||||
let input = make_ingest_input(
|
||||
node.content,
|
||||
node.node_type,
|
||||
node.tags,
|
||||
node.sentiment_score,
|
||||
node.sentiment_magnitude,
|
||||
node.source,
|
||||
node.valid_from,
|
||||
node.valid_until,
|
||||
);
|
||||
let _ = self.storage.ingest(input);
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a snapshot exists
|
||||
pub fn has_snapshot(&self) -> bool {
|
||||
self.snapshot.is_some()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CLEANUP
|
||||
// ========================================================================
|
||||
|
||||
/// Clear all data from the database
|
||||
pub fn clear(&mut self) {
|
||||
// Get all node IDs and delete them
|
||||
if let Ok(nodes) = self.storage.get_all_nodes(10000, 0) {
|
||||
for node in nodes {
|
||||
let _ = self.storage.delete_node(&node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recreate the database (useful for testing migrations)
|
||||
pub fn recreate(&mut self) {
|
||||
// Delete the database file
|
||||
let _ = std::fs::remove_file(&self.db_path);
|
||||
|
||||
// Recreate storage
|
||||
self.storage = Storage::new(Some(self.db_path.clone()))
|
||||
.expect("Failed to recreate storage");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDatabaseManager {
|
||||
fn drop(&mut self) {
|
||||
// Storage is dropped automatically
|
||||
// TempDir (if Some) will clean up the temp directory
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_temp_database_creation() {
|
||||
let db = TestDatabaseManager::new_temp();
|
||||
assert!(db.is_empty());
|
||||
assert!(db.path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_nodes() {
|
||||
let mut db = TestDatabaseManager::new_temp();
|
||||
let ids = db.seed_nodes(10);
|
||||
|
||||
assert_eq!(ids.len(), 10);
|
||||
assert_eq!(db.node_count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_diverse() {
|
||||
let mut db = TestDatabaseManager::new_temp();
|
||||
let ids = db.seed_diverse(3);
|
||||
|
||||
// 5 types * 3 each = 15
|
||||
assert_eq!(ids.len(), 15);
|
||||
assert_eq!(db.node_count(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_database() {
|
||||
let mut db = TestDatabaseManager::new_temp();
|
||||
db.seed_nodes(5);
|
||||
assert_eq!(db.node_count(), 5);
|
||||
|
||||
db.clear();
|
||||
assert!(db.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_restore() {
|
||||
let mut db = TestDatabaseManager::new_temp();
|
||||
db.seed_nodes(5);
|
||||
|
||||
db.take_snapshot();
|
||||
assert!(db.has_snapshot());
|
||||
|
||||
db.clear();
|
||||
assert!(db.is_empty());
|
||||
|
||||
db.restore_snapshot();
|
||||
assert_eq!(db.node_count(), 5);
|
||||
}
|
||||
}
|
||||
11
tests/e2e/src/harness/mod.rs
Normal file
11
tests/e2e/src/harness/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! Test Harness Module
|
||||
//!
|
||||
//! Provides test setup utilities:
|
||||
//! - `TimeTravelEnvironment` for testing time-dependent behavior (decay, scheduling)
|
||||
//! - `TestDatabaseManager` for isolated test databases
|
||||
|
||||
mod db_manager;
|
||||
mod time_travel;
|
||||
|
||||
pub use db_manager::TestDatabaseManager;
|
||||
pub use time_travel::TimeTravelEnvironment;
|
||||
342
tests/e2e/src/harness/time_travel.rs
Normal file
342
tests/e2e/src/harness/time_travel.rs
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
//! Time Travel Environment for Testing Decay
|
||||
//!
|
||||
//! Enables testing of time-dependent memory behavior:
|
||||
//! - FSRS-6 scheduling and intervals
|
||||
//! - Memory decay (retrieval strength degradation)
|
||||
//! - Temporal validity periods
|
||||
//! - Consolidation timing
|
||||
//!
|
||||
//! Uses a virtual clock that can be advanced without waiting.
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Environment for testing time-dependent memory behavior
|
||||
///
|
||||
/// Provides a virtual clock that can be advanced to test:
|
||||
/// - Memory decay over time
|
||||
/// - FSRS-6 scheduling calculations
|
||||
/// - Temporal validity windows
|
||||
/// - Consolidation cycles
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut env = TimeTravelEnvironment::new();
|
||||
///
|
||||
/// // Start at a known time
|
||||
/// env.set_time(Utc::now());
|
||||
///
|
||||
/// // Advance 30 days to test decay
|
||||
/// env.advance_days(30);
|
||||
///
|
||||
/// // Check retrievability at this point
|
||||
/// let elapsed = env.days_since(original_time);
|
||||
/// ```
|
||||
pub struct TimeTravelEnvironment {
|
||||
/// Current virtual time
|
||||
current_time: RefCell<DateTime<Utc>>,
|
||||
/// Original start time for reference
|
||||
start_time: DateTime<Utc>,
|
||||
/// History of time jumps for debugging
|
||||
time_history: RefCell<Vec<TimeJump>>,
|
||||
}
|
||||
|
||||
/// Record of a time jump for debugging
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimeJump {
|
||||
pub from: DateTime<Utc>,
|
||||
pub to: DateTime<Utc>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl Default for TimeTravelEnvironment {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeTravelEnvironment {
|
||||
/// Create a new time travel environment starting at the current time
|
||||
pub fn new() -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
current_time: RefCell::new(now),
|
||||
start_time: now,
|
||||
time_history: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create environment at a specific starting time
|
||||
pub fn at(time: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
current_time: RefCell::new(time),
|
||||
start_time: time,
|
||||
time_history: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current virtual time
|
||||
pub fn now(&self) -> DateTime<Utc> {
|
||||
*self.current_time.borrow()
|
||||
}
|
||||
|
||||
/// Get the original start time
|
||||
pub fn start_time(&self) -> DateTime<Utc> {
|
||||
self.start_time
|
||||
}
|
||||
|
||||
/// Set the current time to a specific point
|
||||
pub fn set_time(&self, time: DateTime<Utc>) {
|
||||
let from = *self.current_time.borrow();
|
||||
self.time_history.borrow_mut().push(TimeJump {
|
||||
from,
|
||||
to: time,
|
||||
reason: "set_time".to_string(),
|
||||
});
|
||||
*self.current_time.borrow_mut() = time;
|
||||
}
|
||||
|
||||
/// Advance time by a duration
|
||||
pub fn advance(&self, duration: Duration) {
|
||||
let from = *self.current_time.borrow();
|
||||
let to = from + duration;
|
||||
self.time_history.borrow_mut().push(TimeJump {
|
||||
from,
|
||||
to,
|
||||
reason: format!("advance {:?}", duration),
|
||||
});
|
||||
*self.current_time.borrow_mut() = to;
|
||||
}
|
||||
|
||||
/// Advance time by the specified number of days
|
||||
pub fn advance_days(&self, days: i64) {
|
||||
self.advance(Duration::days(days));
|
||||
}
|
||||
|
||||
/// Advance time by the specified number of hours
|
||||
pub fn advance_hours(&self, hours: i64) {
|
||||
self.advance(Duration::hours(hours));
|
||||
}
|
||||
|
||||
/// Advance time by the specified number of minutes
|
||||
pub fn advance_minutes(&self, minutes: i64) {
|
||||
self.advance(Duration::minutes(minutes));
|
||||
}
|
||||
|
||||
/// Advance time by the specified number of seconds
|
||||
pub fn advance_seconds(&self, seconds: i64) {
|
||||
self.advance(Duration::seconds(seconds));
|
||||
}
|
||||
|
||||
/// Calculate days elapsed since a reference time
|
||||
pub fn days_since(&self, reference: DateTime<Utc>) -> f64 {
|
||||
let current = *self.current_time.borrow();
|
||||
(current - reference).num_seconds() as f64 / 86400.0
|
||||
}
|
||||
|
||||
/// Calculate days elapsed since the start time
|
||||
pub fn days_since_start(&self) -> f64 {
|
||||
self.days_since(self.start_time)
|
||||
}
|
||||
|
||||
/// Calculate hours elapsed since a reference time
|
||||
pub fn hours_since(&self, reference: DateTime<Utc>) -> f64 {
|
||||
let current = *self.current_time.borrow();
|
||||
(current - reference).num_seconds() as f64 / 3600.0
|
||||
}
|
||||
|
||||
/// Get time history for debugging
|
||||
pub fn get_history(&self) -> Vec<TimeJump> {
|
||||
self.time_history.borrow().clone()
|
||||
}
|
||||
|
||||
/// Clear time history
|
||||
pub fn clear_history(&self) {
|
||||
self.time_history.borrow_mut().clear();
|
||||
}
|
||||
|
||||
/// Reset to start time
|
||||
pub fn reset(&self) {
|
||||
let from = *self.current_time.borrow();
|
||||
self.time_history.borrow_mut().push(TimeJump {
|
||||
from,
|
||||
to: self.start_time,
|
||||
reason: "reset".to_string(),
|
||||
});
|
||||
*self.current_time.borrow_mut() = self.start_time;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// DECAY TESTING HELPERS
|
||||
// ========================================================================
|
||||
|
||||
/// Calculate expected retrievability at current time
|
||||
///
|
||||
/// Uses FSRS-6 power forgetting curve:
|
||||
/// R = (1 + factor * t / S)^(-w20)
|
||||
pub fn expected_retrievability(&self, stability: f64, last_review: DateTime<Utc>) -> f64 {
|
||||
let elapsed_days = self.days_since(last_review);
|
||||
vestige_core::retrievability(stability, elapsed_days)
|
||||
}
|
||||
|
||||
/// Calculate expected retrievability with custom decay
|
||||
pub fn expected_retrievability_with_decay(
|
||||
&self,
|
||||
stability: f64,
|
||||
last_review: DateTime<Utc>,
|
||||
w20: f64,
|
||||
) -> f64 {
|
||||
let elapsed_days = self.days_since(last_review);
|
||||
vestige_core::retrievability_with_decay(stability, elapsed_days, w20)
|
||||
}
|
||||
|
||||
/// Check if a memory would be due for review at current time
|
||||
pub fn is_due(&self, next_review: DateTime<Utc>) -> bool {
|
||||
*self.current_time.borrow() >= next_review
|
||||
}
|
||||
|
||||
/// Calculate how overdue a memory is (negative if not yet due)
|
||||
pub fn days_overdue(&self, next_review: DateTime<Utc>) -> f64 {
|
||||
self.days_since(next_review)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SCHEDULING HELPERS
|
||||
// ========================================================================
|
||||
|
||||
/// Advance to when a memory would be due
|
||||
pub fn advance_to_due(&self, next_review: DateTime<Utc>) {
|
||||
let from = *self.current_time.borrow();
|
||||
self.time_history.borrow_mut().push(TimeJump {
|
||||
from,
|
||||
to: next_review,
|
||||
reason: "advance_to_due".to_string(),
|
||||
});
|
||||
*self.current_time.borrow_mut() = next_review;
|
||||
}
|
||||
|
||||
/// Advance past due date by specified days
|
||||
pub fn advance_past_due(&self, next_review: DateTime<Utc>, days_overdue: i64) {
|
||||
let target = next_review + Duration::days(days_overdue);
|
||||
let from = *self.current_time.borrow();
|
||||
self.time_history.borrow_mut().push(TimeJump {
|
||||
from,
|
||||
to: target,
|
||||
reason: format!("advance_past_due +{} days", days_overdue),
|
||||
});
|
||||
*self.current_time.borrow_mut() = target;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TEMPORAL VALIDITY HELPERS
|
||||
// ========================================================================
|
||||
|
||||
/// Check if a time is within a validity window
|
||||
pub fn is_within_validity(
|
||||
&self,
|
||||
valid_from: Option<DateTime<Utc>>,
|
||||
valid_until: Option<DateTime<Utc>>,
|
||||
) -> bool {
|
||||
let current = *self.current_time.borrow();
|
||||
let after_start = valid_from.map(|t| current >= t).unwrap_or(true);
|
||||
let before_end = valid_until.map(|t| current <= t).unwrap_or(true);
|
||||
after_start && before_end
|
||||
}
|
||||
|
||||
/// Advance to just before validity starts
|
||||
pub fn advance_to_before_validity(&self, valid_from: DateTime<Utc>) {
|
||||
let target = valid_from - Duration::seconds(1);
|
||||
self.set_time(target);
|
||||
}
|
||||
|
||||
/// Advance to just after validity ends
|
||||
pub fn advance_to_after_validity(&self, valid_until: DateTime<Utc>) {
|
||||
let target = valid_until + Duration::seconds(1);
|
||||
self.set_time(target);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_time_travel_basic() {
|
||||
let env = TimeTravelEnvironment::new();
|
||||
let start = env.now();
|
||||
|
||||
env.advance_days(10);
|
||||
|
||||
assert!(env.days_since(start) >= 9.99);
|
||||
assert!(env.days_since(start) <= 10.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_travel_reset() {
|
||||
let env = TimeTravelEnvironment::new();
|
||||
let start = env.start_time();
|
||||
|
||||
env.advance_days(100);
|
||||
env.reset();
|
||||
|
||||
assert_eq!(env.now(), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retrievability_decay() {
|
||||
let env = TimeTravelEnvironment::new();
|
||||
let stability = 10.0;
|
||||
let last_review = env.now();
|
||||
|
||||
// At t=0, retrievability should be ~1.0
|
||||
let r0 = env.expected_retrievability(stability, last_review);
|
||||
assert!(r0 > 0.99);
|
||||
|
||||
// After 10 days with stability=10, retrievability should be ~0.9
|
||||
env.advance_days(10);
|
||||
let r10 = env.expected_retrievability(stability, last_review);
|
||||
assert!(r10 < r0);
|
||||
assert!(r10 > 0.85 && r10 < 0.95);
|
||||
|
||||
// After 30 days, retrievability should be much lower
|
||||
env.advance_days(20);
|
||||
let r30 = env.expected_retrievability(stability, last_review);
|
||||
assert!(r30 < r10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_due_date_checking() {
|
||||
let env = TimeTravelEnvironment::new();
|
||||
let next_review = env.now() + Duration::days(5);
|
||||
|
||||
// Not due yet
|
||||
assert!(!env.is_due(next_review));
|
||||
assert!(env.days_overdue(next_review) < 0.0);
|
||||
|
||||
// Advance to due
|
||||
env.advance_to_due(next_review);
|
||||
assert!(env.is_due(next_review));
|
||||
assert!(env.days_overdue(next_review).abs() < 0.01);
|
||||
|
||||
// Advance past due
|
||||
env.advance_days(3);
|
||||
assert!(env.is_due(next_review));
|
||||
assert!(env.days_overdue(next_review) > 2.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_tracking() {
|
||||
let env = TimeTravelEnvironment::new();
|
||||
|
||||
env.advance_days(1);
|
||||
env.advance_hours(12);
|
||||
env.advance_minutes(30);
|
||||
|
||||
let history = env.get_history();
|
||||
assert_eq!(history.len(), 3);
|
||||
|
||||
env.clear_history();
|
||||
assert!(env.get_history().is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue