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:
parent
938ebf890c
commit
82e8cdfc1f
278
crates/zx_document_core/src/events_v2.rs
Normal file
278
crates/zx_document_core/src/events_v2.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user