feat: ReadingMaterialRef V2 (DOC-FULL-001)
- 新增 ReadingMaterialRefV2: materialId/filePath/fileName/fileType (camelCase) + mimeType/fileSize/contentHash/createdAtMs (可选) - V1 ReadingMaterialRef 标记 deprecated,保留向后兼容 - session_v2 迁移到 V2 类型 - FFI 层暴露 V2 类型,start_reading_session_v2 接收 V2 - 明确禁止字段: readingTargetType/userId/knowledgeBaseId - 160 tests passed / 0 failed Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a81a9d7e1f
commit
c276503b0d
@ -241,13 +241,13 @@ mod tests {
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::reading_material::ReadingMaterialRef;
|
use crate::reading_material::ReadingMaterialRefV2;
|
||||||
|
|
||||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
fn setup_session(material_id: &str) -> String {
|
fn setup_session(material_id: &str) -> String {
|
||||||
// Lock is held for the duration of each test via the caller.
|
// Lock is held for the duration of each test via the caller.
|
||||||
let mat = ReadingMaterialRef::new(material_id);
|
let mat = ReadingMaterialRefV2::new(material_id, "/tmp/test.md", "test.md", "markdown");
|
||||||
let id = session_v2::start_reading_session_v2(mat, 1000).unwrap();
|
let id = session_v2::start_reading_session_v2(mat, 1000).unwrap();
|
||||||
clear_all_events_v2();
|
clear_all_events_v2();
|
||||||
id
|
id
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Reference to a reading material.
|
/// V1 reference to a reading material (deprecated – use ReadingMaterialRefV2).
|
||||||
///
|
///
|
||||||
/// Rust does not know whether this is a KnowledgeSource or TemporaryReadingMaterial.
|
/// Rust does not know whether this is a KnowledgeSource or TemporaryReadingMaterial.
|
||||||
/// readingTargetType is added by the iOS/API layer.
|
/// readingTargetType is added by the iOS/API layer.
|
||||||
|
#[deprecated(since = "0.2.0", note = "use ReadingMaterialRefV2 instead")]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct ReadingMaterialRef {
|
pub struct ReadingMaterialRef {
|
||||||
pub material_id: String,
|
pub material_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
impl ReadingMaterialRef {
|
impl ReadingMaterialRef {
|
||||||
pub fn new(material_id: impl Into<String>) -> Self {
|
pub fn new(material_id: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -17,22 +19,145 @@ impl ReadingMaterialRef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── V2 ──
|
||||||
|
|
||||||
|
/// V2 reading material reference.
|
||||||
|
///
|
||||||
|
/// Contains the minimal file-level information Rust needs.
|
||||||
|
/// Does NOT contain:
|
||||||
|
/// - readingTargetType (added by iOS/API)
|
||||||
|
/// - userId
|
||||||
|
/// - knowledgeBaseId
|
||||||
|
/// - platform / appVersion
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReadingMaterialRefV2 {
|
||||||
|
pub material_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub file_type: String,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub file_size: Option<u64>,
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
pub created_at_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadingMaterialRefV2 {
|
||||||
|
pub fn new(
|
||||||
|
material_id: impl Into<String>,
|
||||||
|
file_path: impl Into<String>,
|
||||||
|
file_name: impl Into<String>,
|
||||||
|
file_type: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
material_id: material_id.into(),
|
||||||
|
file_path: file_path.into(),
|
||||||
|
file_name: file_name.into(),
|
||||||
|
file_type: file_type.into(),
|
||||||
|
mime_type: None,
|
||||||
|
file_size: None,
|
||||||
|
content_hash: None,
|
||||||
|
created_at_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_mime(mut self, mime_type: impl Into<String>) -> Self {
|
||||||
|
self.mime_type = Some(mime_type.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_file_size(mut self, file_size: u64) -> Self {
|
||||||
|
self.file_size = Some(file_size);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_content_hash(mut self, hash: impl Into<String>) -> Self {
|
||||||
|
self.content_hash = Some(hash.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new() {
|
fn test_v1_new() {
|
||||||
let r = ReadingMaterialRef::new("src_123");
|
let r = ReadingMaterialRef::new("src_123");
|
||||||
assert_eq!(r.material_id, "src_123");
|
assert_eq!(r.material_id, "src_123");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_serde() {
|
fn test_v1_serde() {
|
||||||
let r = ReadingMaterialRef::new("src_456");
|
let r = ReadingMaterialRef::new("src_456");
|
||||||
let json = serde_json::to_string(&r).unwrap();
|
let json = serde_json::to_string(&r).unwrap();
|
||||||
assert!(json.contains("src_456"));
|
assert!(json.contains("src_456"));
|
||||||
let back: ReadingMaterialRef = serde_json::from_str(&json).unwrap();
|
let back: ReadingMaterialRef = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(back, r);
|
assert_eq!(back, r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── V2 tests ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v2_new_minimal() {
|
||||||
|
let r = ReadingMaterialRefV2::new("mat_1", "/tmp/test.md", "test.md", "markdown");
|
||||||
|
assert_eq!(r.material_id, "mat_1");
|
||||||
|
assert_eq!(r.file_path, "/tmp/test.md");
|
||||||
|
assert_eq!(r.file_name, "test.md");
|
||||||
|
assert_eq!(r.file_type, "markdown");
|
||||||
|
assert!(r.mime_type.is_none());
|
||||||
|
assert!(r.file_size.is_none());
|
||||||
|
assert!(r.content_hash.is_none());
|
||||||
|
assert!(r.created_at_ms.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v2_builder() {
|
||||||
|
let r = ReadingMaterialRefV2::new("mat_2", "/tmp/doc.pdf", "doc.pdf", "pdf")
|
||||||
|
.with_mime("application/pdf")
|
||||||
|
.with_file_size(102400)
|
||||||
|
.with_content_hash("abc123def");
|
||||||
|
assert_eq!(r.mime_type.as_deref(), Some("application/pdf"));
|
||||||
|
assert_eq!(r.file_size, Some(102400));
|
||||||
|
assert_eq!(r.content_hash.as_deref(), Some("abc123def"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v2_serde_camel_case() {
|
||||||
|
let r = ReadingMaterialRefV2::new("mat_3", "/a/b.epub", "b.epub", "epub")
|
||||||
|
.with_mime("application/epub+zip");
|
||||||
|
let json = serde_json::to_string(&r).unwrap();
|
||||||
|
// must use camelCase keys
|
||||||
|
assert!(json.contains("\"materialId\""));
|
||||||
|
assert!(json.contains("\"filePath\""));
|
||||||
|
assert!(json.contains("\"fileName\""));
|
||||||
|
assert!(json.contains("\"fileType\""));
|
||||||
|
assert!(json.contains("\"mimeType\""));
|
||||||
|
assert!(!json.contains("material_id"));
|
||||||
|
assert!(!json.contains("file_path"));
|
||||||
|
|
||||||
|
let back: ReadingMaterialRefV2 = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.file_type, "epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v2_serde_roundtrip() {
|
||||||
|
let r = ReadingMaterialRefV2::new("mat_4", "/z/t.txt", "t.txt", "text")
|
||||||
|
.with_file_size(42);
|
||||||
|
let json = serde_json::to_string(&r).unwrap();
|
||||||
|
let back: ReadingMaterialRefV2 = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v2_no_forbidden_fields() {
|
||||||
|
// Verify JSON contains no business-layer fields
|
||||||
|
let r = ReadingMaterialRefV2::new("mat_5", "/x", "x.md", "markdown");
|
||||||
|
let json = serde_json::to_string(&r).unwrap();
|
||||||
|
assert!(!json.contains("readingTargetType"));
|
||||||
|
assert!(!json.contains("userId"));
|
||||||
|
assert!(!json.contains("knowledgeBaseId"));
|
||||||
|
assert!(!json.contains("platform"));
|
||||||
|
assert!(!json.contains("appVersion"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::progress::ReadingPosition;
|
use crate::progress::ReadingPosition;
|
||||||
use crate::reading_material::ReadingMaterialRef;
|
use crate::reading_material::ReadingMaterialRefV2;
|
||||||
use crate::time_tracker::ActiveTimeTracker;
|
use crate::time_tracker::ActiveTimeTracker;
|
||||||
|
|
||||||
fn sessions() -> &'static Mutex<HashMap<String, ReadingSessionV2>> {
|
fn sessions() -> &'static Mutex<HashMap<String, ReadingSessionV2>> {
|
||||||
@ -27,7 +27,7 @@ pub enum ReadingSessionStatus {
|
|||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct ReadingSessionV2 {
|
pub struct ReadingSessionV2 {
|
||||||
pub client_session_id: String,
|
pub client_session_id: String,
|
||||||
pub material: ReadingMaterialRef,
|
pub material: ReadingMaterialRefV2,
|
||||||
pub started_at_ms: i64,
|
pub started_at_ms: i64,
|
||||||
pub last_event_at_ms: i64,
|
pub last_event_at_ms: i64,
|
||||||
pub next_sequence: u64,
|
pub next_sequence: u64,
|
||||||
@ -38,7 +38,7 @@ pub struct ReadingSessionV2 {
|
|||||||
|
|
||||||
/// Start a new reading session. Returns the client_session_id as handle.
|
/// Start a new reading session. Returns the client_session_id as handle.
|
||||||
pub fn start_reading_session_v2(
|
pub fn start_reading_session_v2(
|
||||||
material: ReadingMaterialRef,
|
material: ReadingMaterialRefV2,
|
||||||
timestamp_ms: i64,
|
timestamp_ms: i64,
|
||||||
) -> Result<String, SessionError> {
|
) -> Result<String, SessionError> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
@ -248,8 +248,8 @@ impl std::fmt::Display for SessionError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn test_material() -> ReadingMaterialRef {
|
fn test_material() -> ReadingMaterialRefV2 {
|
||||||
ReadingMaterialRef::new("test_mat_001")
|
ReadingMaterialRefV2::new("test_mat_001", "/tmp/test.md", "test.md", "markdown")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn teardown(id: &str) {
|
fn teardown(id: &str) {
|
||||||
@ -327,7 +327,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_two_sessions_independent() {
|
fn test_two_sessions_independent() {
|
||||||
let a = start_reading_session_v2(test_material(), 1000).unwrap();
|
let a = start_reading_session_v2(test_material(), 1000).unwrap();
|
||||||
let b = start_reading_session_v2(ReadingMaterialRef::new("mat_b"), 2000).unwrap();
|
let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 2000).unwrap();
|
||||||
assert_ne!(a, b);
|
assert_ne!(a, b);
|
||||||
record_session_event_v2(&a, 3000, 0, None).unwrap();
|
record_session_event_v2(&a, 3000, 0, None).unwrap();
|
||||||
record_session_event_v2(&b, 4000, 0, None).unwrap();
|
record_session_event_v2(&b, 4000, 0, None).unwrap();
|
||||||
@ -350,7 +350,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_sequence_and_seconds_independent() {
|
fn test_sequence_and_seconds_independent() {
|
||||||
let a = start_reading_session_v2(test_material(), 0).unwrap();
|
let a = start_reading_session_v2(test_material(), 0).unwrap();
|
||||||
let b = start_reading_session_v2(ReadingMaterialRef::new("mat_b"), 0).unwrap();
|
let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 0).unwrap();
|
||||||
record_session_event_v2(&a, 10_000, 10, None).unwrap();
|
record_session_event_v2(&a, 10_000, 10, None).unwrap();
|
||||||
record_session_event_v2(&b, 5_000, 5, None).unwrap();
|
record_session_event_v2(&b, 5_000, 5, None).unwrap();
|
||||||
record_session_event_v2(&b, 10_000, 5, None).unwrap();
|
record_session_event_v2(&b, 10_000, 5, None).unwrap();
|
||||||
@ -380,7 +380,7 @@ mod tests {
|
|||||||
record_session_event_v2(&old, 1000, 0, None).unwrap();
|
record_session_event_v2(&old, 1000, 0, None).unwrap();
|
||||||
|
|
||||||
// Recent session: last event at t=19000 (just 1s ago at now=20000)
|
// Recent session: last event at t=19000 (just 1s ago at now=20000)
|
||||||
let recent = start_reading_session_v2(ReadingMaterialRef::new("mat_recent"), 10000).unwrap();
|
let recent = start_reading_session_v2(ReadingMaterialRefV2::new("mat_recent", "/tmp/r.md", "r.md", "markdown"), 10000).unwrap();
|
||||||
record_session_event_v2(&recent, 19000, 0, None).unwrap();
|
record_session_event_v2(&recent, 19000, 0, None).unwrap();
|
||||||
|
|
||||||
// At t=20000, clean up sessions inactive for > 5000ms
|
// At t=20000, clean up sessions inactive for > 5000ms
|
||||||
|
|||||||
@ -11,7 +11,9 @@ pub use zx_document_core::search::SearchResult;
|
|||||||
pub use zx_document_core::anchors::NoteAnchor;
|
pub use zx_document_core::anchors::NoteAnchor;
|
||||||
pub use zx_document_core::progress::ReadingPosition;
|
pub use zx_document_core::progress::ReadingPosition;
|
||||||
pub use zx_document_core::events::ReadingEvent;
|
pub use zx_document_core::events::ReadingEvent;
|
||||||
|
#[allow(deprecated)]
|
||||||
pub use zx_document_core::reading_material::ReadingMaterialRef;
|
pub use zx_document_core::reading_material::ReadingMaterialRef;
|
||||||
|
pub use zx_document_core::reading_material::ReadingMaterialRefV2;
|
||||||
pub use zx_document_core::events_v2::{ReadingEventV2, ReadingEventTypeV2};
|
pub use zx_document_core::events_v2::{ReadingEventV2, ReadingEventTypeV2};
|
||||||
pub use zx_document_core::epub::{EpubMetadata, EpubChapter};
|
pub use zx_document_core::epub::{EpubMetadata, EpubChapter};
|
||||||
pub use zx_document_core::office::{OfficePreviewConfig, OfficePreviewStrategy};
|
pub use zx_document_core::office::{OfficePreviewConfig, OfficePreviewStrategy};
|
||||||
@ -23,7 +25,7 @@ use zx_document_core::blocks as core_blocks;
|
|||||||
// ── V2 Reading Session FFI ──
|
// ── V2 Reading Session FFI ──
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
fn start_reading_session_v2(material: ReadingMaterialRef, timestamp_ms: i64) -> Result<String, String> {
|
fn start_reading_session_v2(material: ReadingMaterialRefV2, timestamp_ms: i64) -> Result<String, String> {
|
||||||
zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms)
|
zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@ -666,7 +668,7 @@ mod ffi_tests {
|
|||||||
drain_buffer();
|
drain_buffer();
|
||||||
events_v2::clear_all_events_v2();
|
events_v2::clear_all_events_v2();
|
||||||
|
|
||||||
let mat = ReadingMaterialRef::new("mat_ffi_test".to_string());
|
let mat = ReadingMaterialRefV2::new("mat_ffi_test", "/tmp/test.md", "test.md", "markdown");
|
||||||
let sid = start_reading_session_v2(mat, 1000).unwrap();
|
let sid = start_reading_session_v2(mat, 1000).unwrap();
|
||||||
assert!(!sid.is_empty());
|
assert!(!sid.is_empty());
|
||||||
|
|
||||||
@ -709,7 +711,7 @@ mod ffi_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_session_lifecycle() {
|
fn test_session_lifecycle() {
|
||||||
let mat = ReadingMaterialRef::new("mat_ffi_life".to_string());
|
let mat = ReadingMaterialRefV2::new("mat_ffi_life", "/tmp/life.md", "life.md", "markdown");
|
||||||
let sid = start_reading_session_v2(mat, 0).unwrap();
|
let sid = start_reading_session_v2(mat, 0).unwrap();
|
||||||
pause_reading_session_v2(sid.clone()).unwrap();
|
pause_reading_session_v2(sid.clone()).unwrap();
|
||||||
resume_reading_session_v2(sid.clone()).unwrap();
|
resume_reading_session_v2(sid.clone()).unwrap();
|
||||||
@ -724,7 +726,7 @@ mod ffi_tests {
|
|||||||
drain_buffer();
|
drain_buffer();
|
||||||
events_v2::clear_all_events_v2();
|
events_v2::clear_all_events_v2();
|
||||||
|
|
||||||
let mat = ReadingMaterialRef::new("mat_ffi_recover".to_string());
|
let mat = ReadingMaterialRefV2::new("mat_ffi_recover", "/tmp/recover.md", "recover.md", "markdown");
|
||||||
let sid = start_reading_session_v2(mat, 0).unwrap();
|
let sid = start_reading_session_v2(mat, 0).unwrap();
|
||||||
let e = push_material_opened_v2(sid.clone(), "mat_ffi_recover".to_string(), 1000).unwrap();
|
let e = push_material_opened_v2(sid.clone(), "mat_ffi_recover".to_string(), 1000).unwrap();
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user