#[cfg(test)]
#[allow(clippy::module_inception)]
mod tests {
use bevy::prelude::*;
use super::super::{components::*, events::*, plugin::ContagionPlugin, resources::*};
use crate::IssunCorePlugin;
fn create_test_app() -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
IssunCorePlugin,
ContagionPlugin::default().with_seed(42),
));
app
}
fn create_test_app_with_config(config: ContagionConfig) -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
IssunCorePlugin,
ContagionPlugin::default().with_seed(42).with_config(config),
));
app
}
fn setup_basic_network(app: &mut App) -> (Entity, Entity, Entity) {
let node_a = app
.world_mut()
.spawn(ContagionNode::new("node_a", NodeType::City, 10000))
.id();
let node_b = app
.world_mut()
.spawn(ContagionNode::new("node_b", NodeType::City, 8000))
.id();
let edge = app
.world_mut()
.spawn(PropagationEdge::new("edge_ab", node_a, node_b, 1.0))
.id();
{
let mut registry = app.world_mut().resource_mut::<NodeRegistry>();
registry.register("node_a", node_a);
registry.register("node_b", node_b);
}
(node_a, node_b, edge)
}
#[test]
fn test_spawn_contagion() {
let mut app = create_test_app();
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "disease_1".to_string(),
content: ContagionContent::Disease {
severity: DiseaseLevel::Moderate,
location: "node_a".to_string(),
},
origin_node: node_a,
mutation_rate: 0.1,
});
app.update();
let mut query = app.world_mut().query::<&Contagion>();
let contagions = query.iter(app.world()).count();
assert_eq!(contagions, 1, "Should spawn 1 contagion");
let mut query = app.world_mut().query::<&ContagionInfection>();
let infections = query.iter(app.world()).count();
assert_eq!(infections, 1, "Should create initial infection");
let mut query = app.world_mut().query::<&ContagionInfection>();
let infection = query.iter(app.world()).next().unwrap();
assert!(
matches!(infection.state, InfectionState::Incubating { .. }),
"Initial infection should be Incubating"
);
let messages = app.world().resource::<Messages<ContagionSpawnedEvent>>();
let mut cursor = messages.get_cursor();
let spawned_events: Vec<_> = cursor.read(messages).cloned().collect();
assert_eq!(spawned_events.len(), 1, "Should emit ContagionSpawnedEvent");
}
#[test]
fn test_state_progression_turn_based() {
let config = ContagionConfig {
time_mode: TimeMode::TurnBased,
default_incubation_duration: DurationConfig::new(2.0, 0.0),
default_active_duration: DurationConfig::new(3.0, 0.0),
default_immunity_duration: DurationConfig::new(2.0, 0.0),
..Default::default()
};
let mut app = create_test_app_with_config(config);
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "disease_1".to_string(),
content: ContagionContent::Disease {
severity: DiseaseLevel::Moderate,
location: "node_a".to_string(),
},
origin_node: node_a,
mutation_rate: 0.0,
});
app.update();
let get_state = |app: &mut App| -> InfectionState {
let mut query = app.world_mut().query::<&ContagionInfection>();
query.iter(app.world()).next().unwrap().state.clone()
};
assert!(matches!(
get_state(&mut app),
InfectionState::Incubating { .. }
));
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
assert!(matches!(
get_state(&mut app),
InfectionState::Incubating { .. }
));
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
assert!(matches!(get_state(&mut app), InfectionState::Active { .. }));
for _ in 0..2 {
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
}
assert!(matches!(get_state(&mut app), InfectionState::Active { .. }));
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
assert!(matches!(
get_state(&mut app),
InfectionState::Recovered { .. }
));
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
assert!(matches!(
get_state(&mut app),
InfectionState::Recovered { .. }
));
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
assert!(matches!(get_state(&mut app), InfectionState::Plain));
}
#[test]
fn test_state_based_transmission_rates() {
let config = ContagionConfig {
time_mode: TimeMode::TurnBased,
global_propagation_rate: 1.0,
incubation_transmission_rate: 0.2,
active_transmission_rate: 0.8,
recovered_transmission_rate: 0.05,
plain_transmission_rate: 0.0,
default_incubation_duration: DurationConfig::new(1.0, 0.0),
default_active_duration: DurationConfig::new(1.0, 0.0),
default_immunity_duration: DurationConfig::new(1.0, 0.0),
..Default::default()
};
let mut app = create_test_app_with_config(config);
let node_a = app
.world_mut()
.spawn(ContagionNode::new("a", NodeType::City, 1000))
.id();
let node_b = app
.world_mut()
.spawn(ContagionNode::new("b", NodeType::City, 1000))
.id();
let node_c = app
.world_mut()
.spawn(ContagionNode::new("c", NodeType::City, 1000))
.id();
let node_d = app
.world_mut()
.spawn(ContagionNode::new("d", NodeType::City, 1000))
.id();
app.world_mut()
.spawn(PropagationEdge::new("ab", node_a, node_b, 1.0));
app.world_mut()
.spawn(PropagationEdge::new("bc", node_b, node_c, 1.0));
app.world_mut()
.spawn(PropagationEdge::new("cd", node_c, node_d, 1.0));
{
let mut registry = app.world_mut().resource_mut::<NodeRegistry>();
registry.register("a", node_a);
registry.register("b", node_b);
registry.register("c", node_c);
registry.register("d", node_d);
}
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "test".to_string(),
content: ContagionContent::Custom {
key: "test".to_string(),
data: "data".to_string(),
},
origin_node: node_a,
mutation_rate: 0.0,
});
app.update();
let count_infected = |app: &mut App| -> usize {
let mut query = app.world_mut().query::<&ContagionInfection>();
query.iter(app.world()).count()
};
assert_eq!(count_infected(&mut app), 1);
app.world_mut().write_message(PropagationStepRequested);
app.update();
let count_after_incubating = count_infected(&mut app);
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
app.world_mut().write_message(PropagationStepRequested);
app.update();
let count_after_active = count_infected(&mut app);
assert!(
count_after_active >= count_after_incubating,
"Active state should transmit at least as much as Incubating"
);
}
#[test]
fn test_tick_based_time_mode() {
let config = ContagionConfig {
time_mode: TimeMode::TickBased,
default_incubation_duration: DurationConfig::new(2.0, 0.0), default_active_duration: DurationConfig::new(1.0, 0.0), default_immunity_duration: DurationConfig::new(1.0, 0.0), ..Default::default()
};
let mut app = create_test_app_with_config(config);
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "test".to_string(),
content: ContagionContent::Custom {
key: "test".to_string(),
data: "data".to_string(),
},
origin_node: node_a,
mutation_rate: 0.0,
});
app.update();
let infection = {
let mut query = app.world_mut().query::<&ContagionInfection>();
query.iter(app.world()).next().unwrap().clone()
};
assert!(matches!(infection.state, InfectionState::Incubating { .. }));
for _ in 0..120 {
app.update();
}
let infection = {
let mut query = app.world_mut().query::<&ContagionInfection>();
query.iter(app.world()).next().unwrap().clone()
};
assert!(matches!(infection.state, InfectionState::Active { .. }));
}
#[test]
fn test_mutation() {
let config = ContagionConfig {
time_mode: TimeMode::TurnBased,
global_propagation_rate: 1.0,
active_transmission_rate: 1.0,
default_incubation_duration: DurationConfig::new(0.0, 0.0),
default_active_duration: DurationConfig::new(10.0, 0.0),
..Default::default()
};
let mut app = create_test_app_with_config(config);
let (node_a, _node_b, edge) = setup_basic_network(&mut app);
{
let mut edge_mut = app.world_mut().entity_mut(edge);
edge_mut.insert(PropagationEdge {
edge_id: "edge_ab".to_string(),
from_node: node_a,
to_node: _node_b,
transmission_rate: 1.0,
noise_level: 1.0, });
}
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "disease_1".to_string(),
content: ContagionContent::Disease {
severity: DiseaseLevel::Mild,
location: "node_a".to_string(),
},
origin_node: node_a,
mutation_rate: 1.0, });
app.update();
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
for _ in 0..50 {
app.world_mut().write_message(PropagationStepRequested);
app.update();
}
let messages = app.world().resource::<Messages<ContagionSpreadEvent>>();
let mut cursor = messages.get_cursor();
let spread_events: Vec<_> = cursor
.read(messages)
.filter(|e| e.is_mutation)
.cloned()
.collect();
assert!(
!spread_events.is_empty(),
"Should have at least one mutation event"
);
let mutations_with_original = spread_events
.iter()
.filter(|e| e.original_id.is_some())
.count();
assert!(
mutations_with_original > 0,
"Mutations should reference original ID"
);
}
#[test]
fn test_credibility_decay() {
let config = ContagionConfig {
lifetime_turns: 10,
min_credibility: 0.2,
..Default::default()
};
let mut app = create_test_app_with_config(config);
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "rumor_1".to_string(),
content: ContagionContent::Political {
faction: "FactionA".to_string(),
claim: "Test claim".to_string(),
},
origin_node: node_a,
mutation_rate: 0.0,
});
app.update();
let initial_credibility = {
let mut query = app.world_mut().query::<&Contagion>();
query.iter(app.world()).next().unwrap().credibility
};
assert_eq!(initial_credibility, 1.0);
app.world_mut()
.write_message(CredibilityDecayRequested { elapsed_turns: 5 });
app.update();
let after_decay = {
let mut query = app.world_mut().query::<&Contagion>();
query.iter(app.world()).next().unwrap().credibility
};
assert!(after_decay < 1.0, "Credibility should decay");
assert!(after_decay > 0.0, "Credibility should not reach zero yet");
app.world_mut()
.write_message(CredibilityDecayRequested { elapsed_turns: 10 });
app.update();
let contagions = {
let mut query = app.world_mut().query::<&Contagion>();
query.iter(app.world()).count()
};
assert_eq!(
contagions, 0,
"Contagion should be removed after full decay"
);
let messages = app.world().resource::<Messages<ContagionRemovedEvent>>();
let mut cursor = messages.get_cursor();
let removal_events: Vec<_> = cursor.read(messages).cloned().collect();
assert_eq!(removal_events.len(), 1, "Should emit removal event");
}
#[test]
fn test_reinfection_disabled() {
let config = ContagionConfig {
time_mode: TimeMode::TurnBased,
default_incubation_duration: DurationConfig::new(0.0, 0.0),
default_active_duration: DurationConfig::new(0.0, 0.0),
default_immunity_duration: DurationConfig::new(0.0, 0.0),
default_reinfection_enabled: false,
..Default::default()
};
let mut app = create_test_app_with_config(config);
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "disease_1".to_string(),
content: ContagionContent::Disease {
severity: DiseaseLevel::Moderate,
location: "node_a".to_string(),
},
origin_node: node_a,
mutation_rate: 0.0,
});
app.update();
for _ in 0..5 {
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
}
let infection = {
let mut query = app.world_mut().query::<&ContagionInfection>();
query.iter(app.world()).next().unwrap().clone()
};
assert!(matches!(infection.state, InfectionState::Plain));
let contagion = {
let mut query = app.world_mut().query::<&Contagion>();
query.iter(app.world()).next().unwrap().clone()
};
assert!(!contagion.reinfection_enabled);
}
#[test]
fn test_deterministic_rng() {
let run_simulation = || -> Vec<String> {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
IssunCorePlugin,
ContagionPlugin::default().with_seed(12345),
));
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "test".to_string(),
content: ContagionContent::Disease {
severity: DiseaseLevel::Moderate,
location: "node_a".to_string(),
},
origin_node: node_a,
mutation_rate: 0.5,
});
app.update();
for _ in 0..5 {
app.world_mut().write_message(PropagationStepRequested);
app.update();
}
let messages = app.world().resource::<Messages<ContagionSpreadEvent>>();
let mut cursor = messages.get_cursor();
cursor
.read(messages)
.map(|e| e.contagion_id.clone())
.collect()
};
let results1 = run_simulation();
let results2 = run_simulation();
assert_eq!(
results1, results2,
"Same seed should produce identical results"
);
}
#[test]
fn test_entity_deletion_handling() {
let mut app = create_test_app();
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "disease_1".to_string(),
content: ContagionContent::Disease {
severity: DiseaseLevel::Moderate,
location: "node_a".to_string(),
},
origin_node: node_a,
mutation_rate: 0.0,
});
app.update();
let contagion_entity = {
let mut query = app.world_mut().query::<(Entity, &Contagion)>();
query.iter(app.world()).next().unwrap().0
};
app.world_mut().despawn(contagion_entity);
app.world_mut().write_message(PropagationStepRequested);
app.update();
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
}
#[test]
fn test_state_change_events() {
let config = ContagionConfig {
time_mode: TimeMode::TurnBased,
default_incubation_duration: DurationConfig::new(1.0, 0.0),
default_active_duration: DurationConfig::new(1.0, 0.0),
default_immunity_duration: DurationConfig::new(1.0, 0.0),
..Default::default()
};
let mut app = create_test_app_with_config(config);
let (node_a, _node_b, _edge) = setup_basic_network(&mut app);
app.world_mut().write_message(ContagionSpawnRequested {
contagion_id: "disease_1".to_string(),
content: ContagionContent::Disease {
severity: DiseaseLevel::Moderate,
location: "node_a".to_string(),
},
origin_node: node_a,
mutation_rate: 0.0,
});
app.update();
let messages = app
.world()
.resource::<Messages<InfectionStateChangedEvent>>();
let mut cursor = messages.get_cursor();
let _: Vec<_> = cursor.read(messages).cloned().collect();
for _ in 0..3 {
app.world_mut().write_message(TurnAdvancedMessage);
app.update();
}
let messages = app
.world()
.resource::<Messages<InfectionStateChangedEvent>>();
let mut cursor = messages.get_cursor();
let state_changes: Vec<_> = cursor.read(messages).cloned().collect();
assert_eq!(state_changes.len(), 3, "Should emit 3 state change events");
assert_eq!(state_changes[0].old_state, InfectionStateType::Incubating);
assert_eq!(state_changes[0].new_state, InfectionStateType::Active);
assert_eq!(state_changes[1].old_state, InfectionStateType::Active);
assert_eq!(state_changes[1].new_state, InfectionStateType::Recovered);
assert_eq!(state_changes[2].old_state, InfectionStateType::Recovered);
assert_eq!(state_changes[2].new_state, InfectionStateType::Plain);
}
}