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,
|
Active,
|
||||||
Paused,
|
Paused,
|
||||||
Closed,
|
Closed,
|
||||||
|
Interrupted,
|
||||||
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
@ -29,6 +31,7 @@ pub struct ReadingSessionV2 {
|
|||||||
pub client_session_id: String,
|
pub client_session_id: String,
|
||||||
pub material: ReadingMaterialRefV2,
|
pub material: ReadingMaterialRefV2,
|
||||||
pub started_at_ms: i64,
|
pub started_at_ms: i64,
|
||||||
|
pub closed_at_ms: Option<i64>,
|
||||||
pub last_event_at_ms: i64,
|
pub last_event_at_ms: i64,
|
||||||
pub next_sequence: u64,
|
pub next_sequence: u64,
|
||||||
pub total_active_seconds: u32,
|
pub total_active_seconds: u32,
|
||||||
@ -50,6 +53,7 @@ pub fn start_reading_session_v2(
|
|||||||
client_session_id: session_id.clone(),
|
client_session_id: session_id.clone(),
|
||||||
material,
|
material,
|
||||||
started_at_ms: timestamp_ms,
|
started_at_ms: timestamp_ms,
|
||||||
|
closed_at_ms: None,
|
||||||
last_event_at_ms: timestamp_ms,
|
last_event_at_ms: timestamp_ms,
|
||||||
next_sequence: 1,
|
next_sequence: 1,
|
||||||
total_active_seconds: 0,
|
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.
|
/// 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.
|
/// Returns the sequence number that should be assigned to the event.
|
||||||
pub fn record_session_event_v2(
|
pub fn record_session_event_v2(
|
||||||
@ -227,6 +281,8 @@ where
|
|||||||
pub enum SessionError {
|
pub enum SessionError {
|
||||||
NotFound,
|
NotFound,
|
||||||
AlreadyClosed,
|
AlreadyClosed,
|
||||||
|
AlreadyInterrupted,
|
||||||
|
AlreadyFailed,
|
||||||
NotActive,
|
NotActive,
|
||||||
NotClosed,
|
NotClosed,
|
||||||
LockPoisoned,
|
LockPoisoned,
|
||||||
@ -237,6 +293,8 @@ impl std::fmt::Display for SessionError {
|
|||||||
match self {
|
match self {
|
||||||
Self::NotFound => write!(f, "Session not found"),
|
Self::NotFound => write!(f, "Session not found"),
|
||||||
Self::AlreadyClosed => write!(f, "Session already closed"),
|
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::NotActive => write!(f, "Session is not active (paused or closed)"),
|
||||||
Self::NotClosed => write!(f, "Session must be closed before removal"),
|
Self::NotClosed => write!(f, "Session must be closed before removal"),
|
||||||
Self::LockPoisoned => write!(f, "Session lock poisoned"),
|
Self::LockPoisoned => write!(f, "Session lock poisoned"),
|
||||||
@ -422,4 +480,87 @@ mod tests {
|
|||||||
|
|
||||||
teardown(&active);
|
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())
|
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 ──
|
// ── V2 Reading Event FFI ──
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user