Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions crates/rustmotion-cli/src/commands/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1310,8 +1310,22 @@ pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec<GeometryVi

let path_root = format!("views[{}].scenes[{}]", vi, si);
let scene_duration = scene.duration;

for time in anim_sample_times(scene_duration) {
// A frozen scene renders nothing past `freeze_at` — every path
// now funnels through `SceneTime`, which clamps there (#164). So
// sampling beyond it evaluates transforms at instants the video
// never contains, which is how `--strict-anim` reports a
// violation that cannot happen. Bounding the sample list rather
// than clamping each `time` afterwards also avoids generating a
// run of identical post-freeze samples.
//
// `scene_duration` itself stays untouched below: duration-relative
// effects (contract from PR #27) must keep their real window —
// only the sampling ceiling moves.
let sample_until = scene
.freeze_at
.map_or(scene_duration, |f| f.clamp(0.0, scene_duration));

for time in anim_sample_times(sample_until) {
let root_css = render::root_style(scene.layout.as_ref(), view.view_type.clone());
let anim = Some(BuildAnimationCtx {
time,
Expand Down Expand Up @@ -2600,6 +2614,48 @@ mod tests {
// any other transform `apply_animated_props` bakes into `css.transform`),
// not just translate_x/y and scale_x/y ─────────────────────────────────

/// Every render path now clamps at `scene.freeze_at` (#164, `SceneTime`),
/// so nothing past it is ever rendered. Sampling beyond it therefore
/// reports a violation the video cannot contain — a false positive that
/// blocks a correct scenario and sends a generator "fixing" something
/// that was never wrong.
///
/// Same fixture as the spin test below, frozen at 0.05s: the square only
/// leaves the frame once the rotation has turned far enough, well after
/// the freeze.
#[test]
fn strict_anim_does_not_sample_past_freeze_at() {
let json = r##"{
"video": { "width": 1920, "height": 1080 },
"scenes": [{
"duration": 2.0,
"freeze_at": 0.05,
"children": [{
"type": "shape",
"shape": "rect",
"position": "absolute",
"x": 1810, "y": 490,
"style": {
"width": "100px", "height": "100px",
"animation": [{ "name": "spin", "delay": 0, "duration": 2.0 }]
},
"fill": "#ff0000"
}]
}]
}"##;
let violations = validate_geometry_animated(&parse(json));
assert!(
violations
.iter()
.all(|v| v.kind != ViolationKind::AnimatedTextOverflow),
"the frame that would overflow is never rendered — freeze_at is at \
0.05s: {violations:?}"
);
}

/// The mirror: without the freeze, the very same fixture must still be
/// caught. Otherwise the bound above would be silencing real overflow
/// rather than removing an unreachable sample.
#[test]
fn strict_anim_detects_a_spin_animation_pushing_a_square_off_screen() {
// Same headline numbers as the static-transform regression
Expand Down
97 changes: 91 additions & 6 deletions crates/rustmotion-cli/src/commands/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,40 @@ fn apply_fixes(root: &mut serde_json::Value, violations: &[GeometryViolation]) -
applied += 1;
}
}
ViolationKind::ContentOverflowsBox => {
// Growing the box, shrinking the font and shortening the copy
// are all legitimate answers with very different visual
// outcomes, so this arm used to do nothing rather than pick
// one. `style.text-autofit` removed that dilemma for the two
// components whose painters implement it: it declares the
// author's intent ("this must fit") without touching the
// declared box or the content, so nothing the author wrote is
// overwritten or lost — the same risk category as the two
// fixes above, both of which also change the render.
//
// Scoped to `text`/`gradient_text` deliberately. Every other
// component ignores the field, so writing it there would be a
// no-op the author could reasonably read as a fix, which is
// worse than leaving the violation to them.
let kind = target.get("type").and_then(|t| t.as_str());
if matches!(kind, Some("text") | Some("gradient_text")) {
if let Some(style) = target
.as_object_mut()
.and_then(|o| o.get_mut("style"))
.and_then(|s| s.as_object_mut())
{
if !style.contains_key("text-autofit") {
style.insert("text-autofit".into(), serde_json::Value::Bool(true));
applied += 1;
}
}
}
}
ViolationKind::ViewportOverflow
| ViolationKind::AnimatedTextOverflow
| ViolationKind::ContentOverflowsBox
| ViolationKind::ContentOverflowsCard => {
// Position/size clamping is too risky to auto-fix without
// losing intent — leave it for the user. (ContentOverflowsBox
// /ContentOverflowsCard specifically: growing the box/card,
// shrinking the font, or shortening the copy are all
// legitimate fixes with very different visual outcomes — not
// ours to pick.)
// losing intent — leave it for the user.
}
}
}
Expand Down Expand Up @@ -327,6 +351,67 @@ mod tests {
}
}

fn overflow_box_violation(path: &str) -> GeometryViolation {
GeometryViolation {
kind: ViolationKind::ContentOverflowsBox,
..unwrappable_violation(path)
}
}

/// `ContentOverflowsBox` had no fix because growing the box, shrinking
/// the font and shortening the copy are all legitimate and pick
/// different outcomes. `text-autofit` states the intent instead, without
/// overwriting anything the author declared.
#[test]
fn fix_declares_text_autofit_on_an_overflowing_text() {
let mut json: serde_json::Value = serde_json::from_str(NARROW_CARD_JSON).unwrap();
let path = "views[0].scenes[0].children[0].children[0]";
let applied = apply_fixes(&mut json, &[overflow_box_violation(path)]);
assert_eq!(applied, 1, "expected exactly one fix applied");

let target = navigate(&mut json, path).expect("path resolves");
assert_eq!(
target.get("style").and_then(|s| s.get("text-autofit")),
Some(&serde_json::Value::Bool(true))
);
// The declared box and the content are what the author wrote; a fix
// that rewrote either would be picking one of the outcomes this arm
// exists to avoid picking.
assert!(
target.get("content").is_some(),
"content must be untouched: {target}"
);
}

/// Every component other than `text`/`gradient_text` ignores the field.
/// Writing it there would look like a fix while changing nothing, which
/// is worse than leaving the violation visible.
#[test]
fn fix_leaves_overflowing_components_that_cannot_autofit_alone() {
let json_src = r##"{
"video": { "width": 1920, "height": 1080 },
"scenes": [{ "duration": 1.0, "children": [
{ "type": "table", "headers": ["a"], "rows": [["b"]],
"style": { "width": "40px", "font-size": 40 } }
]}]
}"##;
let mut json: serde_json::Value = serde_json::from_str(json_src).unwrap();
let path = "views[0].scenes[0].children[0]";
assert_eq!(
apply_fixes(&mut json, &[overflow_box_violation(path)]),
0,
"a table cannot autofit, so nothing should be claimed as fixed"
);
let target = navigate(&mut json, path).expect("path resolves");
assert!(
target
.get("style")
.and_then(|s| s.get("text-autofit"))
.is_none(),
"must not write a field this painter ignores: {target}"
);
}

/// C1: `apply_fixes` must never write `style.wrap` (not a `CssStyle`
/// field — writing it drops the whole component at the next parse
/// because `CssStyle` is `deny_unknown_fields`). It must instead remove
Expand Down
Loading