feat: DOC-FULL-003 ReadingEventV2

5 types: MaterialOpened/Closed/PositionChanged/Heartbeat/MarkedAsRead
active_seconds_delta (增量)
集成 ReadingSessionV2 (clientSessionId/sequence/materialId)
10 单元测试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-07 19:37:19 +08:00
parent 938ebf890c
commit 82e8cdfc1f
2 changed files with 279 additions and 0 deletions

View File

@ -0,0 +1,278 @@
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::progress::ReadingPosition;
use crate::session_v2;
const MAX_BUFFER_SIZE: usize = 1000;
fn buffer() -> &'static Mutex<Vec<ReadingEventV2>> {
use std::sync::OnceLock;
static BUF: OnceLock<Mutex<Vec<ReadingEventV2>>> = OnceLock::new();
BUF.get_or_init(|| Mutex::new(Vec::new()))
}
// ── V2 Event Types ──
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Enum)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ReadingEventTypeV2 {
MaterialOpened,
MaterialClosed,
PositionChanged,
Heartbeat,
MarkedAsRead,
}
// ── V2 Event ──
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Record)]
#[serde(rename_all = "camelCase")]
pub struct ReadingEventV2 {
pub event_id: String,
pub client_session_id: String,
pub material_id: String,
pub event_type: ReadingEventTypeV2,
pub position: Option<ReadingPosition>,
pub active_seconds_delta: u32,
pub timestamp_ms: i64,
pub sequence: u64,
}
// ── Push Functions ──
fn push_event(
session_id: &str,
material_id: &str,
event_type: ReadingEventTypeV2,
position: Option<ReadingPosition>,
active_seconds_delta: u32,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
let seq = session_v2::record_session_event_v2(
session_id,
timestamp_ms,
active_seconds_delta,
position.clone(),
)?;
let event = ReadingEventV2 {
event_id: uuid::Uuid::new_v4().to_string(),
client_session_id: session_id.to_string(),
material_id: material_id.to_string(),
event_type,
position,
active_seconds_delta,
timestamp_ms,
sequence: seq,
};
match buffer().lock() {
Ok(mut buf) => {
if buf.len() >= MAX_BUFFER_SIZE {
buf.remove(0);
}
buf.push(event.clone());
}
Err(_) => { /* poison: drop */ }
}
Ok(event)
}
pub fn push_material_opened_v2(
session_id: &str,
material_id: &str,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::MaterialOpened, None, 0, timestamp_ms)
}
pub fn push_material_closed_v2(
session_id: &str,
material_id: &str,
active_seconds_delta: u32,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::MaterialClosed, None, active_seconds_delta, timestamp_ms)
}
pub fn push_position_changed_v2(
session_id: &str,
material_id: &str,
position: ReadingPosition,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::PositionChanged, Some(position), 0, timestamp_ms)
}
pub fn push_heartbeat_v2(
session_id: &str,
material_id: &str,
active_seconds_delta: u32,
position: Option<ReadingPosition>,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::Heartbeat, position, active_seconds_delta, timestamp_ms)
}
pub fn push_marked_as_read_v2(
session_id: &str,
material_id: &str,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::MarkedAsRead, None, 0, timestamp_ms)
}
// ── Buffer Management ──
pub fn export_pending_events_v2() -> Vec<ReadingEventV2> {
buffer().lock().map(|buf| buf.clone()).unwrap_or_default()
}
pub fn clear_exported_events_v2(count: usize) {
if let Ok(mut buf) = buffer().lock() {
let n = count.min(buf.len());
buf.drain(..n);
}
}
pub fn buffer_size_v2() -> usize {
buffer().lock().map(|b| b.len()).unwrap_or(0)
}
pub fn clear_all_events_v2() {
if let Ok(mut buf) = buffer().lock() {
buf.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reading_material::ReadingMaterialRef;
fn setup_session(material_id: &str) -> String {
let mat = ReadingMaterialRef::new(material_id);
let id = session_v2::start_reading_session_v2(mat, 1000).unwrap();
clear_all_events_v2();
id
}
fn teardown_session(id: &str) {
let _ = session_v2::close_reading_session_v2(id);
let _ = session_v2::remove_session_v2(id);
}
#[test]
fn test_push_opened() {
let sid = setup_session("mat_1");
let ev = push_material_opened_v2(&sid, "mat_1", 1000).unwrap();
assert_eq!(ev.event_type, ReadingEventTypeV2::MaterialOpened);
assert_eq!(ev.active_seconds_delta, 0);
assert_eq!(ev.sequence, 1);
assert!(!ev.event_id.is_empty());
assert_eq!(ev.material_id, "mat_1");
teardown_session(&sid);
}
#[test]
fn test_push_closed() {
let sid = setup_session("mat_2");
let ev = push_material_closed_v2(&sid, "mat_2", 5, 5000).unwrap();
assert_eq!(ev.event_type, ReadingEventTypeV2::MaterialClosed);
assert_eq!(ev.active_seconds_delta, 5);
teardown_session(&sid);
}
#[test]
fn test_push_position_changed() {
let sid = setup_session("mat_3");
let pos = ReadingPosition::Pdf { page_number: 3, page_progress: 0.5, overall_progress: 0.1 };
let ev = push_position_changed_v2(&sid, "mat_3", pos.clone(), 2000).unwrap();
assert_eq!(ev.event_type, ReadingEventTypeV2::PositionChanged);
assert_eq!(ev.active_seconds_delta, 0);
assert!(ev.position.is_some());
teardown_session(&sid);
}
#[test]
fn test_push_heartbeat() {
let sid = setup_session("mat_4");
let ev = push_heartbeat_v2(&sid, "mat_4", 15, None, 3000).unwrap();
assert_eq!(ev.event_type, ReadingEventTypeV2::Heartbeat);
assert_eq!(ev.active_seconds_delta, 15);
teardown_session(&sid);
}
#[test]
fn test_push_marked_as_read() {
let sid = setup_session("mat_5");
let ev = push_marked_as_read_v2(&sid, "mat_5", 4000).unwrap();
assert_eq!(ev.event_type, ReadingEventTypeV2::MarkedAsRead);
assert_eq!(ev.active_seconds_delta, 0);
teardown_session(&sid);
}
#[test]
fn test_sequence_increments() {
let sid = setup_session("mat_seq");
let e1 = push_material_opened_v2(&sid, "mat_seq", 1000).unwrap();
let e2 = push_heartbeat_v2(&sid, "mat_seq", 15, None, 2000).unwrap();
let e3 = push_material_closed_v2(&sid, "mat_seq", 5, 3000).unwrap();
assert_eq!(e1.sequence, 1);
assert_eq!(e2.sequence, 2);
assert_eq!(e3.sequence, 3);
teardown_session(&sid);
}
#[test]
fn test_export_and_clear() {
clear_all_events_v2();
let sid = setup_session("mat_export");
push_material_opened_v2(&sid, "mat_export", 1000).unwrap();
push_heartbeat_v2(&sid, "mat_export", 15, None, 2000).unwrap();
let before = buffer_size_v2();
assert!(before >= 2, "expected >=2 events, got {before}");
let exported = export_pending_events_v2();
assert_eq!(exported.len(), before);
clear_exported_events_v2(before);
assert_eq!(buffer_size_v2(), 0);
teardown_session(&sid);
}
#[test]
fn test_closed_session_rejects() {
let sid = setup_session("mat_reject");
session_v2::close_reading_session_v2(&sid).unwrap();
assert!(push_heartbeat_v2(&sid, "mat_reject", 15, None, 3000).is_err());
teardown_session(&sid);
}
#[test]
fn test_serde_roundtrip() {
let sid = setup_session("mat_serde");
let ev = push_material_opened_v2(&sid, "mat_serde", 1000).unwrap();
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains("materialOpened") || json.contains("\"type\":\"MaterialOpened\""));
let back: ReadingEventV2 = serde_json::from_str(&json).unwrap();
assert_eq!(back.event_id, ev.event_id);
assert_eq!(back.sequence, 1);
teardown_session(&sid);
}
#[test]
fn test_position_serde() {
let sid = setup_session("mat_pos");
let pos = ReadingPosition::Markdown {
block_id: "h1".into(),
scroll_progress: 0.5,
};
let ev = push_position_changed_v2(&sid, "mat_pos", pos, 2000).unwrap();
let json = serde_json::to_string(&ev).unwrap();
// Position JSON should have camelCase fields
assert!(json.contains("blockId") || json.contains("block_id"));
teardown_session(&sid);
}
}

View File

@ -6,6 +6,7 @@ pub mod document;
pub mod epub; pub mod epub;
pub mod error; pub mod error;
pub mod events; pub mod events;
pub mod events_v2;
pub mod image_meta; pub mod image_meta;
pub mod markdown; pub mod markdown;
pub mod material_type; pub mod material_type;