2026-06-07 20:33:53 +08:00

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());
}
}