use super::*;
fn roundtrip(json: &str) -> serde_json::Value {
let doc: SoundDoc = serde_json::from_str(json).expect("deserialize");
serde_json::to_value(&doc).expect("serialize")
}
fn doc_with_root(root: &str) -> SoundDoc {
serde_json::from_str(&format!(
r#"{{ "name": "t", "duration": 0.2, "engine": 1, "root": {root} }}"#
))
.expect("deserialize")
}
#[test]
fn doc_defaults_fill_in() {
let doc: SoundDoc =
serde_json::from_str(r#"{ "name": "beep", "root": { "type": "sine", "freq": 440 } }"#)
.unwrap();
assert_eq!(doc.duration, 0.3);
assert_eq!(doc.sample_rate, 44_100);
assert_eq!(doc.version, None);
assert_eq!(doc.effective_version(), 1);
assert!(matches!(doc.stereo, Stereo::Mono));
assert!(matches!(doc.playback, Playback::OneShot));
}
#[test]
fn v2_tracks_reject_doc_level_stereo() {
let mut doc: SoundDoc = serde_json::from_str(
r#"{ "name": "band", "duration": 0.2, "version": 2,
"stereo": { "mode": "wide" },
"root": { "type": "tracks",
"tracks": [ { "id": "a", "node": { "type": "sine", "freq": 220 } } ] } }"#,
)
.unwrap();
let err = doc.validate().unwrap_err();
assert!(err.contains("per-layer pan"), "{err}");
doc.version = None;
assert_eq!(doc.validate(), Ok(()));
}
#[test]
fn future_schema_versions_are_rejected() {
let mut doc: SoundDoc =
serde_json::from_str(r#"{ "name": "beep", "root": { "type": "sine", "freq": 440 } }"#)
.unwrap();
assert_eq!(doc.validate(), Ok(()));
doc.version = Some(SCHEMA_VERSION);
assert_eq!(doc.validate(), Ok(()));
doc.version = Some(SCHEMA_VERSION + 1);
let err = doc.validate().unwrap_err();
assert!(err.contains("upgrade tono"), "unhelpful error: {err}");
doc.version = Some(0);
assert!(doc.validate().is_err());
}
#[test]
fn engine_defaults_to_zero_and_bounds_at_current_revision() {
let mut doc: SoundDoc =
serde_json::from_str(r#"{ "name": "beep", "root": { "type": "sine", "freq": 440 } }"#)
.unwrap();
assert_eq!(doc.engine, None);
assert_eq!(doc.effective_engine(), 0);
assert_eq!(doc.validate(), Ok(()));
doc.engine = Some(ENGINE_VERSION);
assert_eq!(doc.validate(), Ok(()));
doc.engine = Some(ENGINE_VERSION + 1);
let err = doc.validate().unwrap_err();
assert!(err.contains("engine must be in"), "unhelpful error: {err}");
}
#[test]
fn modal_and_impact_validate_their_ranges() {
let modal = |modes: &str| -> Result<(), String> {
doc_with_root(&format!(
r#"{{ "type": "chain", "stages": [
{{ "type": "impact" }},
{{ "type": "modal", "modes": {modes} }} ] }}"#
))
.validate()
};
assert!(modal(r#"[ { "freq": 440, "decay": 0.5, "gain": 1.0 } ]"#).is_ok());
assert!(modal("[]").unwrap_err().contains("non-empty"));
assert!(modal(r#"[ { "freq": -1 } ]"#).unwrap_err().contains("freq"));
assert!(
modal(r#"[ { "freq": 440, "decay": 0 } ]"#)
.unwrap_err()
.contains("decay")
);
assert!(
modal(r#"[ { "freq": 440, "gain": 2 } ]"#)
.unwrap_err()
.contains("gain")
);
assert!(
doc_with_root(r#"{ "type": "impact", "hardness": 1.5 }"#)
.validate()
.unwrap_err()
.contains("hardness")
);
}
#[test]
fn dust_and_rand_validate_their_ranges() {
assert!(
doc_with_root(r#"{ "type": "dust", "density": 50 }"#)
.validate()
.is_ok()
);
assert!(
doc_with_root(r#"{ "type": "dust", "density": 0 }"#)
.validate()
.unwrap_err()
.contains("density")
);
let with_cutoff = |m: &str| {
doc_with_root(&format!(
r#"{{ "type": "chain", "stages": [
{{ "type": "noise" }},
{{ "type": "lowpass", "cutoff": {m} }} ] }}"#
))
.validate()
};
assert!(with_cutoff(r#"{ "rand": { "from": 200, "to": 1200, "rate": 0.8 } }"#).is_ok());
assert!(
with_cutoff(r#"{ "rand": { "from": 200, "to": 1200, "rate": 0 } }"#)
.unwrap_err()
.contains("rand.rate")
);
}
#[test]
fn node_tag_is_type_lowercase() {
let v = roundtrip(r#"{ "name": "n", "root": { "type": "ringmod", "freq": 100 } }"#);
assert_eq!(v["root"]["type"], "ringmod");
}
#[test]
fn env_flattens_adsr_fields_inline() {
let v = roundtrip(
r#"{ "name": "n", "root": { "type": "env", "a": 0.01, "d": 0.2, "punch": 0.3 } }"#,
);
assert_eq!(v["root"]["a"], 0.01f32 as f64);
assert_eq!(v["root"]["punch"], 0.3f32 as f64);
assert!(v["root"].get("adsr").is_none());
}
#[test]
fn value_untagged_forms() {
let doc: SoundDoc = serde_json::from_str(
r#"{ "name": "n", "root": { "type": "mix", "inputs": [
{ "type": "sine", "freq": 440 },
{ "type": "sine", "freq": "A4" },
{ "type": "sine", "freq": { "slide": { "from": 880, "to": 180, "secs": 0.2 } } },
{ "type": "sine", "freq": { "lfo": { "rate": 5, "depth": 10, "center": 440 } } },
{ "type": "sine", "freq": { "arp": { "steps": [523, 659], "rate": 12 } } },
{ "type": "sine", "freq": { "env": { "a": 0.1, "from": 100, "to": 800 } } }
] } }"#,
)
.unwrap();
let Node::Mix { inputs } = &doc.root else {
panic!("expected mix");
};
assert!(matches!(
&inputs[0],
Node::Sine {
freq: Value::Const(f)
} if *f == 440.0
));
assert!(matches!(&inputs[1], Node::Sine { freq: Value::Note(s) } if s == "A4"));
assert!(matches!(
&inputs[2],
Node::Sine {
freq: Value::Modulated(Modulator::Slide {
curve: Curve::Lin,
..
})
}
));
assert!(matches!(
&inputs[5],
Node::Sine {
freq: Value::Modulated(Modulator::EnvMod { adsr, .. })
} if adsr.a == 0.1
));
}
#[test]
fn playback_loop_tag() {
let v = roundtrip(
r#"{ "name": "n", "playback": { "mode": "loop", "crossfade_secs": 0.25 },
"root": { "type": "noise" } }"#,
);
assert_eq!(v["playback"]["mode"], "loop");
assert_eq!(v["playback"]["crossfade_secs"], 0.25f32 as f64);
}
#[test]
fn stereo_modes() {
let v = roundtrip(
r#"{ "name": "n", "stereo": { "mode": "haas", "pan": -1 },
"root": { "type": "noise" } }"#,
);
assert_eq!(v["stereo"]["mode"], "haas");
assert_eq!(v["stereo"]["ms"], 12.0); }
#[test]
fn note_names_resolve_to_hz() {
assert_eq!(note_to_hz("A4"), Some(440.0));
assert_eq!(note_to_hz("midi:69"), Some(440.0));
assert_eq!(note_to_hz("m69"), Some(440.0));
assert!((note_to_hz("C#3").unwrap() - 138.591).abs() < 0.01);
assert!((note_to_hz("Gb5").unwrap() - 739.989).abs() < 0.01);
assert_eq!(note_to_hz("A"), Some(440.0));
assert_eq!(note_to_hz("a4"), Some(440.0));
assert!((note_to_hz("F#-1").unwrap() - 11.562).abs() < 0.01);
assert_eq!(note_to_hz(""), None);
assert_eq!(note_to_hz("H4"), None);
assert_eq!(note_to_hz("A4x"), None);
}
#[test]
fn processors_are_processors_sources_are_not() {
let p: Node = serde_json::from_str(r#"{ "type": "reverb", "room": 0.5 }"#).unwrap();
assert!(p.is_processor());
let s: Node = serde_json::from_str(r#"{ "type": "sine", "freq": 440 }"#).unwrap();
assert!(!s.is_processor());
}
fn doc(json: &str) -> SoundDoc {
serde_json::from_str(json).expect("deserialize")
}
#[test]
fn validate_accepts_a_sane_doc() {
let d = doc(
r#"{ "name": "zap", "duration": 0.2, "root": { "type": "mul", "inputs": [
{ "type": "square", "freq": { "slide": { "from": 880, "to": 180, "secs": 0.18 } } },
{ "type": "env", "d": 0.18, "punch": 0.3 }
] } }"#,
);
assert_eq!(d.validate(), Ok(()));
}
#[test]
fn validate_rejects_out_of_range_metadata() {
let d = doc(r#"{ "name": "n", "duration": 0, "root": { "type": "noise" } }"#);
assert!(d.validate().unwrap_err().contains("duration"));
let d = doc(r#"{ "name": "n", "sample_rate": 1000, "root": { "type": "noise" } }"#);
assert!(d.validate().unwrap_err().contains("sample_rate"));
let d =
doc(r#"{ "name": "n", "normalize": { "target_lufs": 5 }, "root": { "type": "noise" } }"#);
assert!(d.validate().unwrap_err().contains("target_lufs"));
}
#[test]
fn validate_rejects_bad_loop_region() {
let d = doc(r#"{ "name": "n", "duration": 1,
"playback": { "mode": "loop", "start_secs": 0.8, "end_secs": 0.5 },
"root": { "type": "noise" } }"#);
assert!(d.validate().unwrap_err().contains("end_secs"));
}
#[test]
fn validate_rejects_unit_range_violations() {
let d = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "reverb", "mix": 1.5 }
] } }"#);
assert!(d.validate().unwrap_err().contains("reverb.mix"));
let d = doc(r#"{ "name": "n", "root": { "type": "env", "s": 2 } }"#);
assert!(d.validate().unwrap_err().contains("env.s"));
}
#[test]
fn validate_rejects_silent_all_zero_env() {
let d = doc(r#"{ "name": "n", "root": { "type": "env",
"adsr": { "a": 0.01, "d": 0.1, "s": 0.7, "r": 0.2 } } }"#);
assert!(d.validate().unwrap_err().contains("env is silent"));
let ok = doc(
r#"{ "name": "n", "root": { "type": "env", "a": 0.01, "d": 0.1, "s": 0.7, "r": 0.2 } }"#,
);
assert!(ok.validate().is_ok());
}
#[test]
fn validate_rejects_extreme_eq_gain() {
let d = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "peak", "cutoff": 1000, "gain_db": 2000 }
] } }"#);
assert!(d.validate().unwrap_err().contains("peak.gain_db"));
let d = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "lowshelf", "cutoff": 200, "gain_db": -100 }
] } }"#);
assert!(d.validate().unwrap_err().contains("shelf.gain_db"));
}
#[test]
fn validate_rejects_empty_combinators_and_bad_notes() {
let d = doc(r#"{ "name": "n", "root": { "type": "mix", "inputs": [] } }"#);
assert!(d.validate().unwrap_err().contains("mix/mul"));
let d = doc(r#"{ "name": "n", "root": { "type": "sine", "freq": "H9" } }"#);
assert!(d.validate().unwrap_err().contains("not a valid note"));
}
#[test]
fn validate_bounds_the_delay_line() {
let d = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "delay", "secs": 1e9, "feedback": 0.3 }
] } }"#);
assert!(d.validate().unwrap_err().contains("delay.secs"));
let ok = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "delay", "secs": 0.3, "feedback": 0.3 }
] } }"#);
assert!(ok.validate().is_ok());
}
#[test]
fn validate_rejects_non_finite_and_non_positive_constants() {
let d = doc(r#"{ "name": "n", "root": { "type": "sine", "freq": 1e308 } }"#);
assert!(d.validate().unwrap_err().contains("finite"));
let d = doc(r#"{ "name": "n", "root": { "type": "sine", "freq": -440 } }"#);
assert!(d.validate().unwrap_err().contains("sine.freq"));
let d = doc(r#"{ "name": "n", "root": { "type": "square", "duty": 0.5,
"freq": { "slide": { "from": 1e308, "to": 440, "secs": 0.1 } } } }"#);
assert!(d.validate().unwrap_err().contains("slide.from"));
}
#[test]
fn validate_rejects_non_finite_seq_voice_knobs() {
let d = doc(
r#"{ "name": "n", "root": { "type": "seq", "bpm": 120, "wave": "bass",
"bass_cutoff": 1e308,
"env": { "a": 0.005, "d": 0.05, "s": 0.7, "r": 0.1 },
"notes": [ { "step": 0, "len": 2, "pitch": "E1" } ] } }"#,
);
assert!(d.validate().unwrap_err().contains("bass_cutoff"));
}
#[test]
fn validate_checks_automation_lanes() {
let d = doc(
r#"{ "name": "n", "version": 2, "root": { "type": "tracks", "tracks": [
{ "id": "a", "node": { "type": "noise" },
"automation": [ { "target": "gain", "points": [ { "t": -1, "v": 0.5 } ] } ] }
] } }"#,
);
assert!(d.validate().unwrap_err().contains(".t must be >= 0"));
let d = doc(
r#"{ "name": "n", "version": 2, "root": { "type": "tracks", "tracks": [
{ "id": "a", "node": { "type": "noise" },
"automation": [ { "target": "pan", "points": [ { "t": 0, "v": 7 } ] } ] }
] } }"#,
);
assert!(d.validate().unwrap_err().contains("[-1, 1]"));
}