233 lines
6.9 KiB
Rust
233 lines
6.9 KiB
Rust
use std::sync::Mutex;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::progress::ReadingPosition;
|
|
|
|
/// Maximum number of events before oldest are dropped to prevent unbounded memory growth.
|
|
const MAX_BUFFER_SIZE: usize = 1000;
|
|
|
|
// Global event buffer, protected by a Mutex for thread safety.
|
|
static EVENT_BUFFER: Mutex<Vec<ReadingEvent>> = Mutex::new(Vec::new());
|
|
|
|
/// Push a reading event into the global buffer.
|
|
/// If the buffer exceeds MAX_BUFFER_SIZE, the oldest event is dropped.
|
|
#[deprecated(since = "0.2.0", note = "Use events_v2::push_heartbeat_v2 etc. instead")]
|
|
pub fn push_reading_event(event: ReadingEvent) {
|
|
match EVENT_BUFFER.lock() {
|
|
Ok(mut buf) => {
|
|
if buf.len() >= MAX_BUFFER_SIZE {
|
|
buf.remove(0);
|
|
}
|
|
buf.push(event);
|
|
}
|
|
Err(_) => { /* poison: silently drop event */ }
|
|
}
|
|
}
|
|
|
|
#[deprecated(since = "0.2.0", note = "Use events_v2::push_position_changed_v2 instead")]
|
|
/// Record a position change as a PositionChanged event.
|
|
pub fn update_reading_position(material_id: &str, position: ReadingPosition) {
|
|
let event = ReadingEvent::PositionChanged {
|
|
material_id: material_id.to_string(),
|
|
position,
|
|
timestamp_ms: now_ms(),
|
|
};
|
|
push_reading_event(event);
|
|
}
|
|
|
|
/// Export all pending events without clearing.
|
|
#[deprecated(since = "0.2.0", note = "Use events_v2::export_pending_events_v2 instead")]
|
|
pub fn export_pending_events() -> Vec<ReadingEvent> {
|
|
EVENT_BUFFER.lock().map(|buf| buf.clone()).unwrap_or_default()
|
|
}
|
|
|
|
/// Remove the first `count` events after successful upload.
|
|
pub fn clear_exported_events(count: usize) {
|
|
if let Ok(mut buf) = EVENT_BUFFER.lock() {
|
|
let n = count.min(buf.len());
|
|
buf.drain(..n);
|
|
}
|
|
}
|
|
|
|
fn now_ms() -> i64 {
|
|
use std::time::SystemTime;
|
|
SystemTime::now()
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Enum)]
|
|
#[serde(tag = "type")]
|
|
pub enum ReadingEvent {
|
|
MaterialOpened {
|
|
material_id: String,
|
|
timestamp_ms: i64,
|
|
},
|
|
MaterialClosed {
|
|
material_id: String,
|
|
timestamp_ms: i64,
|
|
active_seconds: u32,
|
|
},
|
|
PositionChanged {
|
|
material_id: String,
|
|
position: ReadingPosition,
|
|
timestamp_ms: i64,
|
|
},
|
|
Heartbeat {
|
|
material_id: String,
|
|
active_seconds: u32,
|
|
position: Option<ReadingPosition>,
|
|
timestamp_ms: i64,
|
|
},
|
|
MarkedAsRead {
|
|
material_id: String,
|
|
timestamp_ms: i64,
|
|
},
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::Mutex;
|
|
|
|
// Serialize event buffer tests to prevent races on the global EVENT_BUFFER.
|
|
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
fn setup() {
|
|
clear_exported_events(usize::MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn test_material_opened_serde() {
|
|
let e = ReadingEvent::MaterialOpened {
|
|
material_id: "abc".into(),
|
|
timestamp_ms: 1000,
|
|
};
|
|
let json = serde_json::to_string(&e).unwrap();
|
|
assert!(json.contains("\"type\":\"MaterialOpened\""));
|
|
let back: ReadingEvent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, e);
|
|
}
|
|
|
|
#[test]
|
|
fn test_material_closed_serde() {
|
|
let e = ReadingEvent::MaterialClosed {
|
|
material_id: "abc".into(),
|
|
timestamp_ms: 2000,
|
|
active_seconds: 120,
|
|
};
|
|
let json = serde_json::to_string(&e).unwrap();
|
|
let back: ReadingEvent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, e);
|
|
}
|
|
|
|
#[test]
|
|
fn test_position_changed_serde() {
|
|
let e = ReadingEvent::PositionChanged {
|
|
material_id: "abc".into(),
|
|
position: ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 },
|
|
timestamp_ms: 3000,
|
|
};
|
|
let json = serde_json::to_string(&e).unwrap();
|
|
let back: ReadingEvent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, e);
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat_serde() {
|
|
let e = ReadingEvent::Heartbeat {
|
|
material_id: "abc".into(),
|
|
active_seconds: 15,
|
|
position: None,
|
|
timestamp_ms: 4000,
|
|
};
|
|
let json = serde_json::to_string(&e).unwrap();
|
|
let back: ReadingEvent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, e);
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat_with_position_serde() {
|
|
let e = ReadingEvent::Heartbeat {
|
|
material_id: "abc".into(),
|
|
active_seconds: 15,
|
|
position: Some(ReadingPosition::Pdf { page_number: 3, page_progress: 0.5, overall_progress: 0.1 }),
|
|
timestamp_ms: 5000,
|
|
};
|
|
let json = serde_json::to_string(&e).unwrap();
|
|
let back: ReadingEvent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, e);
|
|
}
|
|
|
|
#[test]
|
|
fn test_marked_as_read_serde() {
|
|
let e = ReadingEvent::MarkedAsRead {
|
|
material_id: "abc".into(),
|
|
timestamp_ms: 6000,
|
|
};
|
|
let json = serde_json::to_string(&e).unwrap();
|
|
let back: ReadingEvent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, e);
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_and_export() {
|
|
let _guard = TEST_LOCK.lock().unwrap();
|
|
setup();
|
|
push_reading_event(ReadingEvent::MaterialOpened {
|
|
material_id: "m1".into(),
|
|
timestamp_ms: 1000,
|
|
});
|
|
push_reading_event(ReadingEvent::MarkedAsRead {
|
|
material_id: "m1".into(),
|
|
timestamp_ms: 2000,
|
|
});
|
|
let events = export_pending_events();
|
|
assert_eq!(events.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clear_exported() {
|
|
let _guard = TEST_LOCK.lock().unwrap();
|
|
setup();
|
|
push_reading_event(ReadingEvent::MaterialOpened {
|
|
material_id: "m1".into(),
|
|
timestamp_ms: 1000,
|
|
});
|
|
push_reading_event(ReadingEvent::MaterialOpened {
|
|
material_id: "m2".into(),
|
|
timestamp_ms: 2000,
|
|
});
|
|
push_reading_event(ReadingEvent::MaterialOpened {
|
|
material_id: "m3".into(),
|
|
timestamp_ms: 3000,
|
|
});
|
|
clear_exported_events(2);
|
|
let events = export_pending_events();
|
|
assert_eq!(events.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_reading_position() {
|
|
let _guard = TEST_LOCK.lock().unwrap();
|
|
setup();
|
|
update_reading_position(
|
|
"m1",
|
|
ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 },
|
|
);
|
|
let events = export_pending_events();
|
|
assert_eq!(events.len(), 1);
|
|
assert!(matches!(events[0], ReadingEvent::PositionChanged { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_export() {
|
|
let _guard = TEST_LOCK.lock().unwrap();
|
|
setup();
|
|
let events = export_pending_events();
|
|
assert!(events.is_empty());
|
|
}
|
|
}
|