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:
wangdl 2026-06-07 19:34:12 +08:00
parent 855c7b15c2
commit 938ebf890c
2 changed files with 255 additions and 0 deletions

View File

@ -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;

View 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();
}
}