Skip to content

Commit

Permalink
Add integration tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Shatur committed Oct 18, 2024
1 parent b70d7ea commit 0045443
Show file tree
Hide file tree
Showing 5 changed files with 538 additions and 0 deletions.
82 changes: 82 additions & 0 deletions tests/accumulation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
mod action_recorder;

use action_recorder::{ActionRecorderPlugin, AppTriggeredExt, RecordedActions};
use bevy::{input::InputPlugin, prelude::*};
use bevy_enhanced_input::prelude::*;

#[test]
fn max_abs() {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
InputPlugin,
EnhancedInputPlugin,
ActionRecorderPlugin,
))
.add_input_context::<Moving>()
.record_action::<MaxAbsMove>();

let entity = app.world_mut().spawn(Moving).id();

app.update();

let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.press(KeyCode::KeyW);
keys.press(KeyCode::KeyS);

app.update();

let recorded = app.world().resource::<RecordedActions>();
assert_eq!(recorded.last::<MaxAbsMove>(entity).value, Vec2::Y.into());
}

#[test]
fn cumulative() {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
InputPlugin,
EnhancedInputPlugin,
ActionRecorderPlugin,
))
.add_input_context::<Moving>()
.record_action::<CumulativeMove>();

let entity = app.world_mut().spawn(Moving).id();

app.update();

let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.press(KeyCode::ArrowUp);
keys.press(KeyCode::ArrowDown);

app.update();

let recorded = app.world().resource::<RecordedActions>();
assert!(
recorded.is_empty::<CumulativeMove>(entity),
"up and down should cancel each other"
);
}

#[derive(Debug, Component)]
struct Moving;

impl InputContext for Moving {
fn context_instance(_world: &World, _entity: Entity) -> ContextInstance {
let mut ctx = ContextInstance::default();

ctx.bind::<MaxAbsMove>().with_wasd();
ctx.bind::<CumulativeMove>().with_arrows();

ctx
}
}

#[derive(Debug, InputAction)]
#[input_action(dim = Axis2D, accumulation = MaxAbs)]
struct MaxAbsMove;

#[derive(Debug, InputAction)]
#[input_action(dim = Axis2D, accumulation = Cumulative)]
struct CumulativeMove;
124 changes: 124 additions & 0 deletions tests/action_recorder/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//! Assert action events in tests.

use std::any::{self, TypeId};

use bevy::{ecs::entity::EntityHashMap, prelude::*, utils::HashMap};
use bevy_enhanced_input::{prelude::*, EnhancedInputSystem};

pub trait AppTriggeredExt {
/// Observes for [`ActionEvent`] and stores them inside [`RecordedActions`].
fn record_action<A: InputAction>(&mut self) -> &mut Self;
}

impl AppTriggeredExt for App {
fn record_action<A: InputAction>(&mut self) -> &mut Self {
self.world_mut()
.resource_mut::<RecordedActions>()
.register::<A>();
self.observe(read::<A>)
}
}

fn read<A: InputAction>(trigger: Trigger<ActionEvent<A>>, mut triggered: ResMut<RecordedActions>) {
triggered.insert::<A>(trigger.entity(), *trigger.event());
}

pub struct ActionRecorderPlugin;

impl Plugin for ActionRecorderPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<RecordedActions>()
.add_systems(PreUpdate, Self::clear.before(EnhancedInputSystem));
}
}

impl ActionRecorderPlugin {
fn clear(mut triggered: ResMut<RecordedActions>) {
triggered.clear();
}
}

#[derive(Default, Resource)]
pub struct RecordedActions(HashMap<TypeId, EntityHashMap<Vec<UntypedActionEvent>>>);

