feat: DOC-FULL-002 ReadingSessionV2
- clientSessionId UUID 生成 - start/pause/resume/close lifecycle - sequence 递增 - record_session_event_v2 更新时长和位置 - 7 个单元测试 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
855c7b15c2
commit
938ebf890c
@ -13,4 +13,5 @@ pub mod pdf;
|
||||
pub mod progress;
|
||||
pub mod reading_material;
|
||||
pub mod search;
|
||||
pub mod session_v2;
|
||||
pub mod text;
|
||||
|
||||
254
crates/zx_document_core/src/session_v2.rs
Normal file
254
crates/zx_document_core/src/session_v2.rs
Normal file
@ -0,0 +1,254 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::progress::ReadingPosition;
|
||||
use crate::reading_material::ReadingMaterialRef;
|
||||
|
||||
fn sessions() -> &'static Mutex<HashMap<String, ReadingSessionV2>> {
|
||||
use std::sync::OnceLock;
|
||||
static SESSIONS: OnceLock<Mutex<HashMap<String, ReadingSessionV2>>> = OnceLock::new();
|
||||
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ReadingSessionStatus {
|
||||
Active,
|
||||
Paused,
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReadingSessionV2 {
|
||||
pub client_session_id: String,
|
||||
pub material: ReadingMaterialRef,
|
||||
pub started_at_ms: i64,
|
||||
pub last_event_at_ms: i64,
|
||||
pub next_sequence: u64,
|
||||
pub total_active_seconds: u32,
|
||||
pub last_position: Option<ReadingPosition>,
|
||||
pub status: ReadingSessionStatus,
|
||||
}
|
||||
|
||||
/// Start a new reading session. Returns the client_session_id as handle.
|
||||
pub fn start_reading_session_v2(
|
||||
material: ReadingMaterialRef,
|
||||
timestamp_ms: i64,
|
||||
) -> Result<String, SessionError> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let session = ReadingSessionV2 {
|
||||
client_session_id: session_id.clone(),
|
||||
material,
|
||||
started_at_ms: timestamp_ms,
|
||||
last_event_at_ms: timestamp_ms,
|
||||
next_sequence: 1,
|
||||
total_active_seconds: 0,
|
||||
last_position: None,
|
||||
status: ReadingSessionStatus::Active,
|
||||
};
|
||||
|
||||
match sessions().lock() {
|
||||
Ok(mut map) => {
|
||||
map.insert(session_id.clone(), session);
|
||||
Ok(session_id)
|
||||
}
|
||||
Err(_) => Err(SessionError::LockPoisoned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause the session. Stops active time tracking.
|
||||
pub fn pause_reading_session_v2(session_id: &str) -> Result<(), SessionError> {
|
||||
with_session_mut(session_id, |s| {
|
||||
if s.status == ReadingSessionStatus::Closed {
|
||||
return Err(SessionError::AlreadyClosed);
|
||||
}
|
||||
s.status = ReadingSessionStatus::Paused;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Resume a paused session.
|
||||
pub fn resume_reading_session_v2(session_id: &str) -> Result<(), SessionError> {
|
||||
with_session_mut(session_id, |s| {
|
||||
if s.status == ReadingSessionStatus::Closed {
|
||||
return Err(SessionError::AlreadyClosed);
|
||||
}
|
||||
s.status = ReadingSessionStatus::Active;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the session. No more events allowed.
|
||||
pub fn close_reading_session_v2(session_id: &str) -> Result<(), SessionError> {
|
||||
with_session_mut(session_id, |s| {
|
||||
if s.status == ReadingSessionStatus::Closed {
|
||||
return Err(SessionError::AlreadyClosed);
|
||||
}
|
||||
s.status = ReadingSessionStatus::Closed;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Record an event on this session: bumps sequence and updates last_event_at_ms.
|
||||
/// Returns the sequence number that should be assigned to the event.
|
||||
pub fn record_session_event_v2(
|
||||
session_id: &str,
|
||||
timestamp_ms: i64,
|
||||
active_seconds_delta: u32,
|
||||
position: Option<ReadingPosition>,
|
||||
) -> Result<u64, SessionError> {
|
||||
with_session_mut(session_id, |s| {
|
||||
if s.status == ReadingSessionStatus::Closed {
|
||||
return Err(SessionError::AlreadyClosed);
|
||||
}
|
||||
if s.status != ReadingSessionStatus::Active && active_seconds_delta > 0 {
|
||||
return Err(SessionError::NotActive);
|
||||
}
|
||||
let seq = s.next_sequence;
|
||||
s.next_sequence += 1;
|
||||
s.last_event_at_ms = timestamp_ms;
|
||||
if s.status == ReadingSessionStatus::Active {
|
||||
s.total_active_seconds += active_seconds_delta;
|
||||
}
|
||||
if position.is_some() {
|
||||
s.last_position = position;
|
||||
}
|
||||
Ok(seq)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a copy of the session info.
|
||||
pub fn get_session_v2(session_id: &str) -> Result<ReadingSessionV2, SessionError> {
|
||||
match sessions().lock() {
|
||||
Ok(map) => map.get(session_id).cloned().ok_or(SessionError::NotFound),
|
||||
Err(_) => Err(SessionError::LockPoisoned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a closed session from memory.
|
||||
pub fn remove_session_v2(session_id: &str) -> Result<(), SessionError> {
|
||||
match sessions().lock() {
|
||||
Ok(mut map) => {
|
||||
map.remove(session_id).ok_or(SessionError::NotFound)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Err(SessionError::LockPoisoned),
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
fn with_session_mut<F, T>(session_id: &str, f: F) -> Result<T, SessionError>
|
||||
where
|
||||
F: FnOnce(&mut ReadingSessionV2) -> Result<T, SessionError>,
|
||||
{
|
||||
match sessions().lock() {
|
||||
Ok(mut map) => {
|
||||
let session = map.get_mut(session_id).ok_or(SessionError::NotFound)?;
|
||||
f(session)
|
||||
}
|
||||
Err(_) => Err(SessionError::LockPoisoned),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SessionError {
|
||||
NotFound,
|
||||
AlreadyClosed,
|
||||
NotActive,
|
||||
LockPoisoned,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound => write!(f, "Session not found"),
|
||||
Self::AlreadyClosed => write!(f, "Session already closed"),
|
||||
Self::NotActive => write!(f, "Session is not active (paused or closed)"),
|
||||
Self::LockPoisoned => write!(f, "Session lock poisoned"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_material() -> ReadingMaterialRef {
|
||||
ReadingMaterialRef::new("test_mat_001")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_session() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
assert!(!id.is_empty());
|
||||
let s = get_session_v2(&id).unwrap();
|
||||
assert_eq!(s.material.material_id, "test_mat_001");
|
||||
assert_eq!(s.status, ReadingSessionStatus::Active);
|
||||
assert_eq!(s.next_sequence, 1);
|
||||
remove_session_v2(&id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pause_resume_close() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
pause_reading_session_v2(&id).unwrap();
|
||||
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Paused);
|
||||
resume_reading_session_v2(&id).unwrap();
|
||||
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Active);
|
||||
close_reading_session_v2(&id).unwrap();
|
||||
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Closed);
|
||||
remove_session_v2(&id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sequence_increments() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
let s1 = record_session_event_v2(&id, 2000, 15, None).unwrap();
|
||||
let s2 = record_session_event_v2(&id, 3000, 15, None).unwrap();
|
||||
assert_eq!(s1, 1);
|
||||
assert_eq!(s2, 2);
|
||||
assert_eq!(get_session_v2(&id).unwrap().total_active_seconds, 30);
|
||||
remove_session_v2(&id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_closed_rejects_events() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
close_reading_session_v2(&id).unwrap();
|
||||
assert!(record_session_event_v2(&id, 2000, 10, None).is_err());
|
||||
assert!(close_reading_session_v2(&id).is_err());
|
||||
remove_session_v2(&id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paused_rejects_active_seconds() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
pause_reading_session_v2(&id).unwrap();
|
||||
// PositionChanged (delta=0) should be allowed when paused
|
||||
let seq = record_session_event_v2(&id, 2000, 0, None).unwrap();
|
||||
assert_eq!(seq, 1);
|
||||
// Heartbeat (delta>0) should be rejected when paused
|
||||
assert!(record_session_event_v2(&id, 3000, 15, None).is_err());
|
||||
remove_session_v2(&id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_found() {
|
||||
assert!(get_session_v2("nonexistent").is_err());
|
||||
assert!(close_reading_session_v2("nonexistent").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_sessions_independent() {
|
||||
let a = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
let b = start_reading_session_v2(ReadingMaterialRef::new("mat_b"), 2000).unwrap();
|
||||
assert_ne!(a, b);
|
||||
record_session_event_v2(&a, 3000, 10, None).unwrap();
|
||||
record_session_event_v2(&b, 4000, 20, None).unwrap();
|
||||
assert_eq!(get_session_v2(&a).unwrap().total_active_seconds, 10);
|
||||
assert_eq!(get_session_v2(&b).unwrap().total_active_seconds, 20);
|
||||
remove_session_v2(&a).unwrap();
|
||||
remove_session_v2(&b).unwrap();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user