314 lines
13 KiB
Rust
314 lines
13 KiB
Rust
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<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
|
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<f32> {
|
|
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<ReadingPosition, _> = 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<ReadingPosition, _> = serde_json::from_str(json_camel);
|
|
assert!(result.is_ok(), "camelCase should succeed: rename=blockId is the expected name");
|
|
}
|
|
}
|