From 82e8cdfc1f4853d91b400ec64a4845cc0786c59b Mon Sep 17 00:00:00 2001 From: wangdl Date: Sun, 7 Jun 2026 19:37:19 +0800 Subject: [PATCH] feat: DOC-FULL-003 ReadingEventV2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 types: MaterialOpened/Closed/PositionChanged/Heartbeat/MarkedAsRead active_seconds_delta (增量) 集成 ReadingSessionV2 (clientSessionId/sequence/materialId) 10 单元测试 Co-Authored-By: Claude Opus 4.7 --- crates/zx_document_core/src/events_v2.rs | 278 +++++++++++++++++++++++ crates/zx_document_core/src/lib.rs | 1 + 2 files changed, 279 insertions(+) create mode 100644 crates/zx_document_core/src/events_v2.rs diff --git a/crates/zx_document_core/src/events_v2.rs b/crates/zx_document_core/src/events_v2.rs new file mode 100644 index 0000000..a2174d7 --- /dev/null +++ b/crates/zx_document_core/src/events_v2.rs @@ -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> { + use std::sync::OnceLock; + static BUF: OnceLock>> = 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, + 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, + active_seconds_delta: u32, + timestamp_ms: i64, +) -> Result { + 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 { + 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 { + 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 { + 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, + timestamp_ms: i64, +) -> Result { + 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 { + push_event(session_id, material_id, ReadingEventTypeV2::MarkedAsRead, None, 0, timestamp_ms) +} + +// ── Buffer Management ── + +pub fn export_pending_events_v2() -> Vec { + 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); + } +} diff --git a/crates/zx_document_core/src/lib.rs b/crates/zx_document_core/src/lib.rs index 60101ae..d0abe52 100644 --- a/crates/zx_document_core/src/lib.rs +++ b/crates/zx_document_core/src/lib.rs @@ -6,6 +6,7 @@ pub mod document; pub mod epub; pub mod error; pub mod events; +pub mod events_v2; pub mod image_meta; pub mod markdown; pub mod material_type;