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 super::*;
|
||||
use crate::reading_material::ReadingMaterialRef;
|
||||
use crate::reading_material::ReadingMaterialRefV2;
|
||||
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn setup_session(material_id: &str) -> String {
|
||||
// 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();
|
||||
clear_all_events_v2();
|
||||
id
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
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.
|
||||
/// 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)]
|
||||
pub struct ReadingMaterialRef {
|
||||
pub material_id: String,
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl ReadingMaterialRef {
|
||||
pub fn new(material_id: impl Into<String>) -> 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new() {
|
||||
fn test_v1_new() {
|
||||
let r = ReadingMaterialRef::new("src_123");
|
||||
assert_eq!(r.material_id, "src_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde() {
|
||||
fn test_v1_serde() {
|
||||
let r = ReadingMaterialRef::new("src_456");
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains("src_456"));
|
||||
let back: ReadingMaterialRef = serde_json::from_str(&json).unwrap();
|
||||
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 crate::progress::ReadingPosition;
|
||||
use crate::reading_material::ReadingMaterialRef;
|
||||
use crate::reading_material::ReadingMaterialRefV2;
|
||||
use crate::time_tracker::ActiveTimeTracker;
|
||||
|
||||
fn sessions() -> &'static Mutex<HashMap<String, ReadingSessionV2>> {
|
||||
@ -27,7 +27,7 @@ pub enum ReadingSessionStatus {
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ReadingSessionV2 {
|
||||
pub client_session_id: String,
|
||||
pub material: ReadingMaterialRef,
|
||||
pub material: ReadingMaterialRefV2,
|
||||
pub started_at_ms: i64,
|
||||
pub last_event_at_ms: i64,
|
||||
pub next_sequence: u64,
|
||||
@ -38,7 +38,7 @@ pub struct ReadingSessionV2 {
|
||||
|
||||
/// Start a new reading session. Returns the client_session_id as handle.
|
||||
pub fn start_reading_session_v2(
|
||||
material: ReadingMaterialRef,
|
||||
material: ReadingMaterialRefV2,
|
||||
timestamp_ms: i64,
|
||||
) -> Result<String, SessionError> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
@ -248,8 +248,8 @@ impl std::fmt::Display for SessionError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_material() -> ReadingMaterialRef {
|
||||
ReadingMaterialRef::new("test_mat_001")
|
||||
fn test_material() -> ReadingMaterialRefV2 {
|
||||
ReadingMaterialRefV2::new("test_mat_001", "/tmp/test.md", "test.md", "markdown")
|
||||
}
|
||||
|
||||
fn teardown(id: &str) {
|
||||
@ -327,7 +327,7 @@ mod tests {
|
||||
#[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();
|
||||
let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 2000).unwrap();
|
||||
assert_ne!(a, b);
|
||||
record_session_event_v2(&a, 3000, 0, None).unwrap();
|
||||
record_session_event_v2(&b, 4000, 0, None).unwrap();
|
||||
@ -350,7 +350,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_sequence_and_seconds_independent() {
|
||||
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(&b, 5_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();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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::progress::ReadingPosition;
|
||||
pub use zx_document_core::events::ReadingEvent;
|
||||
#[allow(deprecated)]
|
||||
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::epub::{EpubMetadata, EpubChapter};
|
||||
pub use zx_document_core::office::{OfficePreviewConfig, OfficePreviewStrategy};
|
||||
@ -23,7 +25,7 @@ use zx_document_core::blocks as core_blocks;
|
||||
// ── V2 Reading Session FFI ──
|
||||
|
||||
#[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)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@ -666,7 +668,7 @@ mod ffi_tests {
|
||||
drain_buffer();
|
||||
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();
|
||||
assert!(!sid.is_empty());
|
||||
|
||||
@ -709,7 +711,7 @@ mod ffi_tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
pause_reading_session_v2(sid.clone()).unwrap();
|
||||
resume_reading_session_v2(sid.clone()).unwrap();
|
||||
@ -724,7 +726,7 @@ mod ffi_tests {
|
||||
drain_buffer();
|
||||
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 e = push_material_opened_v2(sid.clone(), "mat_ffi_recover".to_string(), 1000).unwrap();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user