diff --git a/.claude/dev-methodology.local.md b/.claude/dev-methodology.local.md deleted file mode 100644 index 504753a..0000000 --- a/.claude/dev-methodology.local.md +++ /dev/null @@ -1,9 +0,0 @@ -# dev-methodology preferences (user answers — change only when the user does) -commit: claude-allowed -create-pr: claude-allowed -merge-pr: claude-allowed # granted 2026-07-18 ("tu as l'autorisation de squash and merge toi-même") -feature-merge: squash # feature → main -workstream-merge: squash -tracking: github-issues # parent chantier issue + one issue per workstream -model-strategy: split # orchestration/review: session model (Fable); implementation sub-agents: sonnet -attribution: none # never cite Claude as author/co-author in commits, PRs, or issues diff --git a/.gitignore b/.gitignore index 4af6aa8..7d8d7f8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /out .DS_Store docs/ +.claude/*.local.md diff --git a/Cargo.lock b/Cargo.lock index c8e1a7c..325403a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4920,6 +4920,7 @@ dependencies = [ "rustmotion-components", "rustmotion-core", "rustmotion-html", + "schemars", "serde", "serde_json", "similar", diff --git a/crates/rustmotion/Cargo.toml b/crates/rustmotion/Cargo.toml index 45498f0..ee44fdd 100644 --- a/crates/rustmotion/Cargo.toml +++ b/crates/rustmotion/Cargo.toml @@ -30,3 +30,6 @@ resvg = "0.44" usvg = "0.44" tiny-skia = "0.11" ureq = "3" + +[dev-dependencies] +schemars = "0.8" diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 3139c3e..d64053c 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -97,6 +97,38 @@ mod component_smoke { "positioned", r#"{"type":"positioned","children":[{"type":"text","content":"hi","x":0,"y":0}]}"#, ), + // Media & diagram components (minimal required fields) + ("image", r#"{"type":"image","src":"a.png"}"#), + ("video", r#"{"type":"video","src":"a.mp4"}"#), + ("gif", r#"{"type":"gif","src":"a.gif"}"#), + ( + "caption", + r#"{"type":"caption","words":[{"text":"hi","start":0.0,"end":1.0}]}"#, + ), + ( + "connector", + r#"{"type":"connector","from":{"x":0,"y":0},"to":{"x":10,"y":10}}"#, + ), + ("avatar", r#"{"type":"avatar","src":"a.png"}"#), + ( + "avatar_group", + r#"{"type":"avatar_group","avatars":[{"src":"a.png"}]}"#, + ), + ("arrow", r#"{"type":"arrow","x2":10,"y2":10}"#), + ("comparison", r#"{"type":"comparison"}"#), + ( + "dot_map", + r#"{"type":"dot_map","points":[{"lat":48.8,"lng":2.3}]}"#, + ), + ("line", r#"{"type":"line","x2":10,"y2":10}"#), + ("lottie", r#"{"type":"lottie"}"#), + ( + "mockup", + r#"{"type":"mockup","device":"browser","src":"a.png"}"#, + ), + ("notification", r#"{"type":"notification","title":"Hi"}"#), + ("pill_nav", r#"{"type":"pill_nav","items":["A","B"]}"#), + ("tooltip", r#"{"type":"tooltip","text":"hi"}"#), ]; #[test] @@ -117,6 +149,116 @@ mod component_smoke { } } + /// Input-only `type` aliases and the canonical tag they must serialize to. + const COMPONENT_ALIASES: &[(&str, &str)] = + &[("container", "div"), ("progress_bar", "progress")]; + + #[test] + fn all_components_serde_round_trip() { + // deserialize → serialize → deserialize → serialize: the canonical + // form must itself deserialize and re-serialize identically. Locks + // tags, aliases and field names against silent schema drift (the + // `container` alias was once lost by a bare rename to `div`). + let mut failures = Vec::new(); + for (name, json) in COMPONENT_JSONS { + let parsed: Component = match serde_json::from_str(json) { + Ok(c) => c, + Err(e) => { + failures.push(format!("{name}: does not deserialize: {e}")); + continue; + } + }; + let canonical = match serde_json::to_value(&parsed) { + Ok(v) => v, + Err(e) => { + failures.push(format!("{name}: does not serialize: {e}")); + continue; + } + }; + match serde_json::from_value::(canonical.clone()) { + Ok(reparsed) => { + let again = serde_json::to_value(&reparsed).unwrap(); + if canonical != again { + failures.push(format!("{name}: unstable serialization")); + } + } + Err(e) => { + failures.push(format!("{name}: canonical form does not deserialize: {e}")); + } + } + } + if !failures.is_empty() { + panic!( + "{} component(s) fail serde round-trip:\n{}", + failures.len(), + failures.join("\n") + ); + } + } + + #[test] + fn component_aliases_map_to_canonical_tags() { + for (alias, canonical) in COMPONENT_ALIASES { + let (_, json) = COMPONENT_JSONS + .iter() + .find(|(n, _)| n == canonical || n == alias) + .unwrap_or_else(|| panic!("no corpus entry for {canonical}")); + let mut value: serde_json::Value = serde_json::from_str(json).unwrap(); + value["type"] = serde_json::Value::String(alias.to_string()); + let parsed: Component = serde_json::from_value(value) + .unwrap_or_else(|e| panic!("alias {alias} does not deserialize: {e}")); + let tag = serde_json::to_value(&parsed).unwrap()["type"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + &tag, canonical, + "alias {alias} must serialize to canonical tag {canonical}" + ); + } + } + + #[test] + fn corpus_covers_every_component_tag() { + // Every `type` tag advertised by the generated JSON schema must have + // a corpus entry, so the deserialize/round-trip tests above cannot + // silently lose coverage when a component is added. + let schema = serde_json::to_value(schemars::schema_for!(Component)).unwrap(); + let one_of = schema["oneOf"] + .as_array() + .expect("Component schema should be a oneOf over tagged variants"); + let schema_tags: Vec = one_of + .iter() + .filter_map(|v| v["properties"]["type"]["enum"][0].as_str()) + .map(str::to_string) + .collect(); + assert!( + !schema_tags.is_empty(), + "no tags extracted from schema — schemars layout changed?" + ); + + let covered: std::collections::HashSet = COMPONENT_JSONS + .iter() + .map(|(name, json)| { + let parsed: Component = + serde_json::from_str(json).unwrap_or_else(|e| panic!("{name}: {e}")); + serde_json::to_value(&parsed).unwrap()["type"] + .as_str() + .unwrap() + .to_string() + }) + .collect(); + + let missing: Vec<&String> = schema_tags + .iter() + .filter(|t| !covered.contains(*t)) + .collect(); + assert!( + missing.is_empty(), + "component tags with no minimal-JSON corpus entry: {missing:?}" + ); + } + #[test] fn all_components_paint_through_new_pipeline() { // Build a tiny scene per component (one absolutely-positioned child)