use crate::model::{Haptic, Inst, LoopMode, Motor, Profile};
use crate::semantic::{profile_for_haptic, profile_map, resolve_curve_symbol};
use std::collections::BTreeSet;
fn motor_label(motor: Motor) -> &'static str {
match motor {
Motor::Erm => "erm",
Motor::Lra => "lra",
}
}
pub(crate) fn validate_haptics(
haptics: &[Haptic],
profiles: &[Profile],
known_curves: Option<&BTreeSet<String>>,
) -> Result<(), String> {
let profile_map = profile_map(profiles);
let default_erm = Profile::default_erm_named("__default_erm");
let default_lra = Profile::default_lra_named("__default_lra");
for haptic in haptics {
if haptic.instructions.is_empty() {
return Err(format!(
"line {}: haptic `{}` has no instructions",
haptic.line, haptic.name
));
}
let profile = profile_for_haptic(haptic, &profile_map, &default_erm, &default_lra)?;
if haptic.profile.is_some()
&& let Some(profile) = profile
&& profile.motor != haptic.motor
{
return Err(format!(
"line {}: haptic `{}` is motor={} but profile `{}` is tuned for {}",
haptic.line,
haptic.name,
motor_label(haptic.motor),
profile.name,
motor_label(profile.motor)
));
}
validate_loop_bounds(haptic)?;
for inst in &haptic.instructions {
if haptic.motor == Motor::Erm
&& let Some(option) = inst.lra_option()
{
return Err(format!(
"line {}: `{option}` is not valid on motor=erm haptic `{}`",
inst.line(),
haptic.name
));
}
if let Inst::Ramp {
line,
curve: curve_ref,
..
} = inst
{
let symbol = resolve_curve_symbol(curve_ref, profile, *line, &haptic.name)?;
if let Some(curves) = known_curves
&& !curves.contains(&symbol)
{
return Err(format!("line {line}: unknown curve symbol `{symbol}`"));
}
}
}
}
Ok(())
}
fn validate_loop_bounds(haptic: &Haptic) -> Result<(), String> {
let LoopMode::Count(count) = haptic.loop_mode else {
return Ok(());
};
let cycle_ms: u64 = haptic
.instructions
.iter()
.map(|inst| u64::from(inst.duration_ms()))
.sum();
let total_ms = cycle_ms.saturating_mul(u64::from(count));
if total_ms > u64::from(u32::MAX) {
return Err(format!(
"line {}: haptic `{}` loop={count} over a {cycle_ms}ms cycle totals {total_ms}ms, which overflows the runtime's u32 millisecond clock",
haptic.line, haptic.name
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CurveRef, Inst, LoopMode, Motor};
#[test]
fn rejects_unknown_profile_reference() {
let haptics = [Haptic {
line: 1,
name: "click".to_owned(),
motor: Motor::Erm,
loop_mode: LoopMode::Once,
profile: Some("missing".to_owned()),
instructions: vec![Inst::Hold {
line: 2,
duration_ms: 10,
level: 100,
lra_hz: None,
}],
}];
let error = validate_haptics(&haptics, &[], None).unwrap_err();
assert!(error.contains("unknown profile"));
}
#[test]
fn rejects_unknown_curve_symbol_when_validation_enabled() {
let haptics = [Haptic {
line: 1,
name: "click".to_owned(),
motor: Motor::Erm,
loop_mode: LoopMode::Once,
profile: None,
instructions: vec![Inst::Ramp {
line: 2,
duration_ms: 10,
from: 0,
to: 100,
curve: CurveRef::Named("missing_curve".to_owned()),
step: None,
rounding: None,
min_dt_ms: None,
lra_hz: None,
lra_hz_to: None,
}],
}];
let mut known = BTreeSet::new();
known.insert("LINEAR".to_owned());
let error = validate_haptics(&haptics, &[], Some(&known)).unwrap_err();
assert!(error.contains("unknown curve symbol"));
}
#[test]
fn accepts_valid_haptic() {
let haptics = [Haptic {
line: 1,
name: "click".to_owned(),
motor: Motor::Erm,
loop_mode: LoopMode::Once,
profile: None,
instructions: vec![Inst::Hold {
line: 2,
duration_ms: 10,
level: 30000,
lra_hz: None,
}],
}];
validate_haptics(&haptics, &[], None).unwrap();
}
fn haptic_with(motor: Motor, profile: Option<&str>, instructions: Vec<Inst>) -> Haptic {
Haptic {
line: 1,
name: "probe".to_owned(),
motor,
loop_mode: LoopMode::Once,
profile: profile.map(str::to_owned),
instructions,
}
}
fn hold(line: u32, lra_hz: Option<u16>) -> Inst {
Inst::Hold {
line,
duration_ms: 10,
level: 30_000,
lra_hz,
}
}
#[test]
fn rejects_profile_tuned_for_the_other_motor() {
let haptics = [haptic_with(
Motor::Lra,
Some("erm_ui_soft"),
vec![hold(2, None)],
)];
let profiles = [Profile::default_erm_named("erm_ui_soft")];
let error = validate_haptics(&haptics, &profiles, None).unwrap_err();
assert!(error.contains("tuned for erm"), "error: {error}");
}
#[test]
fn accepts_profile_matching_motor() {
let haptics = [haptic_with(Motor::Lra, Some("lra_ui"), vec![hold(2, None)])];
let profiles = [Profile::default_lra_named("lra_ui")];
validate_haptics(&haptics, &profiles, None).unwrap();
}
#[test]
fn rejects_lra_options_on_erm_haptic() {
let haptics = [haptic_with(Motor::Erm, None, vec![hold(7, Some(200))])];
let error = validate_haptics(&haptics, &[], None).unwrap_err();
assert!(error.contains("line 7"), "error: {error}");
assert!(error.contains("`lra_hz` is not valid"), "error: {error}");
}
#[test]
fn allows_lra_options_on_lra_haptic() {
let haptics = [haptic_with(Motor::Lra, None, vec![hold(7, Some(200))])];
validate_haptics(&haptics, &[], None).unwrap();
}
#[test]
fn rejects_loop_count_that_overflows_the_ms_clock() {
let mut haptic = haptic_with(Motor::Erm, None, vec![hold(2, None)]);
haptic.loop_mode = LoopMode::Count(500_000_000);
let error = validate_haptics(&[haptic], &[], None).unwrap_err();
assert!(error.contains("overflows"), "error: {error}");
}
#[test]
fn accepts_loop_count_within_the_ms_clock() {
let mut haptic = haptic_with(Motor::Erm, None, vec![hold(2, None)]);
haptic.loop_mode = LoopMode::Count(1_000);
validate_haptics(&[haptic], &[], None).unwrap();
}
#[test]
fn rejects_empty_instructions() {
let haptics = [Haptic {
line: 1,
name: "empty".to_owned(),
motor: Motor::Erm,
loop_mode: LoopMode::Once,
profile: None,
instructions: vec![],
}];
let error = validate_haptics(&haptics, &[], None).unwrap_err();
assert!(error.contains("has no instructions"), "error: {error}");
}
}