generated from GarandPLG/rust-flake-template
Add tests, fix offset logic, and bump dependencies
- Introduce a comprehensive test suite (cli, app, board, tasks, audio) and a new TESTING.md document describing test execution. - Correct vertical offset calculation in `BoardState` to use saturating subtraction and ceiling division. - Clamp initial `Offset` values to the provided maximum in `Offset::new`. - Export `load_audio` from the audio thread module and switch to `LoopedDecoder` for proper OGG looping. - Update several crate versions: clap, ratatui, log, rodio, and bitflags.
This commit is contained in:
1 parent
9bc42dc898
commit
fcdbbaf74c
16 files changed
+817
-246
No files matched your search
+161
@@ -0,0 +1,161 @@
|
||||
use clap::Parser;
|
||||
use ratatui::{
|
||||
Terminal,
|
||||
backend::TestBackend,
|
||||
crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
|
||||
layout::Rect,
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use war_in_tunnels::{
|
||||
app::{App, GameStates, View, handle_keybindings, threads::AudioCmd},
|
||||
cli::Cli,
|
||||
};
|
||||
|
||||
fn app() -> (App, Receiver<AudioCmd>) {
|
||||
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
|
||||
let (tx, rx) = channel();
|
||||
let mut app = App::new(args, tx);
|
||||
app.window_area = Rect::new(0, 0, 120, 40);
|
||||
app.states = Some(GameStates::new(&app.args, &app.window_area));
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
fn press(app: &mut App, code: KeyCode) {
|
||||
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_selection_stays_in_bounds_and_opens_each_view() {
|
||||
let (mut app, _) = app();
|
||||
for _ in 0..10 {
|
||||
press(&mut app, KeyCode::Up);
|
||||
}
|
||||
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 1);
|
||||
for view in [View::Skirmish, View::PerkDecks, View::SkillsConfig] {
|
||||
press(&mut app, KeyCode::Char(' '));
|
||||
assert_eq!(app.view, view);
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert_eq!(app.view, View::MainMenu);
|
||||
press(&mut app, KeyCode::Down);
|
||||
}
|
||||
for _ in 0..10 {
|
||||
press(&mut app, KeyCode::Down);
|
||||
}
|
||||
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_keys_work_in_every_view() {
|
||||
for view in [
|
||||
View::MainMenu,
|
||||
View::Skirmish,
|
||||
View::PerkDecks,
|
||||
View::SkillsConfig,
|
||||
] {
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
] {
|
||||
let (mut app, _) = app();
|
||||
app.view = view;
|
||||
handle_keybindings(&mut app, key);
|
||||
assert!(app.exit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_release_does_not_trigger_quit() {
|
||||
let (mut app, _) = app();
|
||||
handle_keybindings(
|
||||
&mut app,
|
||||
KeyEvent::new_with_kind(
|
||||
KeyCode::Char('q'),
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release,
|
||||
),
|
||||
);
|
||||
assert!(!app.exit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_keys_send_commands_without_opening_audio_device() {
|
||||
let (mut app, rx) = app();
|
||||
press(&mut app, KeyCode::Char('m'));
|
||||
press(&mut app, KeyCode::Char('b'));
|
||||
press(&mut app, KeyCode::Char('n'));
|
||||
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::Mute));
|
||||
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeUp));
|
||||
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeDown));
|
||||
drop(rx);
|
||||
press(&mut app, KeyCode::Char('m'));
|
||||
assert!(!app.exit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skirmish_keys_move_focus_toggle_panel_and_limit_zoom() {
|
||||
let (mut app, _) = app();
|
||||
app.view = View::Skirmish;
|
||||
press(&mut app, KeyCode::Right);
|
||||
assert_eq!(
|
||||
app.states_ref()
|
||||
.unwrap()
|
||||
.skirmish
|
||||
.board
|
||||
.get_focused_cell()
|
||||
.get_coords(),
|
||||
(5, 4)
|
||||
);
|
||||
press(&mut app, KeyCode::Tab);
|
||||
assert!(app.states_ref().unwrap().skirmish.side_panel);
|
||||
press(&mut app, KeyCode::Tab);
|
||||
assert!(!app.states_ref().unwrap().skirmish.side_panel);
|
||||
for _ in 0..4 {
|
||||
press(&mut app, KeyCode::Char(','));
|
||||
}
|
||||
assert_eq!(
|
||||
app.states_ref().unwrap().skirmish.board.zoom_level,
|
||||
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedIn
|
||||
);
|
||||
for _ in 0..4 {
|
||||
press(&mut app, KeyCode::Char('.'));
|
||||
}
|
||||
assert_eq!(
|
||||
app.states_ref().unwrap().skirmish.board.zoom_level,
|
||||
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedOut
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_views_render_without_real_terminal() {
|
||||
let (mut app, _) = app();
|
||||
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
|
||||
for view in [
|
||||
View::MainMenu,
|
||||
View::Skirmish,
|
||||
View::PerkDecks,
|
||||
View::SkillsConfig,
|
||||
] {
|
||||
app.view = view;
|
||||
let before = app.states.clone();
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget(&mut app, frame.area()))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
|
||||
assert!(!text.trim().is_empty(), "{view:?}");
|
||||
if matches!(view, View::PerkDecks | View::SkillsConfig) {
|
||||
assert!(text.contains("Work in progress"));
|
||||
}
|
||||
assert_eq!(app.states, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_keys_work_before_states_are_initialized() {
|
||||
let (mut app, _) = app();
|
||||
app.states = None;
|
||||
press(&mut app, KeyCode::Down);
|
||||
press(&mut app, KeyCode::Char('q'));
|
||||
assert!(app.exit);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use rodio::Decoder;
|
||||
use std::{
|
||||
fs::read,
|
||||
io::{BufReader, Cursor},
|
||||
};
|
||||
use war_in_tunnels::app::threads::{SoundrackParts, Soundtrack, load_audio};
|
||||
|
||||
#[test]
|
||||
fn audio_repeats_after_end_of_file() {
|
||||
let data = read("soundtracks/default/calm.ogg").unwrap();
|
||||
let sample_count = Decoder::new(BufReader::new(Cursor::new(data)))
|
||||
.unwrap()
|
||||
.count();
|
||||
assert!(sample_count > 0);
|
||||
|
||||
let mut source = load_audio(&Soundtrack::Default, &SoundrackParts::Calm);
|
||||
assert_eq!(source.by_ref().take(sample_count).count(), sample_count);
|
||||
assert_eq!(source.take(1024).count(), 1024);
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
use ratatui::layout::Rect;
|
||||
use war_in_tunnels::app::states::skirmish_states::{
|
||||
BoardState, FocusedCell, MoveFocusedCell, Offset, Players, ZoomLevel,
|
||||
structures::{Structure, Structures},
|
||||
tasks::Task,
|
||||
units::Unit,
|
||||
};
|
||||
|
||||
fn board() -> BoardState {
|
||||
BoardState::new(&Rect::new(0, 0, 120, 40), 42, 11, ZoomLevel::Default, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_has_opposing_bases_ore_and_two_miners() {
|
||||
let board = board();
|
||||
assert_eq!(board.cells.len(), 11);
|
||||
assert!(board.cells.iter().all(|row| row.len() == 42));
|
||||
for (col, owner, ore_col) in [(2, Players::Player, 1), (39, Players::Enemy, 40)] {
|
||||
let cell = board.get_ref_cell(5, col);
|
||||
assert!(matches!(cell.get_structure(), Structures::Base(_)));
|
||||
assert_eq!(cell.get_structure().get_owner(), owner);
|
||||
let unit = cell.get_ref_option_unit().as_ref().unwrap();
|
||||
assert_eq!(unit.get_coords(), (5, col));
|
||||
assert_eq!(unit.get_owner(), owner);
|
||||
assert!(matches!(
|
||||
board.get_ref_cell(5, ore_col).get_structure(),
|
||||
Structures::Ore(_)
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
board
|
||||
.cells
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|c| c.get_ref_option_unit().is_some())
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(matches!(
|
||||
board.get_ref_cell(0, 0).get_structure(),
|
||||
Structures::Stone(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_cannot_leave_map_even_with_extreme_coordinates() {
|
||||
let mut focus = FocusedCell::new(0, 0, 11, 42);
|
||||
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Up), (0, 0));
|
||||
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Left), (0, 0));
|
||||
focus.set_focused_cell((usize::MAX, usize::MAX));
|
||||
assert_eq!(focus.get_coords(), (10, 41));
|
||||
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Down), (10, 41));
|
||||
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Right), (10, 41));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_remain_within_bounds_when_limits_change() {
|
||||
let mut offset = Offset::new(Some(100), Some(5));
|
||||
assert_eq!(offset.get_value(), 5);
|
||||
offset.next();
|
||||
assert_eq!(offset.get_value(), 5);
|
||||
offset.set_max(2);
|
||||
assert_eq!(offset.get_value(), 2);
|
||||
offset.set_value(usize::MAX);
|
||||
assert_eq!(offset.get_value(), 2);
|
||||
for _ in 0..4 {
|
||||
offset.prev();
|
||||
}
|
||||
assert_eq!(offset.get_value(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirming_path_queues_task_without_including_start_cell() {
|
||||
let mut board = board();
|
||||
board.change_focused_cell(MoveFocusedCell::Left);
|
||||
board.toggle_marking();
|
||||
board.change_focused_cell(MoveFocusedCell::Right);
|
||||
board.change_focused_cell(MoveFocusedCell::Down);
|
||||
board.toggle_marking();
|
||||
let unit = board
|
||||
.get_ref_cell(5, 2)
|
||||
.get_ref_option_unit()
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let tasks = unit.get_ref_tasks();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(
|
||||
tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
|
||||
[(5, 3), (6, 3)]
|
||||
);
|
||||
assert!(!board.marked_cells.marking_cells);
|
||||
assert!(board.get_marked_cells().is_empty());
|
||||
assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_path_clears_marks_without_queuing_task() {
|
||||
let mut board = board();
|
||||
board.change_focused_cell(MoveFocusedCell::Left);
|
||||
board.toggle_marking();
|
||||
board.change_focused_cell(MoveFocusedCell::Right);
|
||||
board.marked_cells.marking_cells = false;
|
||||
board.end_marking_cells(true);
|
||||
assert!(
|
||||
board
|
||||
.get_ref_cell(5, 2)
|
||||
.get_ref_option_unit()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_ref_tasks()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(board.get_marked_cells().is_empty());
|
||||
assert!(board.marked_cells.selected_unit.is_none());
|
||||
assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backtracking_revisited_cell_preserves_earlier_mark() {
|
||||
let mut board = board();
|
||||
board.toggle_marking();
|
||||
board.change_focused_cell(MoveFocusedCell::Right);
|
||||
board.change_focused_cell(MoveFocusedCell::Left);
|
||||
board.pop_marked_cell();
|
||||
assert_eq!(board.get_focused_cell().get_coords(), (5, 4));
|
||||
assert!(board.get_ref_cell(5, 3).get_marked());
|
||||
board.pop_marked_cell();
|
||||
board.pop_marked_cell();
|
||||
assert_eq!(board.get_focused_cell().get_coords(), (5, 3));
|
||||
assert_eq!(board.get_marked_cells().len(), 1);
|
||||
assert!(!board.get_ref_cell(5, 4).get_marked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_viewport_and_zoom_keep_camera_inside_map() {
|
||||
for zoom in [
|
||||
ZoomLevel::ZoomedIn,
|
||||
ZoomLevel::Default,
|
||||
ZoomLevel::ZoomedOut,
|
||||
] {
|
||||
let mut board = BoardState::new(&Rect::new(0, 0, 240, 120), 36, 11, zoom, false);
|
||||
assert_eq!(board.vertical_offset.get_value(), 0);
|
||||
board.horizontal_offset.set_value(usize::MAX);
|
||||
board.change_resize(&Rect::new(0, 0, 500, 120), false);
|
||||
assert_eq!(board.horizontal_offset.get_value(), 0);
|
||||
board.change_zoom(ZoomLevel::ZoomedOut);
|
||||
assert!(board.is_focused_cell_visible());
|
||||
assert_eq!(board.cells.len(), 11);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use clap::{Parser, error::ErrorKind};
|
||||
use std::process::Command;
|
||||
use war_in_tunnels::{app::View, cli::Cli};
|
||||
|
||||
#[test]
|
||||
fn defaults_start_in_menu_without_logging() {
|
||||
let args = Cli::try_parse_from(["game"]).unwrap();
|
||||
assert_eq!(args.view, View::MainMenu);
|
||||
assert_eq!((args.map_width, args.map_height), (42, 11));
|
||||
assert_eq!(args.username, "Player");
|
||||
assert!(!args.log);
|
||||
assert!(!args.disable_audio);
|
||||
assert!(!args.mute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_options_accept_boundaries_and_reject_out_of_range_values() {
|
||||
for (flag, min, max) in [
|
||||
("--map-width", 36, 108),
|
||||
("--map-height", 11, 39),
|
||||
("--starting-wood", 1, 150),
|
||||
("--starting-iron", 1, 150),
|
||||
("--supply-limit", 49, 149),
|
||||
("--skill-points-limit", 90, 690),
|
||||
] {
|
||||
for value in [min, max] {
|
||||
assert!(
|
||||
Cli::try_parse_from(["game", flag, &value.to_string()]).is_ok(),
|
||||
"{flag}={value}"
|
||||
);
|
||||
}
|
||||
for value in [min - 1, max + 1] {
|
||||
assert!(
|
||||
Cli::try_parse_from(["game", flag, &value.to_string()]).is_err(),
|
||||
"{flag}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_modifier_rejects_non_finite_malformed_and_out_of_range_values() {
|
||||
for value in ["0.5", "1", "2.0"] {
|
||||
let args = Cli::try_parse_from(["game", "--xp-modifier", value]).unwrap();
|
||||
assert_eq!(args.xp_modifier, value.parse::<f32>().unwrap());
|
||||
}
|
||||
for value in ["0.49", "2.01", "NaN", "inf", "text"] {
|
||||
assert!(
|
||||
Cli::try_parse_from(["game", "--xp-modifier", value]).is_err(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_options_and_audio_flags_are_parsed() {
|
||||
let args =
|
||||
Cli::try_parse_from(["game", "--view", "skirmish", "--disable-audio", "--mute"]).unwrap();
|
||||
assert_eq!(args.view, View::Skirmish);
|
||||
assert!(args.disable_audio && args.mute);
|
||||
for flag in [
|
||||
"--view",
|
||||
"--game-mode",
|
||||
"--zoom-level",
|
||||
"--perk-deck",
|
||||
"--soundtrack",
|
||||
] {
|
||||
assert_eq!(
|
||||
Cli::try_parse_from(["game", flag, "unknown"])
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::InvalidValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_help_and_version_work_without_terminal_or_audio() {
|
||||
for (flag, expected) in [("--help", "Usage:"), ("--version", "0.1.0")] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels"))
|
||||
.arg(flag)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8(output.stdout).unwrap().contains(expected));
|
||||
}
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels"))
|
||||
.args(["--map-width", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
String::from_utf8(output.stderr)
|
||||
.unwrap()
|
||||
.contains("--map-width")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use war_in_tunnels::app::states::skirmish_states::{
|
||||
Players,
|
||||
tasks::{DiggingTask, Task, Tasks},
|
||||
units::{MinerUnit, Unit},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn digging_progress_accumulates_and_stops_at_completion() {
|
||||
let mut task = DiggingTask::new([(5, 3)].into(), 40.0);
|
||||
assert!(!task.is_finished());
|
||||
task.progress();
|
||||
assert_eq!(task.get_progress(), 40.0);
|
||||
assert!(!task.is_finished());
|
||||
for _ in 0..3 {
|
||||
task.progress();
|
||||
}
|
||||
assert_eq!(task.get_progress(), 100.0);
|
||||
assert!(task.is_finished());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advancing_path_resets_progress_and_handles_empty_path() {
|
||||
let mut task = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
|
||||
task.progress();
|
||||
task.pop_front();
|
||||
assert_eq!(task.current_target(), Some((5, 4)));
|
||||
assert_eq!(task.get_progress(), 0.0);
|
||||
task.pop_front();
|
||||
task.pop_front();
|
||||
assert_eq!(task.current_target(), None);
|
||||
assert!(task.get_path().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miners_keep_tasks_in_submission_order() {
|
||||
let mut miner = MinerUnit::new(Players::Player, (5, 2));
|
||||
for target in [(5, 3), (6, 3)] {
|
||||
miner.set_task(Tasks::Digging(DiggingTask::new([target].into(), 1.0)));
|
||||
}
|
||||
assert_eq!(miner.get_ref_tasks().len(), 2);
|
||||
assert_eq!(
|
||||
*miner
|
||||
.get_mut_tasks()
|
||||
.pop_front()
|
||||
.unwrap()
|
||||
.get_path_front_coords(),
|
||||
(5, 3)
|
||||
);
|
||||
assert_eq!(*miner.get_ref_tasks()[0].get_path_front_coords(), (6, 3));
|
||||
}
|
||||
Reference in new issue
Block a user