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 progress;
|
||||||
pub mod reading_material;
|
pub mod reading_material;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod session_v2;
|
||||||
pub mod text;
|
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