Skip to main content

peat_schema/validation/
actuator.rs

1//! Actuator validators
2//!
3//! Validates ActuatorSpec, ActuatorStateUpdate, and ActuatorCommand messages for Peat Protocol.
4
5use super::{ValidationError, ValidationResult};
6use crate::actuator::v1::{
7    ActuatorCommand, ActuatorCommandType, ActuatorMount, ActuatorSpec, ActuatorStateUpdate,
8    ActuatorStatus, ActuatorType, BarrierLimits, BarrierState, GripperLimits, GripperState,
9    LinearLimits, LinearState, LockState, RotaryLimits, RotaryState, ValveLimits, ValveState,
10    WinchLimits, WinchState,
11};
12
13/// Validate linear actuator limits
14///
15/// Validates:
16/// - position_min <= position_max
17/// - velocity_max is non-negative
18/// - force_max is non-negative
19pub fn validate_linear_limits(limits: &LinearLimits) -> ValidationResult<()> {
20    if limits.position_min_m > limits.position_max_m {
21        return Err(ValidationError::ConstraintViolation(
22            "position_min_m must be <= position_max_m".to_string(),
23        ));
24    }
25
26    if limits.velocity_max_mps < 0.0 {
27        return Err(ValidationError::InvalidValue(
28            "velocity_max_mps must be non-negative".to_string(),
29        ));
30    }
31
32    if limits.force_max_n < 0.0 {
33        return Err(ValidationError::InvalidValue(
34            "force_max_n must be non-negative".to_string(),
35        ));
36    }
37
38    Ok(())
39}
40
41/// Validate rotary actuator limits
42///
43/// Validates:
44/// - angle_min <= angle_max
45/// - velocity_max is non-negative
46/// - torque_max is non-negative
47pub fn validate_rotary_limits(limits: &RotaryLimits) -> ValidationResult<()> {
48    // For continuous rotation, min/max might be equal (e.g., both 0 for unlimited)
49    if !limits.continuous_rotation && limits.angle_min_deg > limits.angle_max_deg {
50        return Err(ValidationError::ConstraintViolation(
51            "angle_min_deg must be <= angle_max_deg for non-continuous rotation".to_string(),
52        ));
53    }
54
55    if limits.velocity_max_dps < 0.0 {
56        return Err(ValidationError::InvalidValue(
57            "velocity_max_dps must be non-negative".to_string(),
58        ));
59    }
60
61    if limits.torque_max_nm < 0.0 {
62        return Err(ValidationError::InvalidValue(
63            "torque_max_nm must be non-negative".to_string(),
64        ));
65    }
66
67    Ok(())
68}
69
70/// Validate gripper limits
71///
72/// Validates:
73/// - aperture_min <= aperture_max
74/// - aperture values are non-negative
75/// - grip_force_max is non-negative
76/// - payload_max is non-negative
77pub fn validate_gripper_limits(limits: &GripperLimits) -> ValidationResult<()> {
78    if limits.aperture_min_m < 0.0 {
79        return Err(ValidationError::InvalidValue(
80            "aperture_min_m must be non-negative".to_string(),
81        ));
82    }
83
84    if limits.aperture_min_m > limits.aperture_max_m {
85        return Err(ValidationError::ConstraintViolation(
86            "aperture_min_m must be <= aperture_max_m".to_string(),
87        ));
88    }
89
90    if limits.grip_force_max_n < 0.0 {
91        return Err(ValidationError::InvalidValue(
92            "grip_force_max_n must be non-negative".to_string(),
93        ));
94    }
95
96    if limits.payload_max_kg < 0.0 {
97        return Err(ValidationError::InvalidValue(
98            "payload_max_kg must be non-negative".to_string(),
99        ));
100    }
101
102    Ok(())
103}
104
105/// Validate valve limits
106///
107/// Validates:
108/// - position values are in [0.0, 1.0]
109/// - position_min <= position_max
110/// - travel time is positive
111pub fn validate_valve_limits(limits: &ValveLimits) -> ValidationResult<()> {
112    if limits.position_min < 0.0 || limits.position_min > 1.0 {
113        return Err(ValidationError::InvalidValue(format!(
114            "position_min {} must be in range [0.0, 1.0]",
115            limits.position_min
116        )));
117    }
118
119    if limits.position_max < 0.0 || limits.position_max > 1.0 {
120        return Err(ValidationError::InvalidValue(format!(
121            "position_max {} must be in range [0.0, 1.0]",
122            limits.position_max
123        )));
124    }
125
126    if limits.position_min > limits.position_max {
127        return Err(ValidationError::ConstraintViolation(
128            "position_min must be <= position_max".to_string(),
129        ));
130    }
131
132    if limits.full_travel_time_s <= 0.0 {
133        return Err(ValidationError::InvalidValue(
134            "full_travel_time_s must be positive".to_string(),
135        ));
136    }
137
138    Ok(())
139}
140
141/// Validate barrier/gate limits
142///
143/// Validates:
144/// - position values are in [0.0, 1.0]
145/// - cycle time is positive
146/// - dimensions are non-negative
147pub fn validate_barrier_limits(limits: &BarrierLimits) -> ValidationResult<()> {
148    if limits.position_min < 0.0 || limits.position_min > 1.0 {
149        return Err(ValidationError::InvalidValue(format!(
150            "position_min {} must be in range [0.0, 1.0]",
151            limits.position_min
152        )));
153    }
154
155    if limits.position_max < 0.0 || limits.position_max > 1.0 {
156        return Err(ValidationError::InvalidValue(format!(
157            "position_max {} must be in range [0.0, 1.0]",
158            limits.position_max
159        )));
160    }
161
162    if limits.full_cycle_time_s <= 0.0 {
163        return Err(ValidationError::InvalidValue(
164            "full_cycle_time_s must be positive".to_string(),
165        ));
166    }
167
168    if limits.clear_width_m < 0.0 {
169        return Err(ValidationError::InvalidValue(
170            "clear_width_m must be non-negative".to_string(),
171        ));
172    }
173
174    if limits.clear_height_m < 0.0 {
175        return Err(ValidationError::InvalidValue(
176            "clear_height_m must be non-negative".to_string(),
177        ));
178    }
179
180    Ok(())
181}
182
183/// Validate winch limits
184///
185/// Validates:
186/// - cable_min <= cable_max
187/// - cable values are non-negative
188/// - speed and tension are non-negative
189pub fn validate_winch_limits(limits: &WinchLimits) -> ValidationResult<()> {
190    if limits.cable_min_m < 0.0 {
191        return Err(ValidationError::InvalidValue(
192            "cable_min_m must be non-negative".to_string(),
193        ));
194    }
195
196    if limits.cable_min_m > limits.cable_max_m {
197        return Err(ValidationError::ConstraintViolation(
198            "cable_min_m must be <= cable_max_m".to_string(),
199        ));
200    }
201
202    if limits.line_speed_max_mps < 0.0 {
203        return Err(ValidationError::InvalidValue(
204            "line_speed_max_mps must be non-negative".to_string(),
205        ));
206    }
207
208    if limits.tension_max_n < 0.0 {
209        return Err(ValidationError::InvalidValue(
210            "tension_max_n must be non-negative".to_string(),
211        ));
212    }
213
214    if limits.payload_max_kg < 0.0 {
215        return Err(ValidationError::InvalidValue(
216            "payload_max_kg must be non-negative".to_string(),
217        ));
218    }
219
220    Ok(())
221}
222
223/// Validate linear actuator state against limits
224pub fn validate_linear_state(
225    state: &LinearState,
226    limits: Option<&LinearLimits>,
227) -> ValidationResult<()> {
228    if let Some(limits) = limits {
229        if state.position_m < limits.position_min_m || state.position_m > limits.position_max_m {
230            return Err(ValidationError::ConstraintViolation(format!(
231                "position_m {} must be within limits [{}, {}]",
232                state.position_m, limits.position_min_m, limits.position_max_m
233            )));
234        }
235    }
236    Ok(())
237}
238
239/// Validate rotary actuator state against limits
240pub fn validate_rotary_state(
241    state: &RotaryState,
242    limits: Option<&RotaryLimits>,
243) -> ValidationResult<()> {
244    if let Some(limits) = limits {
245        if !limits.continuous_rotation
246            && (state.angle_deg < limits.angle_min_deg || state.angle_deg > limits.angle_max_deg)
247        {
248            return Err(ValidationError::ConstraintViolation(format!(
249                "angle_deg {} must be within limits [{}, {}]",
250                state.angle_deg, limits.angle_min_deg, limits.angle_max_deg
251            )));
252        }
253    }
254    Ok(())
255}
256
257/// Validate gripper state against limits
258pub fn validate_gripper_state(
259    state: &GripperState,
260    limits: Option<&GripperLimits>,
261) -> ValidationResult<()> {
262    if state.aperture_m < 0.0 {
263        return Err(ValidationError::InvalidValue(
264            "aperture_m must be non-negative".to_string(),
265        ));
266    }
267
268    if let Some(limits) = limits {
269        if state.aperture_m < limits.aperture_min_m || state.aperture_m > limits.aperture_max_m {
270            return Err(ValidationError::ConstraintViolation(format!(
271                "aperture_m {} must be within limits [{}, {}]",
272                state.aperture_m, limits.aperture_min_m, limits.aperture_max_m
273            )));
274        }
275    }
276    Ok(())
277}
278
279/// Validate valve state
280pub fn validate_valve_state(state: &ValveState) -> ValidationResult<()> {
281    if state.position < 0.0 || state.position > 1.0 {
282        return Err(ValidationError::InvalidValue(format!(
283            "position {} must be in range [0.0, 1.0]",
284            state.position
285        )));
286    }
287    Ok(())
288}
289
290/// Validate barrier/gate state
291pub fn validate_barrier_state(state: &BarrierState) -> ValidationResult<()> {
292    if state.position < 0.0 || state.position > 1.0 {
293        return Err(ValidationError::InvalidValue(format!(
294            "position {} must be in range [0.0, 1.0]",
295            state.position
296        )));
297    }
298
299    // Consistency checks
300    if state.is_closed && state.position > 0.01 {
301        return Err(ValidationError::ConstraintViolation(
302            "is_closed should not be true when position > 0".to_string(),
303        ));
304    }
305
306    if state.is_open && state.position < 0.99 {
307        return Err(ValidationError::ConstraintViolation(
308            "is_open should not be true when position < 1".to_string(),
309        ));
310    }
311
312    Ok(())
313}
314
315/// Validate winch state against limits
316pub fn validate_winch_state(
317    state: &WinchState,
318    limits: Option<&WinchLimits>,
319) -> ValidationResult<()> {
320    if state.cable_out_m < 0.0 {
321        return Err(ValidationError::InvalidValue(
322            "cable_out_m must be non-negative".to_string(),
323        ));
324    }
325
326    if let Some(limits) = limits {
327        if state.cable_out_m > limits.cable_max_m {
328            return Err(ValidationError::ConstraintViolation(format!(
329                "cable_out_m {} exceeds max {}",
330                state.cable_out_m, limits.cable_max_m
331            )));
332        }
333    }
334    Ok(())
335}
336
337/// Validate lock state (basic validation - locks have minimal numeric constraints)
338pub fn validate_lock_state(_state: &LockState) -> ValidationResult<()> {
339    // Lock state is mostly boolean - no numeric constraints to validate
340    // Could validate timestamps if needed
341    Ok(())
342}
343
344/// Validate a complete actuator specification
345///
346/// Validates:
347/// - actuator_id is present
348/// - name is present
349/// - actuator_type is specified
350/// - mount is specified
351/// - Type-specific limits are valid if present
352/// - Type-specific state is valid if present
353pub fn validate_actuator_spec(spec: &ActuatorSpec) -> ValidationResult<()> {
354    // Check required fields
355    if spec.actuator_id.is_empty() {
356        return Err(ValidationError::MissingField("actuator_id".to_string()));
357    }
358
359    if spec.name.is_empty() {
360        return Err(ValidationError::MissingField("name".to_string()));
361    }
362
363    // Type must be specified
364    if spec.actuator_type == ActuatorType::Unspecified as i32 {
365        return Err(ValidationError::InvalidValue(
366            "actuator_type must be specified".to_string(),
367        ));
368    }
369
370    // Mount must be specified
371    if spec.mount == ActuatorMount::Unspecified as i32 {
372        return Err(ValidationError::InvalidValue(
373            "mount must be specified".to_string(),
374        ));
375    }
376
377    // Validate type-specific limits if present
378    if let Some(ref limits) = spec.limits {
379        use crate::actuator::v1::actuator_spec::Limits;
380        match limits {
381            Limits::LinearLimits(l) => validate_linear_limits(l)?,
382            Limits::RotaryLimits(l) => validate_rotary_limits(l)?,
383            Limits::GripperLimits(l) => validate_gripper_limits(l)?,
384            Limits::ValveLimits(l) => validate_valve_limits(l)?,
385            Limits::BarrierLimits(l) => validate_barrier_limits(l)?,
386            Limits::WinchLimits(l) => validate_winch_limits(l)?,
387        }
388    }
389
390    // Validate type-specific state if present
391    if let Some(ref state) = spec.state {
392        use crate::actuator::v1::actuator_spec::State;
393        match state {
394            State::LinearState(s) => {
395                let limits = spec.limits.as_ref().and_then(|l| {
396                    if let crate::actuator::v1::actuator_spec::Limits::LinearLimits(ll) = l {
397                        Some(ll)
398                    } else {
399                        None
400                    }
401                });
402                validate_linear_state(s, limits)?;
403            }
404            State::RotaryState(s) => {
405                let limits = spec.limits.as_ref().and_then(|l| {
406                    if let crate::actuator::v1::actuator_spec::Limits::RotaryLimits(rl) = l {
407                        Some(rl)
408                    } else {
409                        None
410                    }
411                });
412                validate_rotary_state(s, limits)?;
413            }
414            State::GripperState(s) => {
415                let limits = spec.limits.as_ref().and_then(|l| {
416                    if let crate::actuator::v1::actuator_spec::Limits::GripperLimits(gl) = l {
417                        Some(gl)
418                    } else {
419                        None
420                    }
421                });
422                validate_gripper_state(s, limits)?;
423            }
424            State::ValveState(s) => validate_valve_state(s)?,
425            State::BarrierState(s) => validate_barrier_state(s)?,
426            State::WinchState(s) => {
427                let limits = spec.limits.as_ref().and_then(|l| {
428                    if let crate::actuator::v1::actuator_spec::Limits::WinchLimits(wl) = l {
429                        Some(wl)
430                    } else {
431                        None
432                    }
433                });
434                validate_winch_state(s, limits)?;
435            }
436            State::LockState(s) => validate_lock_state(s)?,
437        }
438    }
439
440    Ok(())
441}
442
443/// Validate an actuator state update message
444///
445/// Validates:
446/// - platform_id is present
447/// - actuator spec is valid
448/// - status is specified
449/// - timestamp is present
450pub fn validate_actuator_state_update(update: &ActuatorStateUpdate) -> ValidationResult<()> {
451    if update.platform_id.is_empty() {
452        return Err(ValidationError::MissingField("platform_id".to_string()));
453    }
454
455    // Actuator spec is required
456    let actuator = update
457        .actuator
458        .as_ref()
459        .ok_or_else(|| ValidationError::MissingField("actuator".to_string()))?;
460    validate_actuator_spec(actuator)?;
461
462    // Status must be specified
463    if update.status == ActuatorStatus::Unspecified as i32 {
464        return Err(ValidationError::InvalidValue(
465            "status must be specified".to_string(),
466        ));
467    }
468
469    // Timestamp is required
470    if update.timestamp.is_none() {
471        return Err(ValidationError::MissingField("timestamp".to_string()));
472    }
473
474    Ok(())
475}
476
477/// Validate an actuator command
478///
479/// Validates:
480/// - command_id is present
481/// - platform_id is present
482/// - actuator_id is present
483/// - command_type is specified
484/// - issued_at is present
485pub fn validate_actuator_command(cmd: &ActuatorCommand) -> ValidationResult<()> {
486    if cmd.command_id.is_empty() {
487        return Err(ValidationError::MissingField("command_id".to_string()));
488    }
489
490    if cmd.platform_id.is_empty() {
491        return Err(ValidationError::MissingField("platform_id".to_string()));
492    }
493
494    if cmd.actuator_id.is_empty() {
495        return Err(ValidationError::MissingField("actuator_id".to_string()));
496    }
497
498    if cmd.command_type == ActuatorCommandType::ActuatorCommandUnspecified as i32 {
499        return Err(ValidationError::InvalidValue(
500            "command_type must be specified".to_string(),
501        ));
502    }
503
504    if cmd.issued_at.is_none() {
505        return Err(ValidationError::MissingField("issued_at".to_string()));
506    }
507
508    // If expires_at is set, validate it comes after issued_at
509    if let (Some(issued), Some(expires)) = (&cmd.issued_at, &cmd.expires_at) {
510        if expires.seconds < issued.seconds
511            || (expires.seconds == issued.seconds && expires.nanos < issued.nanos)
512        {
513            return Err(ValidationError::ConstraintViolation(
514                "expires_at must be after issued_at".to_string(),
515            ));
516        }
517    }
518
519    Ok(())
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::actuator::v1::{actuator_spec::Limits, actuator_spec::State, ActuatorDrive};
526    use crate::common::v1::Timestamp;
527
528    fn valid_barrier_actuator() -> ActuatorSpec {
529        ActuatorSpec {
530            actuator_id: "gate-main".to_string(),
531            name: "Main Entry Gate".to_string(),
532            actuator_type: ActuatorType::Barrier as i32,
533            mount: ActuatorMount::Fixed as i32,
534            drive: ActuatorDrive::Electric as i32,
535            limits: Some(Limits::BarrierLimits(BarrierLimits {
536                position_min: 0.0,
537                position_max: 1.0,
538                full_cycle_time_s: 8.0,
539                clear_width_m: 4.0,
540                clear_height_m: 2.5,
541            })),
542            state: Some(State::BarrierState(BarrierState {
543                position: 0.0,
544                is_closed: true,
545                is_open: false,
546                obstruction_detected: false,
547            })),
548            updated_at: None,
549            metadata_json: String::new(),
550        }
551    }
552
553    fn valid_linear_actuator() -> ActuatorSpec {
554        ActuatorSpec {
555            actuator_id: "lift-main".to_string(),
556            name: "Cargo Lift".to_string(),
557            actuator_type: ActuatorType::Linear as i32,
558            mount: ActuatorMount::Fixed as i32,
559            drive: ActuatorDrive::Hydraulic as i32,
560            limits: Some(Limits::LinearLimits(LinearLimits {
561                position_min_m: 0.0,
562                position_max_m: 3.0,
563                velocity_max_mps: 0.5,
564                acceleration_max: 0.2,
565                force_max_n: 50000.0,
566            })),
567            state: Some(State::LinearState(LinearState {
568                position_m: 1.5,
569                velocity_mps: 0.0,
570                force_n: 0.0,
571            })),
572            updated_at: None,
573            metadata_json: String::new(),
574        }
575    }
576
577    fn valid_winch_actuator() -> ActuatorSpec {
578        ActuatorSpec {
579            actuator_id: "crane-hoist".to_string(),
580            name: "Container Crane Hoist".to_string(),
581            actuator_type: ActuatorType::Winch as i32,
582            mount: ActuatorMount::Fixed as i32,
583            drive: ActuatorDrive::Electric as i32,
584            limits: Some(Limits::WinchLimits(WinchLimits {
585                cable_min_m: 0.0,
586                cable_max_m: 50.0,
587                line_speed_max_mps: 2.0,
588                tension_max_n: 500000.0,
589                payload_max_kg: 40000.0,
590            })),
591            state: Some(State::WinchState(WinchState {
592                cable_out_m: 25.0,
593                line_speed_mps: 0.0,
594                tension_n: 150000.0,
595                payload_kg: 15000.0,
596            })),
597            updated_at: None,
598            metadata_json: String::new(),
599        }
600    }
601
602    #[test]
603    fn test_valid_barrier_actuator() {
604        let actuator = valid_barrier_actuator();
605        assert!(validate_actuator_spec(&actuator).is_ok());
606    }
607
608    #[test]
609    fn test_valid_linear_actuator() {
610        let actuator = valid_linear_actuator();
611        assert!(validate_actuator_spec(&actuator).is_ok());
612    }
613
614    #[test]
615    fn test_valid_winch_actuator() {
616        let actuator = valid_winch_actuator();
617        assert!(validate_actuator_spec(&actuator).is_ok());
618    }
619
620    #[test]
621    fn test_missing_actuator_id() {
622        let mut actuator = valid_barrier_actuator();
623        actuator.actuator_id = String::new();
624        let err = validate_actuator_spec(&actuator).unwrap_err();
625        assert!(matches!(err, ValidationError::MissingField(f) if f == "actuator_id"));
626    }
627
628    #[test]
629    fn test_missing_name() {
630        let mut actuator = valid_barrier_actuator();
631        actuator.name = String::new();
632        let err = validate_actuator_spec(&actuator).unwrap_err();
633        assert!(matches!(err, ValidationError::MissingField(f) if f == "name"));
634    }
635
636    #[test]
637    fn test_unspecified_actuator_type() {
638        let mut actuator = valid_barrier_actuator();
639        actuator.actuator_type = ActuatorType::Unspecified as i32;
640        let err = validate_actuator_spec(&actuator).unwrap_err();
641        assert!(matches!(err, ValidationError::InvalidValue(_)));
642    }
643
644    #[test]
645    fn test_unspecified_mount() {
646        let mut actuator = valid_barrier_actuator();
647        actuator.mount = ActuatorMount::Unspecified as i32;
648        let err = validate_actuator_spec(&actuator).unwrap_err();
649        assert!(matches!(err, ValidationError::InvalidValue(_)));
650    }
651
652    #[test]
653    fn test_invalid_barrier_position() {
654        let mut actuator = valid_barrier_actuator();
655        actuator.state = Some(State::BarrierState(BarrierState {
656            position: 1.5, // Invalid: > 1.0
657            is_closed: false,
658            is_open: false,
659            obstruction_detected: false,
660        }));
661        let err = validate_actuator_spec(&actuator).unwrap_err();
662        assert!(matches!(err, ValidationError::InvalidValue(_)));
663    }
664
665    #[test]
666    fn test_barrier_state_consistency() {
667        let mut actuator = valid_barrier_actuator();
668        actuator.state = Some(State::BarrierState(BarrierState {
669            position: 0.5,   // Half open
670            is_closed: true, // Invalid: closed but position > 0
671            is_open: false,
672            obstruction_detected: false,
673        }));
674        let err = validate_actuator_spec(&actuator).unwrap_err();
675        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
676    }
677
678    #[test]
679    fn test_linear_state_outside_limits() {
680        let mut actuator = valid_linear_actuator();
681        actuator.state = Some(State::LinearState(LinearState {
682            position_m: 5.0, // Outside limits [0, 3]
683            velocity_mps: 0.0,
684            force_n: 0.0,
685        }));
686        let err = validate_actuator_spec(&actuator).unwrap_err();
687        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
688    }
689
690    #[test]
691    fn test_invalid_linear_limits() {
692        let mut actuator = valid_linear_actuator();
693        actuator.limits = Some(Limits::LinearLimits(LinearLimits {
694            position_min_m: 5.0, // Invalid: min > max
695            position_max_m: 3.0,
696            velocity_max_mps: 0.5,
697            acceleration_max: 0.2,
698            force_max_n: 50000.0,
699        }));
700        let err = validate_actuator_spec(&actuator).unwrap_err();
701        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
702    }
703
704    #[test]
705    fn test_winch_cable_exceeds_max() {
706        let mut actuator = valid_winch_actuator();
707        actuator.state = Some(State::WinchState(WinchState {
708            cable_out_m: 100.0, // Exceeds max of 50m
709            line_speed_mps: 0.0,
710            tension_n: 0.0,
711            payload_kg: 0.0,
712        }));
713        let err = validate_actuator_spec(&actuator).unwrap_err();
714        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
715    }
716
717    #[test]
718    fn test_valid_actuator_state_update() {
719        let update = ActuatorStateUpdate {
720            platform_id: "PORT-GATE-1".to_string(),
721            actuator: Some(valid_barrier_actuator()),
722            status: ActuatorStatus::Operational as i32,
723            timestamp: Some(Timestamp {
724                seconds: 1702000000,
725                nanos: 0,
726            }),
727        };
728        assert!(validate_actuator_state_update(&update).is_ok());
729    }
730
731    #[test]
732    fn test_actuator_update_missing_platform_id() {
733        let update = ActuatorStateUpdate {
734            platform_id: String::new(),
735            actuator: Some(valid_barrier_actuator()),
736            status: ActuatorStatus::Operational as i32,
737            timestamp: Some(Timestamp {
738                seconds: 1702000000,
739                nanos: 0,
740            }),
741        };
742        let err = validate_actuator_state_update(&update).unwrap_err();
743        assert!(matches!(err, ValidationError::MissingField(f) if f == "platform_id"));
744    }
745
746    #[test]
747    fn test_actuator_update_unspecified_status() {
748        let update = ActuatorStateUpdate {
749            platform_id: "PORT-GATE-1".to_string(),
750            actuator: Some(valid_barrier_actuator()),
751            status: ActuatorStatus::Unspecified as i32,
752            timestamp: Some(Timestamp {
753                seconds: 1702000000,
754                nanos: 0,
755            }),
756        };
757        let err = validate_actuator_state_update(&update).unwrap_err();
758        assert!(matches!(err, ValidationError::InvalidValue(_)));
759    }
760
761    #[test]
762    fn test_valid_actuator_command() {
763        let cmd = ActuatorCommand {
764            command_id: "CMD-001".to_string(),
765            platform_id: "PORT-GATE-1".to_string(),
766            actuator_id: "gate-main".to_string(),
767            command_type: ActuatorCommandType::ActuatorCommandDisengage as i32,
768            target_position: 1.0,
769            target_velocity: 0.0,
770            priority: 1,
771            issued_by: "operator-1".to_string(),
772            issued_at: Some(Timestamp {
773                seconds: 1702000000,
774                nanos: 0,
775            }),
776            expires_at: None,
777        };
778        assert!(validate_actuator_command(&cmd).is_ok());
779    }
780
781    #[test]
782    fn test_command_missing_command_id() {
783        let cmd = ActuatorCommand {
784            command_id: String::new(),
785            platform_id: "PORT-GATE-1".to_string(),
786            actuator_id: "gate-main".to_string(),
787            command_type: ActuatorCommandType::ActuatorCommandDisengage as i32,
788            target_position: 1.0,
789            target_velocity: 0.0,
790            priority: 1,
791            issued_by: "operator-1".to_string(),
792            issued_at: Some(Timestamp {
793                seconds: 1702000000,
794                nanos: 0,
795            }),
796            expires_at: None,
797        };
798        let err = validate_actuator_command(&cmd).unwrap_err();
799        assert!(matches!(err, ValidationError::MissingField(f) if f == "command_id"));
800    }
801
802    #[test]
803    fn test_command_expires_before_issued() {
804        let cmd = ActuatorCommand {
805            command_id: "CMD-001".to_string(),
806            platform_id: "PORT-GATE-1".to_string(),
807            actuator_id: "gate-main".to_string(),
808            command_type: ActuatorCommandType::ActuatorCommandDisengage as i32,
809            target_position: 1.0,
810            target_velocity: 0.0,
811            priority: 1,
812            issued_by: "operator-1".to_string(),
813            issued_at: Some(Timestamp {
814                seconds: 1702000000,
815                nanos: 0,
816            }),
817            expires_at: Some(Timestamp {
818                seconds: 1701000000, // Before issued_at
819                nanos: 0,
820            }),
821        };
822        let err = validate_actuator_command(&cmd).unwrap_err();
823        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
824    }
825}