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:
wangdl 2026-06-11 21:03:19 +08:00
parent c276503b0d
commit c30957e1da
2 changed files with 166 additions and 0 deletions

View File

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

View File

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