impl RecordedActions {
fn insert<A: InputAction>(&mut self, entity: Entity, event: ActionEvent<A>) {
let event_group = self.0.entry(TypeId::of::<A>()).or_default();
let events = event_group.entry(entity).or_default();
events.push(event.into());
}

#[allow(dead_code)]
pub fn assert_array<A: InputAction, const SIZE: usize>(
&self,
entity: Entity,
) -> [UntypedActionEvent; SIZE] {
let events = self.get::<A>(entity);
events.try_into().unwrap_or_else(|_| {
panic!(
"expected {SIZE} events of type `{}`, but got {}",
events.len(),
any::type_name::<A>()
);
})
}

#[allow(dead_code)]
pub fn is_empty<A: InputAction>(&self, entity: Entity) -> bool {
self.get::<A>(entity).is_empty()
}

#[allow(dead_code)]
pub fn last<A: InputAction>(&self, entity: Entity) -> &UntypedActionEvent {
self.get::<A>(entity).last().unwrap_or_else(|| {
panic!(
"expected at least one action event of type `{}`",
any::type_name::<A>()
)
})
}

#[allow(dead_code)]
fn get<A: InputAction>(&self, entity: Entity) -> &[UntypedActionEvent] {
let event_group = self.0.get(&TypeId::of::<A>()).unwrap_or_else(|| {
panic!(
"action event of type `{}` is not registered",
any::type_name::<A>()
)
});

event_group
.get(&entity)
.map(|events| &events[..])
.unwrap_or(&[])
}

fn register<A: InputAction>(&mut self) {
self.0.insert(TypeId::of::<A>(), Default::default());
}

fn clear(&mut self) {
for event_group in self.0.values_mut() {
event_group.clear();
}
}
}

/// Untyped version of [`ActionEvent`].
#[derive(Clone, Copy)]
#[allow(dead_code)]
pub struct UntypedActionEvent {
pub transition: ActionTransition,
pub value: ActionValue,
pub state: ActionState,
}

impl<A: InputAction> From<ActionEvent<A>> for UntypedActionEvent {
fn from(value: ActionEvent<A>) -> Self {
Self {
transition: value.transition,
value: value.value,
state: value.state,
}
}
}
106 changes: 106 additions & 0 deletions tests/consume_input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
mod action_recorder;

use action_recorder::{ActionRecorderPlugin, AppTriggeredExt, RecordedActions};
use bevy::{input::InputPlugin, prelude::*};
use bevy_enhanced_input::prelude::*;

#[test]
fn passthrough() {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
InputPlugin,
EnhancedInputPlugin,
ActionRecorderPlugin,
))
.add_input_context::<ConsumeThenPassthrough>()
.record_action::<Consume>()
.record_action::<Passthrough>();

let entity = app.world_mut().spawn(ConsumeThenPassthrough).id();

app.update();

app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KEY);

app.update();

let recorded = app.world().resource::<RecordedActions>();
assert_eq!(recorded.last::<Consume>(entity).state, ActionState::Fired);
assert!(
recorded.is_empty::<Passthrough>(entity),
"action should be consumed"
);
}

#[test]
fn consume() {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
InputPlugin,
EnhancedInputPlugin,
ActionRecorderPlugin,
))
.add_input_context::<PassthroughThenConsume>()
.record_action::<Consume>()
.record_action::<Passthrough>();

let entity = app.world_mut().spawn(PassthroughThenConsume).id();

app.update();

app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KEY);

app.update();

let recorded = app.world().resource::<RecordedActions>();
assert_eq!(recorded.last::<Consume>(entity).state, ActionState::Fired);
assert_eq!(
recorded.last::<Passthrough>(entity).state,
ActionState::Fired
);
}

/// A key used by both [`Consume`] and [`Passthrough`] actions.
const KEY: KeyCode = KeyCode::Space;

#[derive(Debug, Component)]
struct PassthroughThenConsume;

impl InputContext for PassthroughThenConsume {
fn context_instance(_world: &World, _entity: Entity) -> ContextInstance {
let mut ctx = ContextInstance::default();

ctx.bind::<Passthrough>().with(KEY);
ctx.bind::<Consume>().with(KEY);

ctx
}
}

#[derive(Debug, Component)]
struct ConsumeThenPassthrough;

impl InputContext for ConsumeThenPassthrough {
fn context_instance(_world: &World, _entity: Entity) -> ContextInstance {
let mut ctx = ContextInstance::default();

ctx.bind::<Consume>().with(KEY);
ctx.bind::<Passthrough>().with(KEY);

ctx
}
}

#[derive(Debug, InputAction)]
#[input_action(dim = Bool, consume_input = true)]
struct Consume;

#[derive(Debug, InputAction)]
#[input_action(dim = Bool, consume_input = false)]
struct Passthrough;
Loading

0 comments on commit 0045443

Please sign in to comment.