use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::animation::EasingType;
use super::scenario::default_transition_easing;
use super::video::GradientType;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScrollDirection {
Up,
Down,
Left,
Right,
UpLeft,
UpRight,
DownLeft,
DownRight,
Cw,
Ccw,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GradientShiftConfig {
pub colors: Vec<String>,
#[serde(default = "default_bg_type")]
pub gradient_type: GradientType,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GridDotsConfig {
#[serde(default = "default_grid_dots_color")]
pub color: String,
#[serde(default = "default_bg_element_size")]
pub element_size: f32,
#[serde(default = "default_bg_spacing")]
pub spacing: f32,
}
fn default_grid_dots_color() -> String {
"#FFFFFF15".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GridLinesConfig {
#[serde(default = "default_grid_lines_color")]
pub color: String,
#[serde(default = "default_grid_lines_cell")]
pub cell: f32,
#[serde(default = "default_grid_lines_weight")]
pub weight: f32,
#[serde(default)]
pub major_every: u32,
#[serde(default = "default_grid_lines_major_weight")]
pub major_weight: f32,
}
fn default_grid_lines_color() -> String {
"#FFFFFF14".to_string()
}
fn default_grid_lines_cell() -> f32 {
72.0
}
fn default_grid_lines_weight() -> f32 {
1.0
}
fn default_grid_lines_major_weight() -> f32 {
2.0
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ConcentricCirclesConfig {
#[serde(default = "default_concentric_color")]
pub color: String,
#[serde(default = "default_bg_element_size")]
pub element_size: f32,
#[serde(default = "default_bg_spacing")]
pub spacing: f32,
#[serde(default)]
pub count: Option<u32>,
}
fn default_concentric_color() -> String {
"#FFFFFF20".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HaloConfig {
pub zones: Vec<HaloZone>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PixelGridConfig {
#[serde(default = "default_pixel_colors")]
pub colors: Vec<String>,
#[serde(default = "default_pixel_size")]
pub size: f32,
#[serde(default = "default_pixel_spacing")]
pub spacing: f32,
#[serde(default = "default_pixel_density")]
pub density: f32,
#[serde(default)]
pub density_ramp: PixelDensityRamp,
#[serde(default)]
pub radius: f32,
#[serde(default = "default_pixel_seed")]
pub seed: u32,
#[serde(default)]
pub motion: PixelGridMotion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PixelDensityRamp {
#[default]
None,
Left,
Right,
Top,
Bottom,
Radial,
Edges,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PixelGridMotion {
#[default]
None,
Twinkle,
Sweep,
}
fn default_pixel_colors() -> Vec<String> {
vec!["#FFFFFF22".to_string()]
}
fn default_pixel_size() -> f32 {
10.0
}
fn default_pixel_spacing() -> f32 {
24.0
}
fn default_pixel_density() -> f32 {
0.6
}
fn default_pixel_seed() -> u32 {
7
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HeropatternConfig {
pub pattern: String,
#[serde(default = "default_hero_color")]
pub color: String,
#[serde(default = "default_hero_opacity")]
pub opacity: f32,
#[serde(default = "default_hero_scale")]
pub scale: f32,
}
fn default_hero_color() -> String {
"#FFFFFF".to_string()
}
fn default_hero_opacity() -> f32 {
0.1
}
fn default_hero_scale() -> f32 {
1.0
}
#[derive(Debug, Clone)]
pub enum BackgroundPreset {
GradientShift(GradientShiftConfig),
GridDots(GridDotsConfig),
GridLines(GridLinesConfig),
ConcentricCircles(ConcentricCirclesConfig),
Halo(HaloConfig),
PixelGrid(PixelGridConfig),
Heropattern(HeropatternConfig),
}
impl BackgroundPreset {
pub fn name(&self) -> &'static str {
match self {
BackgroundPreset::GradientShift(_) => "gradient_shift",
BackgroundPreset::GridDots(_) => "grid_dots",
BackgroundPreset::GridLines(_) => "grid_lines",
BackgroundPreset::ConcentricCircles(_) => "concentric_circles",
BackgroundPreset::Halo(_) => "halo",
BackgroundPreset::PixelGrid(_) => "pixel_grid",
BackgroundPreset::Heropattern(_) => "heropattern",
}
}
}
#[derive(Debug, Clone)]
pub struct AnimatedBackground {
pub preset: BackgroundPreset,
pub x: f32,
pub y: f32,
pub speed: f32,
pub direction: Option<ScrollDirection>,
}
impl Serialize for AnimatedBackground {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("preset", self.preset.name())?;
match &self.preset {
BackgroundPreset::GradientShift(cfg) => map.serialize_entry("gradient_shift", cfg)?,
BackgroundPreset::GridDots(cfg) => map.serialize_entry("grid_dots", cfg)?,
BackgroundPreset::GridLines(cfg) => map.serialize_entry("grid_lines", cfg)?,
BackgroundPreset::ConcentricCircles(cfg) => {
map.serialize_entry("concentric_circles", cfg)?
}
BackgroundPreset::Halo(cfg) => map.serialize_entry("halo", cfg)?,
BackgroundPreset::PixelGrid(cfg) => map.serialize_entry("pixel_grid", cfg)?,
BackgroundPreset::Heropattern(cfg) => map.serialize_entry("heropattern", cfg)?,
}
map.serialize_entry("speed", &self.speed)?;
if self.x != 0.0 {
map.serialize_entry("x", &self.x)?;
}
if self.y != 0.0 {
map.serialize_entry("y", &self.y)?;
}
if let Some(ref dir) = self.direction {
map.serialize_entry("direction", dir)?;
}
map.end()
}
}
const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
"gradient_shift",
"grid_dots",
"grid_lines",
"concentric_circles",
"halo",
"pixel_grid",
"heropattern",
];
impl<'de> Deserialize<'de> for AnimatedBackground {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let map: serde_json::Map<String, serde_json::Value> =
serde_json::Map::deserialize(deserializer)?;
let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let direction: Option<ScrollDirection> = match map.get("direction") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| {
serde::de::Error::custom(format!("animated-background.direction: {e}"))
})?),
None => None,
};
let preset_str = map.get("preset").and_then(|v| v.as_str()).unwrap_or("");
if !KNOWN_BACKGROUND_PRESETS.contains(&preset_str) {
return Err(serde::de::Error::custom(format!(
"unknown animated-background preset '{preset_str}': expected one of {}",
KNOWN_BACKGROUND_PRESETS.join(", ")
)));
}
let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object());
let (preset, speed) = if is_new_format {
let sub = map.get(preset_str).unwrap().clone();
let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let preset = deserialize_preset_config::<D::Error>(preset_str, sub)?;
(preset, speed)
} else {
let legacy_speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(30.0) as f32;
let preset = match preset_str {
"grid_dots" => {
let color = map
.get("colors")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.unwrap_or("#FFFFFF15")
.to_string();
let element_size = map
.get("element_size")
.and_then(|v| v.as_f64())
.unwrap_or(4.0) as f32;
let spacing =
map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
BackgroundPreset::GridDots(GridDotsConfig {
color,
element_size,
spacing,
})
}
"concentric_circles" => {
let color = map
.get("colors")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.unwrap_or("#FFFFFF20")
.to_string();
let element_size = map
.get("element_size")
.and_then(|v| v.as_f64())
.unwrap_or(4.0) as f32;
let spacing =
map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
let count = map.get("count").and_then(|v| v.as_u64()).map(|n| n as u32);
BackgroundPreset::ConcentricCircles(ConcentricCirclesConfig {
color,
element_size,
spacing,
count,
})
}
"halo" => {
let mut obj = serde_json::Map::new();
if let Some(z) = map.get("zones") {
obj.insert("zones".to_string(), z.clone());
}
let cfg: HaloConfig = serde_json::from_value(serde_json::Value::Object(obj))
.map_err(|e| {
serde::de::Error::custom(format!("animated-background.zones: {e}"))
})?;
BackgroundPreset::Halo(cfg)
}
"heropattern" => {
let mut obj = serde_json::Map::new();
for key in ["pattern", "color", "opacity", "scale"] {
if let Some(v) = map.get(key) {
obj.insert(key.to_string(), v.clone());
}
}
let cfg: HeropatternConfig =
serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
serde::de::Error::custom(format!(
"animated-background.heropattern: {e}"
))
})?;
BackgroundPreset::Heropattern(cfg)
}
"gradient_shift" => {
let mut obj = serde_json::Map::new();
if let Some(c) = map.get("colors") {
obj.insert("colors".to_string(), c.clone());
}
if let Some(g) = map.get("gradient_type") {
obj.insert("gradient_type".to_string(), g.clone());
}
let cfg: GradientShiftConfig =
serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
serde::de::Error::custom(format!(
"animated-background.colors/gradient_type: {e}"
))
})?;
BackgroundPreset::GradientShift(cfg)
}
other => {
return Err(serde::de::Error::custom(format!(
"internal error: unhandled animated-background preset '{other}'"
)))
}
};
(preset, legacy_speed)
};
let direction = direction.or({
if speed > 0.0 && !is_new_format {
match &preset {
BackgroundPreset::GradientShift(_) => Some(ScrollDirection::Cw),
BackgroundPreset::GridDots(_) => Some(ScrollDirection::Up),
_ => None,
}
} else {
None
}
});
Ok(AnimatedBackground {
preset,
x,
y,
speed,
direction,
})
}
}
fn deserialize_preset_config<E: serde::de::Error>(
preset_str: &str,
sub: serde_json::Value,
) -> Result<BackgroundPreset, E> {
match preset_str {
"grid_dots" => Ok(BackgroundPreset::GridDots(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"grid_lines" => Ok(BackgroundPreset::GridLines(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"concentric_circles" => Ok(BackgroundPreset::ConcentricCircles(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"halo" => Ok(BackgroundPreset::Halo(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"pixel_grid" => Ok(BackgroundPreset::PixelGrid(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"heropattern" => Ok(BackgroundPreset::Heropattern(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"gradient_shift" => Ok(BackgroundPreset::GradientShift(
serde_json::from_value(sub).map_err(E::custom)?,
)),
other => Err(E::custom(format!(
"internal error: unhandled animated-background preset '{other}'"
))),
}
}
impl JsonSchema for AnimatedBackground {
fn schema_name() -> String {
"AnimatedBackground".to_string()
}
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::*;
let mut props = schemars::Map::new();
props.insert("preset".to_string(), gen.subschema_for::<String>());
props.insert("x".to_string(), gen.subschema_for::<f32>());
props.insert("y".to_string(), gen.subschema_for::<f32>());
props.insert("speed".to_string(), gen.subschema_for::<f32>());
props.insert(
"direction".to_string(),
gen.subschema_for::<Option<ScrollDirection>>(),
);
props.insert(
"gradient_shift".to_string(),
gen.subschema_for::<Option<GradientShiftConfig>>(),
);
props.insert(
"grid_dots".to_string(),
gen.subschema_for::<Option<GridDotsConfig>>(),
);
props.insert(
"grid_lines".to_string(),
gen.subschema_for::<Option<GridLinesConfig>>(),
);
props.insert(
"concentric_circles".to_string(),
gen.subschema_for::<Option<ConcentricCirclesConfig>>(),
);
props.insert(
"halo".to_string(),
gen.subschema_for::<Option<HaloConfig>>(),
);
props.insert(
"heropattern".to_string(),
gen.subschema_for::<Option<HeropatternConfig>>(),
);
SchemaObject {
instance_type: Some(InstanceType::Object.into()),
object: Some(Box::new(ObjectValidation {
properties: props,
required: ["preset".to_string()].into_iter().collect(),
..Default::default()
})),
..Default::default()
}
.into()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HaloZone {
pub color: String,
#[serde(default = "default_half")]
pub x: f32,
#[serde(default = "default_half")]
pub y: f32,
#[serde(default = "default_halo_radius")]
pub radius: f32,
#[serde(default = "default_halo_opacity")]
pub opacity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BackgroundTransition {
pub duration: f64,
#[serde(default = "default_transition_easing")]
pub easing: EasingType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackgroundEntry {
#[serde(rename = "$ref", default)]
pub template_ref: Option<String>,
#[serde(default)]
pub transition: Option<BackgroundTransition>,
#[serde(flatten)]
pub overrides: serde_json::Map<String, serde_json::Value>,
}
impl JsonSchema for BackgroundEntry {
fn schema_name() -> String {
"BackgroundEntry".to_string()
}
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::*;
let mut props = schemars::Map::new();
props.insert("$ref".to_string(), gen.subschema_for::<Option<String>>());
props.insert(
"transition".to_string(),
gen.subschema_for::<Option<BackgroundTransition>>(),
);
SchemaObject {
instance_type: Some(InstanceType::Object.into()),
object: Some(Box::new(ObjectValidation {
properties: props,
additional_properties: Some(Box::new(Schema::Bool(true))),
..Default::default()
})),
..Default::default()
}
.into()
}
}
#[derive(Debug, Clone)]
pub enum BackgroundValue {
Color(String),
Single(BackgroundEntry),
Multiple(Vec<BackgroundEntry>),
}
impl Serialize for BackgroundValue {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
BackgroundValue::Color(s) => serializer.serialize_str(s),
BackgroundValue::Single(entry) => entry.serialize(serializer),
BackgroundValue::Multiple(entries) => entries.serialize(serializer),
}
}
}
impl JsonSchema for BackgroundValue {
fn schema_name() -> String {
"BackgroundValue".to_string()
}
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::*;
let string_schema = gen.subschema_for::<String>();
let entry_schema = gen.subschema_for::<BackgroundEntry>();
let array_schema: Schema = SchemaObject {
instance_type: Some(InstanceType::Array.into()),
array: Some(Box::new(ArrayValidation {
items: Some(SingleOrVec::Single(Box::new(entry_schema.clone()))),
..Default::default()
})),
..Default::default()
}
.into();
SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(vec![string_schema, entry_schema, array_schema]),
..Default::default()
})),
..Default::default()
}
.into()
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ResolvedBackground {
pub color: Option<String>,
pub animated: Vec<AnimatedBackground>,
pub transition: Option<BackgroundTransition>,
}
fn default_half() -> f32 {
0.5
}
fn default_halo_radius() -> f32 {
0.4
}
fn default_halo_opacity() -> f32 {
1.0
}
fn default_bg_element_size() -> f32 {
4.0
}
fn default_bg_spacing() -> f32 {
60.0
}
#[allow(dead_code)]
fn default_bg_speed() -> f32 {
30.0
}
fn default_bg_type() -> GradientType {
GradientType::Linear
}
pub(crate) fn deserialize_animated_backgrounds<'de, D>(
deserializer: D,
) -> Result<Vec<AnimatedBackground>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct OneOrMany;
impl<'de> de::Visitor<'de> for OneOrMany {
type Value = Vec<AnimatedBackground>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a single animated background or an array of animated backgrounds")
}
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
Vec::deserialize(de::value::SeqAccessDeserializer::new(seq))
}
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let bg = AnimatedBackground::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(vec![bg])
}
}
deserializer.deserialize_any(OneOrMany)
}
pub(crate) fn deserialize_background_value<'de, D>(
deserializer: D,
) -> Result<Option<BackgroundValue>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct BgVisitor;
impl<'de> de::Visitor<'de> for BgVisitor {
type Value = Option<BackgroundValue>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a color string, a background object, or an array of background objects")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Some(BackgroundValue::Color(v.to_string())))
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Some(BackgroundValue::Color(v)))
}
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let entry = BackgroundEntry::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(Some(BackgroundValue::Single(entry)))
}
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
let entries =
Vec::<BackgroundEntry>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
Ok(Some(BackgroundValue::Multiple(entries)))
}
}
deserializer.deserialize_any(BgVisitor)
}
#[cfg(test)]
mod halo_zone_opacity_tests {
use super::*;
#[test]
fn halo_zone_opacity_defaults_to_1_when_omitted() {
let zone: HaloZone =
serde_json::from_value(serde_json::json!({ "color": "#1E3A8A55" })).unwrap();
assert_eq!(zone.opacity, 1.0);
}
#[test]
fn halo_zone_opacity_respects_explicit_value() {
let zone: HaloZone = serde_json::from_value(serde_json::json!({
"color": "#1E3A8A",
"opacity": 0.35
}))
.unwrap();
assert_eq!(zone.opacity, 0.35);
}
#[test]
fn halo_zone_serializes_opacity() {
let zone = HaloZone {
color: "#1E3A8A".to_string(),
x: 0.5,
y: 0.5,
radius: 0.4,
opacity: 0.6,
};
let v = serde_json::to_value(&zone).unwrap();
let got = v["opacity"]
.as_f64()
.expect("opacity must serialize as a number");
assert!((got - 0.6).abs() < 1e-6, "got {got}");
}
#[test]
fn animated_background_new_format_halo_zone_defaults_opacity() {
let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
"preset": "halo",
"halo": { "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] },
"speed": 0
}))
.unwrap();
match bg.preset {
BackgroundPreset::Halo(cfg) => {
assert_eq!(cfg.zones.len(), 1);
assert_eq!(cfg.zones[0].opacity, 1.0);
assert_eq!(cfg.zones[0].color, "#1E3A8A55");
}
_ => panic!("expected Halo preset"),
}
}
#[test]
fn animated_background_legacy_flat_format_halo_zone_defaults_opacity() {
let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
"preset": "halo",
"zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }]
}))
.unwrap();
match bg.preset {
BackgroundPreset::Halo(cfg) => {
assert_eq!(cfg.zones[0].opacity, 1.0);
}
_ => panic!("expected Halo preset"),
}
}
}
#[cfg(test)]
mod animated_background_silent_sink_tests {
use super::*;
use serde_json::json;
#[test]
fn known_preset_gradient_shift_still_works() {
let bg: AnimatedBackground = serde_json::from_value(json!({
"preset": "gradient_shift",
"colors": ["#111111", "#222222"],
"gradient_type": "radial",
"speed": 10
}))
.unwrap();
match bg.preset {
BackgroundPreset::GradientShift(cfg) => {
assert_eq!(cfg.colors, vec!["#111111", "#222222"]);
assert!(matches!(cfg.gradient_type, GradientType::Radial));
}
other => panic!("expected GradientShift, got {other:?}"),
}
}
#[test]
fn unknown_preset_name_is_a_named_error_not_a_silent_black_gradient() {
let err = serde_json::from_value::<AnimatedBackground>(json!({
"preset": "starfield",
"speed": 10
}))
.expect_err("an unknown preset must be rejected, not silently treated as gradient_shift");
let msg = err.to_string();
assert!(
msg.contains("starfield"),
"error must name the offending preset value, got: {msg}"
);
}
#[test]
fn missing_preset_key_is_a_named_error() {
let err = serde_json::from_value::<AnimatedBackground>(json!({ "speed": 10 }))
.expect_err("a missing `preset` must be rejected, not silently treated as gradient_shift with colors: []");
assert!(
err.to_string().to_lowercase().contains("preset"),
"got: {err}"
);
}
#[test]
fn legacy_halo_zones_still_work() {
let bg: AnimatedBackground = serde_json::from_value(json!({
"preset": "halo",
"zones": [{ "color": "#1E3A8A", "x": 0.1, "y": 0.2, "radius": 0.3 }]
}))
.unwrap();
match bg.preset {
BackgroundPreset::Halo(cfg) => assert_eq!(cfg.zones.len(), 1),
other => panic!("expected Halo, got {other:?}"),
}
}
#[test]
fn legacy_halo_malformed_zones_is_a_named_error_not_a_silent_empty_zones() {
let err = serde_json::from_value::<AnimatedBackground>(json!({
"preset": "halo",
"zones": [{ "color": "#1E3A8A", "x": "not-a-number" }]
}))
.expect_err("a malformed zones entry must be rejected, not silently emptied");
assert!(
err.to_string().contains("zones") || err.to_string().contains("x"),
"error should point at the offending field, got: {err}"
);
}
#[test]
fn legacy_halo_missing_zones_is_a_named_error_not_a_silent_empty_zones() {
let err = serde_json::from_value::<AnimatedBackground>(json!({ "preset": "halo" }))
.expect_err("missing zones must be rejected, not silently treated as an empty halo");
assert!(err.to_string().contains("zones"), "got: {err}");
}
#[test]
fn legacy_gradient_shift_missing_colors_is_a_named_error_not_a_silent_black_gradient() {
let err = serde_json::from_value::<AnimatedBackground>(json!({
"preset": "gradient_shift",
"speed": 5
}))
.expect_err("missing colors must error, not silently produce an empty (black) gradient");
assert!(err.to_string().contains("colors"), "got: {err}");
}
#[test]
fn legacy_heropattern_is_recognised_not_silently_turned_into_gradient_shift() {
let bg: AnimatedBackground = serde_json::from_value(json!({
"preset": "heropattern",
"pattern": "plus",
"color": "#ffffff",
"opacity": 0.2,
"scale": 1.5
}))
.unwrap();
match bg.preset {
BackgroundPreset::Heropattern(cfg) => {
assert_eq!(cfg.pattern, "plus");
assert_eq!(cfg.scale, 1.5);
}
other => panic!("expected Heropattern, got {other:?}"),
}
}
#[test]
fn legacy_heropattern_missing_pattern_is_a_named_error() {
let err = serde_json::from_value::<AnimatedBackground>(json!({
"preset": "heropattern"
}))
.expect_err("heropattern with no pattern name must error");
assert!(err.to_string().contains("pattern"), "got: {err}");
}
#[test]
fn direction_typo_is_a_named_error_not_a_silently_dropped_none() {
let err = serde_json::from_value::<AnimatedBackground>(json!({
"preset": "grid_dots",
"colors": ["#fff"],
"direction": "diagonal"
}))
.expect_err("an unrecognised direction must be rejected, not silently dropped to None");
assert!(err.to_string().contains("direction"), "got: {err}");
}
#[test]
fn direction_still_works_when_valid() {
let bg: AnimatedBackground = serde_json::from_value(json!({
"preset": "grid_dots",
"colors": ["#fff"],
"direction": "up"
}))
.unwrap();
assert!(matches!(bg.direction, Some(ScrollDirection::Up)));
}
}