use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::animation::EasingType;
use super::background::{
deserialize_animated_backgrounds, deserialize_background_value, AnimatedBackground,
BackgroundValue, ResolvedBackground,
};
use super::style::{CardAlign, CardDirection, CardJustify};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VariableDefinition {
#[serde(rename = "type")]
pub var_type: VariableType,
pub default: serde_json::Value,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum VariableType {
String,
Number,
Boolean,
Object,
Array,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Scenario {
#[serde(default = "default_version")]
pub version: String,
pub video: VideoConfig,
#[serde(default)]
pub audio: Vec<AudioTrack>,
#[serde(default)]
pub fonts: Vec<FontEntry>,
#[serde(default, deserialize_with = "deserialize_scene_entries")]
pub scenes: Vec<SceneEntry>,
#[serde(default)]
pub composition: Option<Vec<View>>,
#[serde(default)]
pub config: Option<HashMap<String, VariableDefinition>>,
#[serde(default)]
pub backgrounds: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<Annotation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum AnnotationStatus {
#[default]
Open,
Resolved,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AnnotationTarget {
pub pointer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rect: Option<[f32; 4]>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Annotation {
pub id: String,
pub note: String,
#[serde(default)]
pub status: AnnotationStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scene: Option<usize>,
pub target: AnnotationTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ViewType {
Slide,
World,
}
fn default_view_type() -> ViewType {
ViewType::Slide
}
fn default_camera_pan_duration() -> f64 {
0.8
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct View {
#[serde(rename = "type", default = "default_view_type")]
pub view_type: ViewType,
#[serde(default, deserialize_with = "deserialize_scene_entries")]
pub scenes: Vec<SceneEntry>,
#[serde(default)]
pub transition: Option<Transition>,
#[serde(default, deserialize_with = "deserialize_background_value")]
pub background: Option<BackgroundValue>,
#[serde(
default,
rename = "animated-background",
deserialize_with = "deserialize_animated_backgrounds"
)]
pub animated_background: Vec<AnimatedBackground>,
#[serde(default = "default_transition_easing")]
pub camera_easing: EasingType,
#[serde(default = "default_camera_pan_duration")]
pub camera_pan_duration: f64,
}
#[derive(Debug)]
pub struct ResolvedScenario {
pub video: VideoConfig,
pub audio: Vec<AudioTrack>,
pub fonts: Vec<FontEntry>,
pub views: Vec<ResolvedView>,
pub included_paths: Vec<std::path::PathBuf>,
}
impl ResolvedScenario {
pub fn all_scenes(&self) -> impl Iterator<Item = &Scene> {
self.views.iter().flat_map(|v| v.scenes.iter())
}
#[allow(dead_code)]
pub fn all_scenes_vec(&self) -> Vec<&Scene> {
self.all_scenes().collect()
}
}
#[derive(Debug)]
pub struct ResolvedView {
pub view_type: ViewType,
pub scenes: Vec<Scene>,
pub transition: Option<Transition>,
pub background: ResolvedBackground,
pub camera_easing: EasingType,
pub camera_pan_duration: f64,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)] pub enum SceneEntry {
Scene(Scene),
Include(IncludeDirective),
}
fn deserialize_scene_entries<'de, D>(deserializer: D) -> Result<Vec<SceneEntry>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
let mut out = Vec::with_capacity(raw.len());
for (i, entry) in raw.into_iter().enumerate() {
let is_include = entry.get("include").is_some();
if is_include {
let directive: IncludeDirective = serde_json::from_value(entry)
.map_err(|e| D::Error::custom(format!("scenes[{i}] (include directive): {e}")))?;
out.push(SceneEntry::Include(directive));
} else {
let scene: Scene = serde_json::from_value(entry)
.map_err(|e| D::Error::custom(format!("scenes[{i}]: {e}")))?;
out.push(SceneEntry::Scene(scene));
}
}
Ok(out)
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IncludeDirective {
pub include: String,
#[serde(default)]
pub scenes: Option<Vec<usize>>,
#[serde(default)]
pub config: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FontEntry {
#[serde(default)]
pub path: Option<String>,
pub family: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub weights: Option<Vec<u16>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AudioTrack {
pub src: String,
#[serde(default)]
pub start: f64,
#[serde(default)]
pub end: Option<f64>,
#[serde(default = "default_volume")]
pub volume: f32,
#[serde(default)]
pub fade_in: Option<f64>,
#[serde(default)]
pub fade_out: Option<f64>,
#[serde(default)]
pub volume_keyframes: Vec<VolumeKeyframe>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VolumeKeyframe {
pub time: f64,
pub volume: f32,
#[serde(default)]
pub easing: EasingType,
}
fn default_volume() -> f32 {
1.0
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VideoConfig {
pub width: u32,
pub height: u32,
#[serde(default = "default_fps")]
pub fps: u32,
#[serde(default = "default_background")]
pub background: String,
#[serde(default)]
pub codec: Option<VideoCodec>,
#[serde(default)]
pub crf: Option<u8>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct WorldPosition {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Scene {
pub duration: f64,
#[serde(default, deserialize_with = "deserialize_background_value")]
pub background: Option<BackgroundValue>,
#[serde(default)]
pub children: Vec<serde_json::Value>,
#[serde(default)]
pub transition: Option<Transition>,
#[serde(default)]
pub freeze_at: Option<f64>,
#[serde(default)]
pub layout: Option<SceneLayout>,
#[serde(
default,
rename = "animated-background",
deserialize_with = "deserialize_animated_backgrounds"
)]
pub animated_background: Vec<AnimatedBackground>,
#[serde(default)]
pub camera: Option<Camera>,
#[serde(default, rename = "world-position")]
pub world_position: Option<WorldPosition>,
#[serde(default)]
pub persist: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<PostEffect>,
#[serde(skip)]
#[schemars(skip)]
pub resolved_background: ResolvedBackground,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Camera {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
#[serde(default = "default_camera_zoom")]
pub zoom: f32,
#[serde(default)]
pub rotation: f32,
#[serde(default)]
pub origin: Option<CameraOrigin>,
#[serde(default)]
pub keyframes: Vec<CameraKeyframe>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraOrigin {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
}
const KNOWN_CAMERA_PROPERTIES: &[&str] = &["x", "y", "zoom", "rotation", "origin.x", "origin.y"];
fn validate_camera_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
if KNOWN_CAMERA_PROPERTIES.contains(&value) {
return Ok(());
}
let normalize = |s: &str| s.replace(['-', '_', ' '], ".").to_lowercase();
let normalized = normalize(value);
if let Some(suggestion) = KNOWN_CAMERA_PROPERTIES
.iter()
.find(|known| normalize(known) == normalized)
{
Err(E::custom(format!(
"unknown camera keyframe property '{value}' — did you mean '{suggestion}'?"
)))
} else {
Err(E::custom(format!(
"unknown camera keyframe property '{value}': expected one of {}",
KNOWN_CAMERA_PROPERTIES.join(", ")
)))
}
}
fn deserialize_camera_property<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
validate_camera_property::<D::Error>(&s)?;
Ok(s)
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraKeyframe {
#[serde(deserialize_with = "deserialize_camera_property")]
pub property: String,
pub values: Vec<CameraKeyframePoint>,
#[serde(default)]
pub easing: EasingType,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraKeyframePoint {
pub time: f64,
pub value: f32,
}
fn default_camera_zoom() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum BlurDirection {
Top,
#[default]
Bottom,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PostEffect {
Grain {
#[serde(default = "default_grain_intensity")]
intensity: f32,
#[serde(default = "default_grain_seed")]
seed: u64,
#[serde(default = "default_true")]
animated: bool,
},
Vignette {
#[serde(default = "default_vignette_intensity")]
intensity: f32,
#[serde(default = "default_vignette_radius")]
radius: f32,
},
Pixelate {
#[serde(default = "default_pixelate_size")]
size: u32,
},
ProgressiveBlur {
#[serde(default)]
direction: BlurDirection,
#[serde(default = "default_blur_start")]
start: f32,
#[serde(default = "default_blur_max_radius")]
max_radius: f32,
},
}
fn default_grain_intensity() -> f32 {
0.15
}
fn default_grain_seed() -> u64 {
42
}
fn default_true() -> bool {
true
}
fn default_vignette_intensity() -> f32 {
0.5
}
fn default_vignette_radius() -> f32 {
0.75
}
fn default_pixelate_size() -> u32 {
8
}
fn default_blur_start() -> f32 {
0.5
}
fn default_blur_max_radius() -> f32 {
12.0
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SceneLayout {
#[serde(default)]
pub direction: Option<CardDirection>,
#[serde(default)]
pub gap: Option<f32>,
#[serde(default)]
pub align_items: Option<CardAlign>,
#[serde(default)]
pub justify_content: Option<CardJustify>,
#[serde(default)]
pub padding: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PixelDissolveOrder {
#[default]
EdgesIn,
CenterOut,
Random,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionCorner {
#[default]
TopRight,
TopLeft,
BottomRight,
BottomLeft,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionDirection {
#[default]
Left,
Right,
Up,
Down,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Transition {
#[serde(rename = "type")]
pub transition_type: TransitionType,
#[serde(default)]
pub corner: TransitionCorner,
#[serde(default = "default_transition_cell")]
pub cell: f32,
#[serde(default = "default_transition_seed")]
pub seed: u32,
#[serde(default)]
pub order: PixelDissolveOrder,
#[serde(default)]
pub direction: TransitionDirection,
#[serde(default = "default_transition_aberration")]
pub aberration: f32,
#[serde(default = "default_transition_duration")]
pub duration: f64,
#[serde(default = "default_transition_easing")]
pub easing: EasingType,
#[serde(default)]
pub background: PanBackground,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PanBackground {
#[default]
Static,
Travel,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionType {
Fade,
WipeLeft,
WipeRight,
WipeUp,
WipeDown,
ZoomIn,
ZoomOut,
Flip,
ClockWipe,
Iris,
Slide,
Dissolve,
CornerReveal,
PixelDissolve,
CameraPan,
ChromaticWipe,
None,
}
fn default_transition_cell() -> f32 {
48.0
}
fn default_transition_seed() -> u32 {
11
}
fn default_transition_aberration() -> f32 {
1.0
}
fn default_transition_duration() -> f64 {
0.5
}
pub(crate) fn default_transition_easing() -> EasingType {
EasingType::EaseInOut
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum VideoCodec {
#[default]
H264,
H265,
Vp9,
Prores,
}
fn default_version() -> String {
"1.0".to_string()
}
fn default_fps() -> u32 {
30
}
fn default_background() -> String {
"#000000".to_string()
}
#[cfg(test)]
mod annotation_tests {
use super::*;
const MINIMAL: &str = r#"{ "video": { "width": 1920, "height": 1080 }, "scenes": [] }"#;
const WITH_ANNOTATIONS: &str = r#"{
"video": { "width": 1920, "height": 1080 },
"scenes": [],
"annotations": [
{
"id": "an_4f2a",
"note": "reduce font-size",
"status": "open",
"frame": 142,
"view": 0,
"scene": 2,
"target": {
"pointer": "/scenes/2/children/5",
"kind": "text",
"rect": [10.0, 20.0, 30.0, 40.0]
}
}
]
}"#;
#[test]
fn scenario_without_annotations_defaults_empty() {
let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
assert!(s.annotations.is_empty());
}
#[test]
fn scenario_with_annotations_deserializes() {
let s: Scenario = serde_json::from_str(WITH_ANNOTATIONS).unwrap();
assert_eq!(s.annotations.len(), 1);
let a = &s.annotations[0];
assert_eq!(a.id, "an_4f2a");
assert_eq!(a.status, AnnotationStatus::Open);
assert_eq!(a.frame, Some(142));
assert_eq!(a.view, Some(0));
assert_eq!(a.scene, Some(2));
assert_eq!(a.target.pointer, "/scenes/2/children/5");
assert_eq!(a.target.kind.as_deref(), Some("text"));
assert_eq!(a.target.rect, Some([10.0, 20.0, 30.0, 40.0]));
}
#[test]
fn empty_annotations_are_not_serialized() {
let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
let json = serde_json::to_string(&s).unwrap();
assert!(
!json.contains("annotations"),
"empty annotations must be skipped, got: {json}"
);
}
#[test]
fn status_defaults_to_open_and_target_fields_optional() {
let json = r#"{
"video": { "width": 1, "height": 1 },
"scenes": [],
"annotations": [ { "id": "x", "note": "n", "target": { "pointer": "/scenes/0" } } ]
}"#;
let s: Scenario = serde_json::from_str(json).unwrap();
assert_eq!(s.annotations[0].status, AnnotationStatus::Open);
assert_eq!(s.annotations[0].target.kind, None);
assert_eq!(s.annotations[0].target.rect, None);
assert_eq!(s.annotations[0].frame, None);
}
}
#[cfg(test)]
mod scene_entry_error_tests {
use super::*;
#[test]
fn missing_duration_names_the_scene_index_and_field_not_the_untagged_message() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{ "duration": 1.0, "children": [] },
{ "children": [] }
]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("missing duration must fail");
let msg = err.to_string();
assert!(
!msg.contains("did not match any variant of untagged enum"),
"must not regress to the opaque untagged message: {msg}"
);
assert!(
msg.contains("scenes[1]"),
"must name the offending scene index: {msg}"
);
assert!(
msg.contains("duration"),
"must name the missing field: {msg}"
);
}
#[test]
fn misspelled_transition_type_names_itself() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{
"duration": 1.0,
"children": [],
"transition": { "type": "wip_left" }
}
]
}"#;
let err =
serde_json::from_str::<Scenario>(json).expect_err("bad transition type must fail");
let msg = err.to_string();
assert!(
!msg.contains("did not match any variant of untagged enum"),
"must not regress to the opaque untagged message: {msg}"
);
assert!(
msg.contains("scenes[0]"),
"must name the offending scene index: {msg}"
);
assert!(
msg.contains("wip_left"),
"must echo the bad value so the author can spot the typo: {msg}"
);
}
#[test]
fn include_directive_still_works() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{ "include": "does/not/matter.json" }
]
}"#;
let s: Scenario = serde_json::from_str(json).expect("include entry must parse");
assert!(matches!(s.scenes[0], SceneEntry::Include(_)));
}
#[test]
fn broken_include_directive_names_itself() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{ "include": "x.json", "scenes": "not-an-array" }
]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
let msg = err.to_string();
assert!(msg.contains("scenes[0]"), "got: {msg}");
assert!(msg.contains("include directive"), "got: {msg}");
}
#[test]
fn view_scenes_field_uses_the_same_precise_errors() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"composition": [
{ "type": "slide", "scenes": [ { "children": [] } ] }
]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
let msg = err.to_string();
assert!(
!msg.contains("did not match any variant of untagged enum"),
"got: {msg}"
);
assert!(msg.contains("scenes[0]"), "got: {msg}");
assert!(msg.contains("duration"), "got: {msg}");
}
}
#[cfg(test)]
mod strict_schema_tests {
use super::*;
#[test]
fn misspelled_scene_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ { "durration": 3.0, "children": [] } ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("durration"), "got: {err}");
}
#[test]
fn misspelled_video_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100, "framerate": 30 },
"scenes": [ { "duration": 1.0, "children": [] } ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("framerate"), "got: {err}");
}
#[test]
fn misspelled_top_level_scenario_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ { "duration": 1.0, "children": [] } ],
"titel": "typo"
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("titel"), "got: {err}");
}
#[test]
fn misspelled_camera_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ {
"duration": 1.0,
"children": [],
"camera": { "zooom": 1.5 }
} ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("zooom"), "got: {err}");
}
#[test]
fn misspelled_scene_layout_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ {
"duration": 1.0,
"children": [],
"layout": { "gapp": 10 }
} ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("gapp"), "got: {err}");
}
#[test]
fn misspelled_view_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"composition": [ { "typ": "slide", "scenes": [] } ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("typ"), "got: {err}");
}
#[test]
fn valid_scenario_with_every_covered_struct_still_parses() {
let json = r##"{
"version": "1.0",
"video": { "width": 100, "height": 100, "fps": 30, "background": "#000000" },
"scenes": [ {
"duration": 1.0,
"children": [],
"layout": { "direction": "column", "gap": 10, "align_items": "center", "justify_content": "center", "padding": 5 },
"transition": { "type": "fade", "duration": 0.5, "easing": "ease_in_out" },
"camera": { "x": 0, "y": 0, "zoom": 1.0, "rotation": 0, "origin": { "x": 1, "y": 2 }, "keyframes": [ { "property": "zoom", "values": [ { "time": 0.0, "value": 1.0 } ], "easing": "linear" } ] }
} ]
}"##;
let s: Scenario = serde_json::from_str(json).expect("valid scenario must still parse");
assert_eq!(s.scenes.len(), 1);
}
}
#[cfg(test)]
mod camera_keyframe_property_tests {
use super::*;
#[test]
fn known_camera_properties_still_work() {
for prop in ["x", "y", "zoom", "rotation", "origin.x", "origin.y"] {
let json = format!(
r#"{{ "property": "{prop}", "values": [ {{ "time": 0.0, "value": 1.0 }} ] }}"#
);
let kf: CameraKeyframe = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("property '{prop}' must be accepted, got: {e}"));
assert_eq!(kf.property, prop);
}
}
#[test]
fn unknown_camera_property_is_a_named_error_not_a_silent_no_op() {
let json = r#"{ "property": "tilt", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
let err = serde_json::from_str::<CameraKeyframe>(json).expect_err(
"an unrecognised camera keyframe property must be rejected, not silently inert",
);
assert!(err.to_string().contains("tilt"), "got: {err}");
}
#[test]
fn misspelled_origin_property_is_a_named_error() {
let json = r#"{ "property": "origin_x", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
let err = serde_json::from_str::<CameraKeyframe>(json)
.expect_err("origin_x must be rejected — the real property is origin.x");
let msg = err.to_string();
assert!(msg.contains("origin_x"), "got: {msg}");
assert!(
msg.contains("origin.x"),
"expected a did-you-mean nudge toward origin.x, got: {msg}"
);
}
}