use serde::{Deserialize, Serialize, Serializer}; /// Clamp a progress value to 0..1. NaN→0, <0→0, >1→1. pub fn clamp_progress(v: f32) -> f32 { if v.is_nan() || v < 0.0 { return 0.0; } if v > 1.0 { return 1.0; } v } #[derive(Debug, Clone, PartialEq, Deserialize, uniffi::Enum)] #[serde(tag = "type")] pub enum ReadingPosition { Markdown { #[serde(rename = "blockId")] block_id: String, #[serde(rename = "scrollProgress")] scroll_progress: f32, }, Text { #[serde(rename = "lineNumber")] line_number: u32, #[serde(rename = "scrollProgress")] scroll_progress: f32, }, Pdf { #[serde(rename = "pageNumber")] page_number: u32, #[serde(rename = "pageProgress")] page_progress: f32, #[serde(rename = "overallProgress")] overall_progress: f32, }, Image { #[serde(rename = "zoomScale")] zoom_scale: f32, #[serde(rename = "offsetX")] offset_x: f32, #[serde(rename = "offsetY")] offset_y: f32, }, Epub { #[serde(rename = "chapterId")] chapter_id: String, #[serde(rename = "chapterProgress")] chapter_progress: f32, #[serde(rename = "overallProgress")] overall_progress: f32, }, Unknown, } // Manual Serialize to enforce camelCase field names + clamped progress values. impl Serialize for ReadingPosition { fn serialize(&self, s: S) -> Result { use serde::ser::SerializeStruct; match self { Self::Markdown { block_id, scroll_progress } => { let mut st = s.serialize_struct("Markdown", 3)?; st.serialize_field("type", "Markdown")?; st.serialize_field("blockId", block_id)?; st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?; st.end() } Self::Text { line_number, scroll_progress } => { let mut st = s.serialize_struct("Text", 3)?; st.serialize_field("type", "Text")?; st.serialize_field("lineNumber", line_number)?; st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?; st.end() } Self::Pdf { page_number, page_progress, overall_progress } => { let mut st = s.serialize_struct("Pdf", 4)?; st.serialize_field("type", "Pdf")?; st.serialize_field("pageNumber", page_number)?; st.serialize_field("pageProgress", &clamp_progress(*page_progress))?; st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?; st.end() } Self::Image { zoom_scale, offset_x, offset_y } => { let mut st = s.serialize_struct("Image", 4)?; st.serialize_field("type", "Image")?; st.serialize_field("zoomScale", zoom_scale)?; st.serialize_field("offsetX", offset_x)?; st.serialize_field("offsetY", offset_y)?; st.end() } Self::Epub { chapter_id, chapter_progress, overall_progress } => { let mut st = s.serialize_struct("Epub", 4)?; st.serialize_field("type", "Epub")?; st.serialize_field("chapterId", chapter_id)?; st.serialize_field("chapterProgress", &clamp_progress(*chapter_progress))?; st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?; st.end() } Self::Unknown => { let mut st = s.serialize_struct("Unknown", 1)?; st.serialize_field("type", "Unknown")?; st.end() } } } } impl ReadingPosition { /// Return a normalized copy with all progress fields clamped to 0..1. pub fn normalized(&self) -> Self { match self { Self::Markdown { block_id, scroll_progress } => Self::Markdown { block_id: block_id.clone(), scroll_progress: clamp_progress(*scroll_progress), }, Self::Text { line_number, scroll_progress } => Self::Text { line_number: *line_number, scroll_progress: clamp_progress(*scroll_progress), }, Self::Pdf { page_number, page_progress, overall_progress } => Self::Pdf { page_number: *page_number, page_progress: clamp_progress(*page_progress), overall_progress: clamp_progress(*overall_progress), }, Self::Image { zoom_scale, offset_x, offset_y } => Self::Image { zoom_scale: *zoom_scale, offset_x: *offset_x, offset_y: *offset_y, }, Self::Epub { chapter_id, chapter_progress, overall_progress } => Self::Epub { chapter_id: chapter_id.clone(), chapter_progress: clamp_progress(*chapter_progress), overall_progress: clamp_progress(*overall_progress), }, Self::Unknown => Self::Unknown, } } /// Check if a position type is compatible with a material type. pub fn is_compatible_with(&self, mt: &crate::material_type::MaterialType) -> bool { use crate::material_type::MaterialType; matches!( (self, mt), (ReadingPosition::Markdown { .. }, MaterialType::Markdown) | (ReadingPosition::Text { .. }, MaterialType::Text) | (ReadingPosition::Pdf { .. }, MaterialType::Pdf) | (ReadingPosition::Image { .. }, MaterialType::Image) | (ReadingPosition::Epub { .. }, MaterialType::Epub) | (ReadingPosition::Unknown, _) ) } pub fn progress_value(&self) -> Option { match self { Self::Markdown { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)), Self::Text { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)), Self::Pdf { overall_progress, .. } => Some(clamp_progress(*overall_progress)), Self::Epub { overall_progress, .. } => Some(clamp_progress(*overall_progress)), Self::Image { .. } | Self::Unknown => None, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_markdown_camel_case() { let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }; let json = serde_json::to_string(&pos).unwrap(); eprintln!("SERIALIZED: {json}"); assert!(json.contains("\"type\":\"Markdown\""), "missing type: {json}"); assert!(json.contains("\"blockId\":\"h1\""), "missing blockId: {json}"); assert!(json.contains("\"scrollProgress\":0.5"), "missing progress: {json}"); } #[test] fn test_pdf_camel_case() { let pos = ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 }; let json = serde_json::to_string(&pos).unwrap(); assert!(json.contains("\"pageNumber\":7")); assert!(json.contains("\"pageProgress\":0.8")); assert!(json.contains("\"overallProgress\":0.35")); } #[test] fn test_clamp_nan() { let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: f32::NAN }; let json = serde_json::to_string(&pos).unwrap(); assert!(json.contains("\"scrollProgress\":0.0")); } #[test] fn test_clamp_negative() { let pos = ReadingPosition::Text { line_number: 1, scroll_progress: -0.5 }; let json = serde_json::to_string(&pos).unwrap(); assert!(json.contains("\"scrollProgress\":0.0")); } #[test] fn test_clamp_above_one() { let pos = ReadingPosition::Pdf { page_number: 1, page_progress: 2.5, overall_progress: 10.0 }; let json = serde_json::to_string(&pos).unwrap(); assert!(json.contains("\"pageProgress\":1.0")); assert!(json.contains("\"overallProgress\":1.0")); } #[test] fn test_is_compatible() { use crate::material_type::MaterialType; let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }; assert!(md.is_compatible_with(&MaterialType::Markdown)); assert!(!md.is_compatible_with(&MaterialType::Pdf)); assert!(ReadingPosition::Unknown.is_compatible_with(&MaterialType::Pdf)); assert!(ReadingPosition::Unknown.is_compatible_with(&MaterialType::Markdown)); // Unknown = compatible with all } #[test] fn test_progress_value() { assert_eq!(ReadingPosition::Unknown.progress_value(), None); let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.75 }; assert_eq!(md.progress_value(), Some(0.75)); } #[test] fn test_roundtrip() { let positions = vec![ ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }, ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 }, ReadingPosition::Unknown, ]; for pos in &positions { let json = serde_json::to_string(pos).unwrap(); eprintln!("ROUNDTRIP JSON: {json}"); let back: ReadingPosition = serde_json::from_str(&json).unwrap(); assert_eq!(&back, pos); } } #[test] fn test_deserialize_from_camel_case_json() { // Simulate JSON from iOS app / API server: all camelCase field names let json = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#; let pos: ReadingPosition = serde_json::from_str(json).unwrap(); assert_eq!( pos, ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 } ); let json = r#"{"type":"Text","lineNumber":5,"scrollProgress":0.3}"#; let pos: ReadingPosition = serde_json::from_str(json).unwrap(); assert_eq!( pos, ReadingPosition::Text { line_number: 5, scroll_progress: 0.3 } ); let json = r#"{"type":"Pdf","pageNumber":3,"pageProgress":0.8,"overallProgress":0.35}"#; let pos: ReadingPosition = serde_json::from_str(json).unwrap(); assert_eq!( pos, ReadingPosition::Pdf { page_number: 3, page_progress: 0.8, overall_progress: 0.35 } ); let json = r#"{"type":"Image","zoomScale":1.5,"offsetX":10.0,"offsetY":20.0}"#; let pos: ReadingPosition = serde_json::from_str(json).unwrap(); assert_eq!( pos, ReadingPosition::Image { zoom_scale: 1.5, offset_x: 10.0, offset_y: 20.0 } ); let json = r#"{"type":"Epub","chapterId":"ch3","chapterProgress":0.6,"overallProgress":0.3}"#; let pos: ReadingPosition = serde_json::from_str(json).unwrap(); assert_eq!( pos, ReadingPosition::Epub { chapter_id: "ch3".into(), chapter_progress: 0.6, overall_progress: 0.3 } ); let json = r#"{"type":"Unknown"}"#; let pos: ReadingPosition = serde_json::from_str(json).unwrap(); assert_eq!(pos, ReadingPosition::Unknown); } #[test] fn test_deserialize_serialize_roundtrip_all_variants() { // Deserialize from camelCase → re-serialize → deserialize again → verify identical let positions = vec![ r#"{"type":"Markdown","blockId":"intro","scrollProgress":0.25}"#, r#"{"type":"Text","lineNumber":42,"scrollProgress":0.5}"#, r#"{"type":"Pdf","pageNumber":7,"pageProgress":0.8,"overallProgress":0.35}"#, r#"{"type":"Image","zoomScale":2.0,"offsetX":0.0,"offsetY":100.0}"#, r#"{"type":"Epub","chapterId":"ch1","chapterProgress":0.6,"overallProgress":0.3}"#, r#"{"type":"Unknown"}"#, ]; for json_str in &positions { let pos1: ReadingPosition = serde_json::from_str(json_str).unwrap(); let re_serialized = serde_json::to_string(&pos1).unwrap(); let pos2: ReadingPosition = serde_json::from_str(&re_serialized).unwrap(); assert_eq!(pos1, pos2, "roundtrip failed for: {json_str}"); } } #[test] fn test_rename_attrs_not_overridden_by_uniffi() { // Verify #[serde(rename)] works correctly alongside #[derive(uniffi::Enum)]: // serde uses renamed camelCase names, not Rust snake_case names. // If uniffi overrode the renames, "blockId" would NOT be recognized. // 1. snake_case should FAIL (field is renamed) let json_snake = r#"{"type":"Markdown","block_id":"h1","scroll_progress":0.5}"#; let result: Result = serde_json::from_str(json_snake); assert!(result.is_err(), "snake_case should fail: rename=blockId means block_id is unknown"); // 2. camelCase should SUCCEED let json_camel = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#; let result: Result = serde_json::from_str(json_camel); assert!(result.is_ok(), "camelCase should succeed: rename=blockId is the expected name"); } }