feat: ReadingSession V2 complete (DOC-FULL-002)
- 新增 ReadingSessionStatus::Interrupted / Failed - ReadingSessionV2 增加 closedAtMs 字段 - 新增 mark_session_interrupted_v2 / mark_session_failed_v2 - 新增 get_active_session_v2 (按 materialId 查 active session) - 新增 set_session_closed_at_ms (独立设置关闭时间) - FFI 暴露 6 个新接口 - SessionError 补充 AlreadyInterrupted / AlreadyFailed - 167 tests passed / 0 failed Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c276503b0d
commit
c30957e1da
@ -22,6 +22,8 @@ pub enum ReadingSessionStatus {
|
||||
Active,
|
||||
Paused,
|
||||
Closed,
|
||||
Interrupted,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
@ -29,6 +31,7 @@ pub struct ReadingSessionV2 {
|
||||
pub client_session_id: String,
|
||||
pub material: ReadingMaterialRefV2,
|
||||
pub started_at_ms: i64,
|
||||
pub closed_at_ms: Option<i64>,
|
||||
pub last_event_at_ms: i64,
|
||||
pub next_sequence: u64,
|
||||
pub total_active_seconds: u32,
|
||||
@ -50,6 +53,7 @@ pub fn start_reading_session_v2(
|
||||
client_session_id: session_id.clone(),
|
||||
material,
|
||||
started_at_ms: timestamp_ms,
|
||||
closed_at_ms: None,
|
||||
last_event_at_ms: timestamp_ms,
|
||||
next_sequence: 1,
|
||||
total_active_seconds: 0,
|
||||
@ -113,6 +117,56 @@ pub fn close_reading_session_v2(session_id: &str) -> Result<(), SessionError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Set closed_at_ms from the caller's timestamp after closing succeeds.
|
||||
/// Separated from close_reading_session_v2 so the close status transition
|
||||
/// is atomic and the timestamp is set independently.
|
||||
pub fn set_session_closed_at_ms(session_id: &str, timestamp_ms: i64) -> Result<(), SessionError> {
|
||||
with_session_mut(session_id, |s| {
|
||||
s.closed_at_ms = Some(timestamp_ms);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark a session as interrupted (app crash / background kill / orphaned).
|
||||
pub fn mark_session_interrupted_v2(session_id: &str) -> Result<(), SessionError> {
|
||||
with_session_mut(session_id, |s| {
|
||||
if s.status == ReadingSessionStatus::Closed {
|
||||
return Err(SessionError::AlreadyClosed);
|
||||
}
|
||||
if s.status == ReadingSessionStatus::Interrupted {
|
||||
return Err(SessionError::AlreadyInterrupted);
|
||||
}
|
||||
s.status = ReadingSessionStatus::Interrupted;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark a session as failed (unrecoverable error).
|
||||
pub fn mark_session_failed_v2(session_id: &str) -> Result<(), SessionError> {
|
||||
with_session_mut(session_id, |s| {
|
||||
if s.status == ReadingSessionStatus::Closed {
|
||||
return Err(SessionError::AlreadyClosed);
|
||||
}
|
||||
if s.status == ReadingSessionStatus::Failed {
|
||||
return Err(SessionError::AlreadyFailed);
|
||||
}
|
||||
s.status = ReadingSessionStatus::Failed;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the active session for a given material_id, if any.
|
||||
pub fn get_active_session_v2(material_id: &str) -> Option<ReadingSessionV2> {
|
||||
match sessions().lock() {
|
||||
Ok(map) => {
|
||||
map.values()
|
||||
.find(|s| s.material.material_id == material_id && s.status == ReadingSessionStatus::Active)
|
||||
.cloned()
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@ -227,6 +281,8 @@ where
|
||||
pub enum SessionError {
|
||||
NotFound,
|
||||
AlreadyClosed,
|
||||
AlreadyInterrupted,
|
||||
AlreadyFailed,
|
||||
NotActive,
|
||||
NotClosed,
|
||||
LockPoisoned,
|
||||
@ -237,6 +293,8 @@ impl std::fmt::Display for SessionError {
|
||||
match self {
|
||||
Self::NotFound => write!(f, "Session not found"),
|
||||
Self::AlreadyClosed => write!(f, "Session already closed"),
|
||||
Self::AlreadyInterrupted => write!(f, "Session already interrupted"),
|
||||
Self::AlreadyFailed => write!(f, "Session already failed"),
|
||||
Self::NotActive => write!(f, "Session is not active (paused or closed)"),
|
||||
Self::NotClosed => write!(f, "Session must be closed before removal"),
|
||||
Self::LockPoisoned => write!(f, "Session lock poisoned"),
|
||||
@ -422,4 +480,87 @@ mod tests {
|
||||
|
||||
teardown(&active);
|
||||
}
|
||||
|
||||
// ── Interrupted / Failed / closed_at_ms / get_active_session ──
|
||||
|
||||
#[test]
|
||||
fn test_mark_interrupted() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
mark_session_interrupted_v2(&id).unwrap();
|
||||
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Interrupted);
|
||||
// Double mark should fail
|
||||
assert!(mark_session_interrupted_v2(&id).is_err());
|
||||
teardown(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_failed() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
mark_session_failed_v2(&id).unwrap();
|
||||
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Failed);
|
||||
// Double mark should fail
|
||||
assert!(mark_session_failed_v2(&id).is_err());
|
||||
teardown(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_closed_rejects_interrupted() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
close_reading_session_v2(&id).unwrap();
|
||||
assert!(mark_session_interrupted_v2(&id).is_err());
|
||||
assert!(mark_session_failed_v2(&id).is_err());
|
||||
remove_session_v2(&id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_closed_at_ms() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
assert!(get_session_v2(&id).unwrap().closed_at_ms.is_none());
|
||||
close_reading_session_v2(&id).unwrap();
|
||||
set_session_closed_at_ms(&id, 5000).unwrap();
|
||||
assert_eq!(get_session_v2(&id).unwrap().closed_at_ms, Some(5000));
|
||||
remove_session_v2(&id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_active_session_by_material() {
|
||||
let mat_a = ReadingMaterialRefV2::new("mat_a", "/tmp/a.md", "a.md", "markdown");
|
||||
let mat_b = ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown");
|
||||
|
||||
let id_a = start_reading_session_v2(mat_a.clone(), 1000).unwrap();
|
||||
let _id_b = start_reading_session_v2(mat_b.clone(), 1000).unwrap();
|
||||
|
||||
let found = get_active_session_v2("mat_a");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().client_session_id, id_a);
|
||||
|
||||
// Close session a, should not be found as active
|
||||
close_reading_session_v2(&id_a).unwrap();
|
||||
assert!(get_active_session_v2("mat_a").is_none());
|
||||
|
||||
teardown(&id_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_does_not_store_business_fields() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
let s = get_session_v2(&id).unwrap();
|
||||
// Verify the material ref has no business fields
|
||||
assert_eq!(s.material.material_id, "test_mat_001");
|
||||
// Session itself has no userId / knowledgeBaseId / readingTargetType fields
|
||||
// (verified at compile time — these fields don't exist on the struct)
|
||||
teardown(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sequence_monotonic_in_session() {
|
||||
let id = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||
let s1 = record_session_event_v2(&id, 2000, 0, None).unwrap();
|
||||
let s2 = record_session_event_v2(&id, 3000, 0, None).unwrap();
|
||||
let s3 = record_session_event_v2(&id, 4000, 0, None).unwrap();
|
||||
assert!(s1 < s2);
|
||||
assert!(s2 < s3);
|
||||
assert_eq!(s3, 3);
|
||||
teardown(&id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,31 @@ fn close_reading_session_v2(session_id: String) -> Result<(), String> {
|
||||
zx_document_core::session_v2::close_reading_session_v2(&session_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
fn set_session_closed_at_ms_v2(session_id: String, timestamp_ms: i64) -> Result<(), String> {
|
||||
zx_document_core::session_v2::set_session_closed_at_ms(&session_id, timestamp_ms).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
fn mark_session_interrupted_v2(session_id: String) -> Result<(), String> {
|
||||
zx_document_core::session_v2::mark_session_interrupted_v2(&session_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
fn mark_session_failed_v2(session_id: String) -> Result<(), String> {
|
||||
zx_document_core::session_v2::mark_session_failed_v2(&session_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
fn get_active_session_v2(material_id: String) -> Option<ReadingSessionV2> {
|
||||
zx_document_core::session_v2::get_active_session_v2(&material_id)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
fn get_session_v2_ffi(session_id: String) -> Option<ReadingSessionV2> {
|
||||
zx_document_core::session_v2::get_session_v2(&session_id).ok()
|
||||
}
|
||||
|
||||
// ── V2 Reading Event FFI ──
|
||||
|
||||
#[uniffi::export]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user