Skip to main content

peat_protocol/models/
node.rs

1//! Node state data structures
2//!
3//! This module defines platform data models with CRDT operations:
4//! - Static capabilities: G-Set (grow-only set) - capabilities can only be added
5//! - Dynamic state: LWW-Register (last-write-wins) - state updates with timestamps
6//! - Fuel counter: PN-Counter (positive-negative counter) - increments/decrements
7
8use crate::models::{Capability, CapabilityExt, HumanMachinePairExt, Operator};
9use crate::traits::Phase;
10use uuid::Uuid;
11
12// Re-export protobuf types
13pub use peat_schema::node::v1::{HealthStatus, NodeConfig, NodeState};
14
15// Extension trait for NodeConfig helper methods
16pub trait NodeConfigExt {
17    /// Create a new node configuration (autonomous, no operator)
18    fn new(platform_type: String) -> Self;
19
20    /// Create a new platform with operator binding
21    fn with_operator(
22        platform_type: String,
23        operator_binding: peat_schema::node::v1::HumanMachinePair,
24    ) -> Self;
25
26    /// Add a capability (G-Set operation - monotonic add only)
27    fn add_capability(&mut self, capability: Capability);
28
29    /// Check if platform has a specific capability type
30    fn has_capability_type(&self, capability_type: crate::models::CapabilityType) -> bool;
31
32    /// Get all capabilities of a specific type
33    fn get_capabilities_by_type(
34        &self,
35        capability_type: crate::models::CapabilityType,
36    ) -> Vec<&Capability>;
37
38    /// Check if platform has an operator binding
39    fn has_operator(&self) -> bool;
40
41    /// Check if platform is human-operated (has at least one operator)
42    fn is_human_operated(&self) -> bool;
43
44    /// Get the primary operator (highest-ranking) if any
45    fn get_primary_operator(&self) -> Option<&Operator>;
46
47    /// Get the operator binding
48    fn get_operator_binding(&self) -> Option<&peat_schema::node::v1::HumanMachinePair>;
49
50    /// Set or update the operator binding
51    fn set_operator_binding(&mut self, binding: Option<peat_schema::node::v1::HumanMachinePair>);
52
53    /// Check if platform is autonomous (no operators)
54    fn is_autonomous(&self) -> bool;
55}
56
57impl NodeConfigExt for NodeConfig {
58    fn new(platform_type: String) -> Self {
59        Self {
60            id: Uuid::new_v4().to_string(),
61            platform_type,
62            capabilities: Vec::new(),
63            comm_range_m: 1000.0,
64            max_speed_mps: 10.0,
65            operator_binding: None,
66            created_at: None,
67        }
68    }
69
70    fn with_operator(
71        platform_type: String,
72        operator_binding: peat_schema::node::v1::HumanMachinePair,
73    ) -> Self {
74        Self {
75            id: Uuid::new_v4().to_string(),
76            platform_type,
77            capabilities: Vec::new(),
78            comm_range_m: 1000.0,
79            max_speed_mps: 10.0,
80            operator_binding: Some(operator_binding),
81            created_at: None,
82        }
83    }
84
85    fn add_capability(&mut self, capability: Capability) {
86        // Check if capability already exists (by ID)
87        if !self.capabilities.iter().any(|c| c.id == capability.id) {
88            self.capabilities.push(capability);
89        }
90    }
91
92    fn has_capability_type(&self, capability_type: crate::models::CapabilityType) -> bool {
93        self.capabilities
94            .iter()
95            .any(|c| c.get_capability_type() == capability_type)
96    }
97
98    fn get_capabilities_by_type(
99        &self,
100        capability_type: crate::models::CapabilityType,
101    ) -> Vec<&Capability> {
102        self.capabilities
103            .iter()
104            .filter(|c| c.get_capability_type() == capability_type)
105            .collect()
106    }
107
108    fn has_operator(&self) -> bool {
109        self.operator_binding.is_some()
110    }
111
112    fn is_human_operated(&self) -> bool {
113        self.operator_binding
114            .as_ref()
115            .map(|binding| !binding.operators.is_empty())
116            .unwrap_or(false)
117    }
118
119    fn get_primary_operator(&self) -> Option<&Operator> {
120        self.operator_binding
121            .as_ref()
122            .and_then(|binding| binding.primary_operator())
123    }
124
125    fn get_operator_binding(&self) -> Option<&peat_schema::node::v1::HumanMachinePair> {
126        self.operator_binding.as_ref()
127    }
128
129    fn set_operator_binding(&mut self, binding: Option<peat_schema::node::v1::HumanMachinePair>) {
130        self.operator_binding = binding;
131    }
132
133    fn is_autonomous(&self) -> bool {
134        !self.is_human_operated()
135    }
136}
137
138// Extension trait for NodeState helper methods
139pub trait NodeStateExt {
140    /// Create a new node state at a given position
141    fn new(position: (f64, f64, f64)) -> Self;
142
143    /// Update the timestamp to current time
144    fn update_timestamp(&mut self);
145
146    /// Get position as tuple (lat, lon, alt)
147    fn get_position(&self) -> (f64, f64, f64);
148
149    /// Update position (LWW-Register operation)
150    fn update_position(&mut self, position: (f64, f64, f64));
151
152    /// Get health status
153    fn get_health(&self) -> HealthStatus;
154
155    /// Update health status (LWW-Register operation)
156    fn update_health(&mut self, health: HealthStatus);
157
158    /// Get phase
159    fn get_phase(&self) -> Phase;
160
161    /// Update phase (LWW-Register operation)
162    fn update_phase(&mut self, phase: Phase);
163
164    /// Assign to a cell (LWW-Register operation)
165    fn assign_cell(&mut self, cell_id: String);
166
167    /// Remove from cell (LWW-Register operation)
168    fn leave_cell(&mut self);
169
170    /// Assign to a zone (LWW-Register operation)
171    fn assign_zone(&mut self, zone_id: String);
172
173    /// Remove from zone (LWW-Register operation)
174    fn leave_zone(&mut self);
175
176    /// Consume fuel (PN-Counter decrement operation)
177    fn consume_fuel(&mut self, minutes: u32);
178
179    /// Replenish fuel (PN-Counter increment operation)
180    fn replenish_fuel(&mut self, minutes: u32);
181
182    /// Check if platform is operational
183    fn is_operational(&self) -> bool;
184
185    /// Check if platform needs refueling (below 25% capacity)
186    fn needs_refuel(&self) -> bool;
187
188    /// Merge with another state (LWW-Register merge)
189    fn merge(&mut self, other: &NodeState);
190}
191
192impl NodeStateExt for NodeState {
193    fn new(position: (f64, f64, f64)) -> Self {
194        use peat_schema::common::v1::Position;
195
196        Self {
197            position: Some(Position {
198                latitude: position.0,
199                longitude: position.1,
200                altitude: position.2,
201            }),
202            fuel_minutes: 120,
203            health: HealthStatus::Nominal as i32,
204            phase: peat_schema::node::v1::Phase::Discovery as i32,
205            cell_id: None,
206            zone_id: None,
207            timestamp: Some(peat_schema::common::v1::Timestamp {
208                seconds: std::time::SystemTime::now()
209                    .duration_since(std::time::UNIX_EPOCH)
210                    .unwrap()
211                    .as_secs(),
212                nanos: 0,
213            }),
214        }
215    }
216
217    fn update_timestamp(&mut self) {
218        self.timestamp = Some(peat_schema::common::v1::Timestamp {
219            seconds: std::time::SystemTime::now()
220                .duration_since(std::time::UNIX_EPOCH)
221                .unwrap()
222                .as_secs(),
223            nanos: 0,
224        });
225    }
226
227    fn get_position(&self) -> (f64, f64, f64) {
228        if let Some(ref pos) = self.position {
229            (pos.latitude, pos.longitude, pos.altitude)
230        } else {
231            (0.0, 0.0, 0.0)
232        }
233    }
234
235    fn update_position(&mut self, position: (f64, f64, f64)) {
236        use peat_schema::common::v1::Position;
237        self.position = Some(Position {
238            latitude: position.0,
239            longitude: position.1,
240            altitude: position.2,
241        });
242        self.update_timestamp();
243    }
244
245    fn get_health(&self) -> HealthStatus {
246        HealthStatus::try_from(self.health).unwrap_or(HealthStatus::Unspecified)
247    }
248
249    fn update_health(&mut self, health: HealthStatus) {
250        self.health = health as i32;
251        self.update_timestamp();
252    }
253
254    fn get_phase(&self) -> Phase {
255        let proto_phase = peat_schema::node::v1::Phase::try_from(self.phase)
256            .unwrap_or(peat_schema::node::v1::Phase::Unspecified);
257        match proto_phase {
258            peat_schema::node::v1::Phase::Discovery => Phase::Discovery,
259            peat_schema::node::v1::Phase::Cell => Phase::Cell,
260            peat_schema::node::v1::Phase::Hierarchy => Phase::Hierarchy,
261            _ => Phase::Discovery,
262        }
263    }
264
265    fn update_phase(&mut self, phase: Phase) {
266        self.phase = phase as i32;
267        self.update_timestamp();
268    }
269
270    fn assign_cell(&mut self, cell_id: String) {
271        self.cell_id = Some(cell_id);
272        self.update_timestamp();
273    }
274
275    fn leave_cell(&mut self) {
276        self.cell_id = None;
277        self.update_timestamp();
278    }
279
280    fn assign_zone(&mut self, zone_id: String) {
281        self.zone_id = Some(zone_id);
282        self.update_timestamp();
283    }
284
285    fn leave_zone(&mut self) {
286        self.zone_id = None;
287        self.update_timestamp();
288    }
289
290    fn consume_fuel(&mut self, minutes: u32) {
291        self.fuel_minutes = self.fuel_minutes.saturating_sub(minutes);
292        self.update_timestamp();
293    }
294
295    fn replenish_fuel(&mut self, minutes: u32) {
296        self.fuel_minutes = self.fuel_minutes.saturating_add(minutes);
297        self.update_timestamp();
298    }
299
300    fn is_operational(&self) -> bool {
301        self.get_health() != HealthStatus::Failed && self.fuel_minutes > 0
302    }
303
304    fn needs_refuel(&self) -> bool {
305        self.fuel_minutes < 30 // Assuming 120 minutes is full capacity
306    }
307
308    fn merge(&mut self, other: &NodeState) {
309        let self_ts = self.timestamp.as_ref().map(|t| t.seconds).unwrap_or(0);
310        let other_ts = other.timestamp.as_ref().map(|t| t.seconds).unwrap_or(0);
311
312        if other_ts > self_ts {
313            self.position = other.position;
314            self.health = other.health;
315            self.phase = other.phase;
316            self.cell_id = other.cell_id.clone();
317            self.zone_id = other.zone_id.clone();
318            self.fuel_minutes = other.fuel_minutes;
319            self.timestamp = other.timestamp;
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::models::{AuthorityLevel, BindingType, CapabilityType, HumanMachinePair};
328
329    #[test]
330    fn test_platform_config_add_capability() {
331        let mut config = NodeConfig::new("UAV".to_string());
332
333        let cap1 = Capability::new(
334            "camera_1".to_string(),
335            "HD Camera".to_string(),
336            CapabilityType::Sensor,
337            0.9,
338        );
339        let cap2 = Capability::new(
340            "gps_1".to_string(),
341            "GPS".to_string(),
342            CapabilityType::Sensor,
343            1.0,
344        );
345
346        config.add_capability(cap1.clone());
347        config.add_capability(cap2);
348
349        assert_eq!(config.capabilities.len(), 2);
350
351        // Try to add duplicate - should not add
352        config.add_capability(cap1);
353        assert_eq!(config.capabilities.len(), 2);
354    }
355
356    #[test]
357    fn test_platform_config_has_capability_type() {
358        let mut config = NodeConfig::new("UAV".to_string());
359        assert!(!config.has_capability_type(CapabilityType::Sensor));
360
361        config.add_capability(Capability::new(
362            "camera".to_string(),
363            "Camera".to_string(),
364            CapabilityType::Sensor,
365            0.9,
366        ));
367
368        assert!(config.has_capability_type(CapabilityType::Sensor));
369        assert!(!config.has_capability_type(CapabilityType::Compute));
370    }
371
372    #[test]
373    fn test_platform_state_lww_operations() {
374        let mut state = NodeState::new((37.7, -122.4, 100.0));
375        let initial_timestamp = state.timestamp.as_ref().map(|t| t.seconds).unwrap_or(0);
376
377        // Update position
378        std::thread::sleep(std::time::Duration::from_secs(1));
379        state.update_position((37.8, -122.5, 150.0));
380        assert!(state.timestamp.as_ref().map(|t| t.seconds).unwrap_or(0) > initial_timestamp);
381        assert_eq!(state.get_position(), (37.8, -122.5, 150.0));
382
383        // Update health
384        state.update_health(HealthStatus::Degraded);
385        assert_eq!(state.get_health(), HealthStatus::Degraded);
386
387        // Update phase
388        state.update_phase(Phase::Cell);
389        assert_eq!(state.get_phase(), Phase::Cell);
390
391        // Cell assignment
392        state.assign_cell("cell_1".to_string());
393        assert_eq!(state.cell_id, Some("cell_1".to_string()));
394
395        state.leave_cell();
396        assert_eq!(state.cell_id, None);
397
398        // Zone assignment
399        state.assign_zone("zone_1".to_string());
400        assert_eq!(state.zone_id, Some("zone_1".to_string()));
401
402        state.leave_zone();
403        assert_eq!(state.zone_id, None);
404    }
405
406    #[test]
407    fn test_platform_state_fuel_counter() {
408        let mut state = NodeState::new((0.0, 0.0, 0.0));
409        assert_eq!(state.fuel_minutes, 120);
410
411        // Consume fuel
412        state.consume_fuel(30);
413        assert_eq!(state.fuel_minutes, 90);
414
415        // Replenish fuel
416        state.replenish_fuel(20);
417        assert_eq!(state.fuel_minutes, 110);
418
419        // Consume more than available
420        state.consume_fuel(200);
421        assert_eq!(state.fuel_minutes, 0);
422
423        // Replenish to max
424        state.replenish_fuel(150);
425        assert_eq!(state.fuel_minutes, 150);
426    }
427
428    #[test]
429    fn test_platform_state_operational_checks() {
430        let mut state = NodeState::new((0.0, 0.0, 0.0));
431        assert!(state.is_operational());
432
433        // No fuel
434        state.consume_fuel(120);
435        assert!(!state.is_operational());
436
437        state.replenish_fuel(50);
438        assert!(state.is_operational());
439
440        // Failed health
441        state.update_health(HealthStatus::Failed);
442        assert!(!state.is_operational());
443    }
444
445    #[test]
446    fn test_platform_state_needs_refuel() {
447        let mut state = NodeState::new((0.0, 0.0, 0.0));
448        assert!(!state.needs_refuel());
449
450        state.consume_fuel(100);
451        assert!(state.needs_refuel());
452    }
453
454    #[test]
455    fn test_platform_state_merge_lww() {
456        let mut state1 = NodeState::new((37.7, -122.4, 100.0));
457        let mut state2 = state1.clone();
458
459        // State2 has a later update
460        std::thread::sleep(std::time::Duration::from_secs(1));
461        state2.update_position((37.8, -122.5, 150.0));
462        state2.update_health(HealthStatus::Degraded);
463
464        // Merge state2 into state1 - state2 wins due to later timestamp
465        state1.merge(&state2);
466
467        assert_eq!(state1.get_position(), (37.8, -122.5, 150.0));
468        assert_eq!(state1.get_health(), HealthStatus::Degraded);
469        assert_eq!(
470            state1.timestamp.as_ref().map(|t| t.seconds),
471            state2.timestamp.as_ref().map(|t| t.seconds)
472        );
473    }
474
475    #[test]
476    fn test_platform_state_merge_older_ignored() {
477        let mut state1 = NodeState::new((37.7, -122.4, 100.0));
478        std::thread::sleep(std::time::Duration::from_secs(1));
479        state1.update_position((37.8, -122.5, 150.0));
480
481        let state2 = NodeState::new((38.0, -123.0, 200.0));
482
483        // Merge older state2 into state1 - state1 should remain unchanged
484        let original_pos = state1.get_position();
485        state1.merge(&state2);
486
487        assert_eq!(state1.get_position(), original_pos);
488    }
489
490    #[test]
491    fn test_platform_config_autonomous() {
492        let config = NodeConfig::new("UAV".to_string());
493
494        assert!(!config.has_operator());
495        assert!(!config.is_human_operated());
496        assert!(config.is_autonomous());
497        assert!(config.get_primary_operator().is_none());
498        assert!(config.get_operator_binding().is_none());
499    }
500
501    #[test]
502    fn test_platform_config_with_operator() {
503        use crate::models::{OperatorExt, OperatorRank};
504
505        let operator = Operator::new(
506            "op_1".to_string(),
507            "SSG Smith".to_string(),
508            OperatorRank::E6,
509            AuthorityLevel::Commander,
510            "11B".to_string(), // Infantry
511        );
512
513        let binding = HumanMachinePair::new(
514            vec![operator],
515            vec!["node_1".to_string()],
516            BindingType::OneToOne,
517        );
518
519        let config = NodeConfig::with_operator("Soldier System".to_string(), binding);
520
521        assert!(config.has_operator());
522        assert!(config.is_human_operated());
523        assert!(!config.is_autonomous());
524
525        let primary = config.get_primary_operator().unwrap();
526        assert_eq!(primary.rank, OperatorRank::E6 as i32);
527        assert_eq!(primary.name, "SSG Smith");
528    }
529
530    #[test]
531    fn test_platform_config_set_operator_binding() {
532        use crate::models::{OperatorExt, OperatorRank};
533
534        let mut config = NodeConfig::new("Robot".to_string());
535        assert!(config.is_autonomous());
536
537        // Add operator binding
538        let operator = Operator::new(
539            "op_1".to_string(),
540            "PFC Jones".to_string(),
541            OperatorRank::E3,
542            AuthorityLevel::Supervisor,
543            "11B".to_string(),
544        );
545
546        let binding = HumanMachinePair::new(
547            vec![operator],
548            vec![config.id.clone()],
549            BindingType::OneToOne,
550        );
551
552        config.set_operator_binding(Some(binding));
553        assert!(config.is_human_operated());
554        assert!(!config.is_autonomous());
555
556        // Remove operator binding
557        config.set_operator_binding(None);
558        assert!(config.is_autonomous());
559    }
560
561    #[test]
562    fn test_platform_config_multiple_operators() {
563        use crate::models::{OperatorExt, OperatorRank};
564
565        // Command vehicle with multiple operators
566        let commander = Operator::new(
567            "op_1".to_string(),
568            "CPT Williams".to_string(),
569            OperatorRank::O3,
570            AuthorityLevel::Commander,
571            "11A".to_string(), // Infantry Officer
572        );
573
574        let nco = Operator::new(
575            "op_2".to_string(),
576            "SFC Davis".to_string(),
577            OperatorRank::E7,
578            AuthorityLevel::Supervisor,
579            "11B".to_string(),
580        );
581
582        let rto = Operator::new(
583            "op_3".to_string(),
584            "SPC Brown".to_string(),
585            OperatorRank::E4,
586            AuthorityLevel::Advisor,
587            "25U".to_string(), // Signal
588        );
589
590        let binding = HumanMachinePair::new(
591            vec![commander, nco, rto],
592            vec!["command_vehicle_1".to_string()],
593            BindingType::ManyToOne,
594        );
595
596        let config = NodeConfig::with_operator("Command Vehicle".to_string(), binding);
597
598        assert!(config.is_human_operated());
599
600        // Primary operator should be highest rank (O3)
601        let primary = config.get_primary_operator().unwrap();
602        assert_eq!(primary.rank, OperatorRank::O3 as i32);
603        assert_eq!(primary.name, "CPT Williams");
604
605        let binding = config.get_operator_binding().unwrap();
606        assert_eq!(binding.operators.len(), 3);
607        assert_eq!(binding.binding_type, BindingType::ManyToOne as i32);
608    }
609
610    #[test]
611    fn test_platform_config_swarm_operator() {
612        use crate::models::{OperatorExt, OperatorRank};
613
614        // One operator controlling multiple platforms
615        let operator = Operator::new(
616            "op_1".to_string(),
617            "SSG Martinez".to_string(),
618            OperatorRank::E6,
619            AuthorityLevel::Supervisor,
620            "11B".to_string(),
621        );
622
623        let platform_ids = vec![
624            "robot_1".to_string(),
625            "robot_2".to_string(),
626            "robot_3".to_string(),
627            "robot_4".to_string(),
628        ];
629
630        let binding =
631            HumanMachinePair::new(vec![operator], platform_ids.clone(), BindingType::OneToMany);
632
633        let config = NodeConfig::with_operator("Swarm Control Station".to_string(), binding);
634
635        assert!(config.is_human_operated());
636
637        let binding = config.get_operator_binding().unwrap();
638        assert_eq!(binding.binding_type, BindingType::OneToMany as i32);
639        assert_eq!(binding.platform_ids.len(), 4);
640        assert_eq!(binding.operators.len(), 1);
641    }
642
643    #[test]
644    fn test_node_config_get_capabilities_by_type_multiple() {
645        let mut config = NodeConfig::new("Multi-sensor platform".to_string());
646
647        // Add multiple sensors
648        for i in 1..=3 {
649            config.add_capability(Capability::new(
650                format!("sensor_{}", i),
651                format!("Sensor {}", i),
652                CapabilityType::Sensor,
653                0.9,
654            ));
655        }
656
657        // Add compute capability
658        config.add_capability(Capability::new(
659            "compute_1".to_string(),
660            "Edge Compute".to_string(),
661            CapabilityType::Compute,
662            0.8,
663        ));
664
665        let sensors = config.get_capabilities_by_type(CapabilityType::Sensor);
666        assert_eq!(sensors.len(), 3);
667
668        let compute = config.get_capabilities_by_type(CapabilityType::Compute);
669        assert_eq!(compute.len(), 1);
670
671        let mobility = config.get_capabilities_by_type(CapabilityType::Mobility);
672        assert_eq!(mobility.len(), 0);
673    }
674
675    #[test]
676    fn test_node_state_health_transitions() {
677        let mut state = NodeState::new((0.0, 0.0, 0.0));
678
679        // Test all health status transitions
680        for health in [
681            HealthStatus::Nominal,
682            HealthStatus::Degraded,
683            HealthStatus::Critical,
684            HealthStatus::Failed,
685        ] {
686            state.update_health(health);
687            assert_eq!(state.get_health(), health);
688        }
689    }
690
691    #[test]
692    fn test_node_state_phase_transitions() {
693        use crate::traits::Phase;
694
695        let mut state = NodeState::new((0.0, 0.0, 0.0));
696
697        // Test all phase transitions
698        for phase in [Phase::Discovery, Phase::Cell, Phase::Hierarchy] {
699            state.update_phase(phase);
700            assert_eq!(state.get_phase(), phase);
701        }
702    }
703
704    #[test]
705    fn test_node_state_fuel_edge_cases() {
706        let mut state = NodeState::new((0.0, 0.0, 0.0));
707
708        // Test saturating add
709        state.replenish_fuel(u32::MAX);
710        assert!(state.fuel_minutes > 0);
711
712        // Consume all fuel
713        state.consume_fuel(u32::MAX);
714        assert_eq!(state.fuel_minutes, 0);
715        assert!(!state.is_operational());
716    }
717
718    #[test]
719    fn test_node_state_position_updates() {
720        let mut state = NodeState::new((37.7, -122.4, 100.0));
721
722        let pos1 = state.get_position();
723        assert_eq!(pos1, (37.7, -122.4, 100.0));
724
725        // Update multiple times
726        state.update_position((38.0, -123.0, 200.0));
727        assert_eq!(state.get_position(), (38.0, -123.0, 200.0));
728
729        state.update_position((39.0, -124.0, 300.0));
730        assert_eq!(state.get_position(), (39.0, -124.0, 300.0));
731    }
732
733    #[test]
734    fn test_node_state_merge_with_equal_timestamps() {
735        let mut state1 = NodeState::new((37.7, -122.4, 100.0));
736        let state2 = state1.clone();
737
738        // Same timestamp - state1 should remain unchanged
739        let original_pos = state1.get_position();
740        state1.merge(&state2);
741        assert_eq!(state1.get_position(), original_pos);
742    }
743
744    #[test]
745    fn test_node_state_cell_and_zone_assignments() {
746        let mut state = NodeState::new((0.0, 0.0, 0.0));
747
748        assert!(state.cell_id.is_none());
749        assert!(state.zone_id.is_none());
750
751        // Assign both
752        state.assign_cell("cell_1".to_string());
753        state.assign_zone("zone_1".to_string());
754
755        assert_eq!(state.cell_id, Some("cell_1".to_string()));
756        assert_eq!(state.zone_id, Some("zone_1".to_string()));
757
758        // Leave cell but keep zone
759        state.leave_cell();
760        assert!(state.cell_id.is_none());
761        assert_eq!(state.zone_id, Some("zone_1".to_string()));
762
763        // Leave zone
764        state.leave_zone();
765        assert!(state.zone_id.is_none());
766    }
767
768    #[test]
769    fn test_node_state_needs_refuel_threshold() {
770        let mut state = NodeState::new((0.0, 0.0, 0.0));
771        assert_eq!(state.fuel_minutes, 120);
772        assert!(!state.needs_refuel());
773
774        // At threshold (30)
775        state.consume_fuel(90);
776        assert_eq!(state.fuel_minutes, 30);
777        assert!(!state.needs_refuel());
778
779        // Below threshold
780        state.consume_fuel(1);
781        assert_eq!(state.fuel_minutes, 29);
782        assert!(state.needs_refuel());
783    }
784
785    #[test]
786    fn test_node_config_empty_binding() {
787        // Empty operators but binding exists
788        let binding =
789            HumanMachinePair::new(vec![], vec!["node_1".to_string()], BindingType::Unspecified);
790
791        let config = NodeConfig::with_operator("Test".to_string(), binding);
792
793        // Has binding but no operators
794        assert!(config.has_operator());
795        assert!(!config.is_human_operated());
796        assert!(config.is_autonomous());
797        assert!(config.get_primary_operator().is_none());
798    }
799
800    #[test]
801    fn test_node_state_get_position_no_position() {
802        let mut state = NodeState::new((0.0, 0.0, 0.0));
803        state.position = None;
804
805        // Should return zeros when position is None
806        assert_eq!(state.get_position(), (0.0, 0.0, 0.0));
807    }
808
809    #[test]
810    fn test_node_state_invalid_health_defaults_to_unspecified() {
811        let mut state = NodeState::new((0.0, 0.0, 0.0));
812
813        // Set invalid health value
814        state.health = 999;
815
816        assert_eq!(state.get_health(), HealthStatus::Unspecified);
817    }
818
819    #[test]
820    fn test_node_state_invalid_phase_defaults_to_discovery() {
821        let mut state = NodeState::new((0.0, 0.0, 0.0));
822
823        // Set invalid phase value
824        state.phase = 999;
825
826        assert_eq!(state.get_phase(), crate::traits::Phase::Discovery);
827    }
828}