test(simulation): QA coverage for Phase 2 verbs, sprint suppression, occupancy, inventory

Add comprehensive test suites across sprint 6 systems:
- Phase 2 observer filter: Confront injection, archetype label relabeling,
  contradiction marking, POI priority ordering (12 tests)
- Sprint anomaly: detection during sprint, queue semantics, delay timing,
  D-055 visible-but-suppressed integration (7 tests)
- Wire format: Confront and CharacterArchetype msgpack roundtrip (4 tests)
- TilePresence: 4-layer coexistence on same tile (1 test)
- Inventory: Take/Place full roundtrip, capacity enforcement (3 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 16:00:32 +01:00
co-authored by Claude Opus 4.6
parent df81b24cd1
commit 3d63a4f9f6
4 changed files with 1280 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+101
View File
@@ -819,4 +819,105 @@ mod tests {
assert!(world.get::<TilePosition>(item).is_some(), "item stays on ground");
assert!(world.get::<CarriedBy>(item).is_none(), "no CarriedBy when full");
}
#[test]
fn take_then_place_roundtrip() {
// D-065: full cycle — item on ground → Take → carried → Place → ground again
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
let item = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Manifest Copy".into()),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Step 1: Take
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
schedule.run(&mut world);
assert!(world.get::<TilePosition>(item).is_none(), "item off ground after Take");
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
assert_eq!(world.get::<InventorySlot>(item).unwrap().0, 0);
// Step 2: Place
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 1,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Place".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
let pos = world.get::<TilePosition>(item).expect("item back on ground after Place");
assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position");
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed after Place");
assert!(world.get::<InventorySlot>(item).is_none(), "InventorySlot removed after Place");
}
#[test]
fn take_without_target_id_is_noop() {
// Edge case: Take verb with no target_entity_id should not panic
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: None,
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
#[test]
fn place_without_target_id_is_noop() {
// Edge case: Place verb with no target_entity_id should not panic
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: None,
verb: Some("Place".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
}
+46
View File
@@ -857,4 +857,50 @@ mod tests {
"two Fixtures on same tile should collide"
);
}
#[test]
fn all_four_layers_coexist_on_same_tile() {
// D-054: Standing + Prone + Seated + Fixture all share one tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 4, 0);
// Fixture and Prone already at tile
world.spawn((target, TilePresence::Fixture));
world.spawn((target, TilePresence::Prone));
// Standing mover enters
let standing = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent { target },
))
.id();
// Seated mover enters from elsewhere
let seated = world
.spawn((
TilePosition::new(5, 3, 0),
TilePresence::Seated,
MoveIntent { target },
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(standing).unwrap(),
target,
"Standing should share tile with Fixture + Prone"
);
assert_eq!(
*world.get::<TilePosition>(seated).unwrap(),
target,
"Seated should share tile with Fixture + Prone + Standing"
);
}
}
+93
View File
@@ -506,3 +506,96 @@ fn all_object_type_variants_roundtrip() {
assert_eq!(decoded, obj_type, "ObjectType::{:?} roundtrip failed", obj_type);
}
}
/// VerbKind::Confront (Phase 2, #422) must survive MessagePack round-trip.
/// Guards against Confront being omitted from serde mapping.
#[test]
fn verb_kind_confront_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Confront,
label: "Confront".into(),
priority: 3,
available: true,
}],
object_type: None,
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions.len(), 1);
assert_eq!(decoded.nearby_interactions[0].verbs[0].kind, VerbKind::Confront);
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
}
/// CharacterArchetype enum round-trips through MessagePack (#422).
/// Used in Phase 2 label relabeling — must survive the wire.
#[test]
fn all_character_archetype_variants_roundtrip() {
let archetypes = [
CharacterArchetype::Smuggler,
CharacterArchetype::Detective,
];
for archetype in archetypes {
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, archetype, "CharacterArchetype::{:?} roundtrip failed", archetype);
}
}
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
/// Guards the contradiction flag survives serialization.
#[test]
fn nearby_interaction_contradicted_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: true,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert!(decoded.nearby_interactions[0].contradicted, "contradicted flag should survive roundtrip");
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]
fn nearby_interaction_object_type_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Object,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Open,
label: "Open".into(),
priority: 1,
available: true,
}],
object_type: Some(ObjectType::Container),
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions[0].object_type, Some(ObjectType::Container));
}