Files
war-in-tunnels/tests/digging.rs
T
GarandPLG 73f544d5d6
Nix package / build (push) Successful in 8m44s
Linux package builds (test) / linux (push) Successful in 2m25s
Linux package builds (test) / aur (push) Successful in 7m41s
Rust checks / check (push) Successful in 3m39s
Update formatting to use 4‑space indentation
- Adjust rustfmt.toml to set tab_spaces = 4.
- Reformat imports, struct definitions, function bodies, and match arms
  across the project to match the new style (aligned fields, added
  spacing, etc.).
- Ensure all code complies with the updated rustfmt settings.
2026-10-19 17:43:43 +02:00

535 lines
19 KiB
Rust

use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
widgets::Widget,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
App, GameStates, View, handle_keybindings,
states::{
SkirmishState,
skirmish_states::{
BoardState, CellSizes, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units},
},
},
threads::{AudioCmd, TICK_MS},
},
cli::Cli,
};
fn game() -> SkirmishState {
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
GameStates::new(&args, &Rect::new(0, 0, 120, 40)).skirmish
}
fn queue(board: &mut BoardState, origin: (usize, usize), path: &[(usize, usize)]) {
let unit = board.cells[origin.0][origin.1]
.get_mut_option_unit()
.as_mut()
.unwrap();
let power = unit.get_digging_power();
unit.set_task(Tasks::Digging(DiggingTask::new(
path.iter().copied().collect(),
power,
)));
}
fn tunnel(board: &mut BoardState, row: usize, col: usize) {
board.cells[row][col].set_structure(Structures::Tunnel(Tunnel::new()));
}
fn unit(board: &BoardState, row: usize, col: usize) -> &Units {
board.cells[row][col]
.get_ref_option_unit()
.as_ref()
.unwrap()
}
fn travel_ticks() -> u16 {
3000_u16.div_ceil(u16::from(TICK_MS))
}
fn advance(game: &mut SkirmishState, ticks: u16) {
for _ in 0..ticks {
game.tick_update();
}
}
#[test]
fn tick_damages_stone_before_moving_and_finishes_entire_route() {
let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
game.tick_update();
assert_eq!(game.turn_counter, 1);
let durability = game.board.cells[5][3].get_structure().get_durability();
assert!(durability < 1000 && durability > 0);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
for _ in 0..500 {
game.tick_update();
}
for col in [3, 4] {
assert!(matches!(
game.board.cells[5][col].get_structure(),
Structures::Tunnel(_)
));
}
assert!(matches!(
game.board.cells[5][2].get_structure(),
Structures::Base(_)
));
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
}
#[test]
fn each_tunnel_step_takes_three_seconds_in_both_directions() {
for path in [[(5, 3), (5, 4)], [(4, 2), (3, 2)]] {
let mut game = game();
for (row, col) in path {
tunnel(&mut game.board, row, col);
}
queue(&mut game.board, (5, 2), &path);
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_ref_tasks()[0]
.get_path()
.len(),
1
);
advance(&mut game, travel_ticks() - 1);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
game.tick_update();
assert!(
unit(&game.board, path[1].0, path[1].1)
.get_ref_tasks()
.is_empty()
);
}
}
#[test]
fn queued_tasks_execute_in_order_and_keep_unit_coordinates_current() {
let mut game = game();
tunnel(&mut game.board, 5, 3);
tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3)]);
queue(&mut game.board, (5, 2), &[(5, 4)]);
advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 3).get_ref_tasks().len(), 1);
advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
queue(&mut game.board, (5, 4), &[(5, 3), (5, 2)]);
advance(&mut game, 2 * travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(matches!(
game.board.cells[5][2].get_structure(),
Structures::Base(_)
));
}
#[test]
fn occupied_tunnel_waits_without_losing_units_or_task() {
let mut game = game();
tunnel(&mut game.board, 5, 3);
*game.board.cells[5][3].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 3))));
queue(&mut game.board, (5, 2), &[(5, 3)]);
for _ in 0..3 {
game.tick_update();
}
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.board.cells[5][3].get_mut_option_unit().take();
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn ore_is_not_destroyed_by_digging_task() {
let mut game = game();
let ore = game.board.cells[5][1].get_structure();
queue(&mut game.board, (5, 2), &[(5, 1)]);
for _ in 0..200 {
game.tick_update();
}
assert_eq!(game.board.cells[5][1].get_structure(), ore);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
}
#[test]
fn invalid_or_empty_routes_do_not_teleport_or_remove_miner() {
for path in [vec![], vec![(usize::MAX, usize::MAX)], vec![(0, 0)]] {
let mut game = game();
queue(&mut game.board, (5, 2), &path);
game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(unit(&game.board, 5, 2).get_ref_tasks().is_empty());
}
}
#[test]
fn only_player_miner_can_start_drawing_route() {
let mut game = game();
game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells);
for _ in 0..36 {
game.board.change_focused_cell(MoveFocusedCell::Right);
}
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 39));
game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells);
assert!(game.board.get_marked_cells().is_empty());
}
#[test]
fn drawing_route_pauses_selected_miner_and_boundary_moves_do_not_repeat_cells() {
let mut game = game();
game.board.change_focused_cell(MoveFocusedCell::Left);
queue(&mut game.board, (5, 2), &[(5, 3)]);
game.board.toggle_marking();
game.tick_update();
assert_eq!(
game.board.cells[5][3].get_structure().get_durability(),
1000
);
for _ in 0..10 {
game.board.change_focused_cell(MoveFocusedCell::Up);
}
let coords: Vec<_> = game.board.get_marked_cells().iter().copied().collect();
assert!(coords.windows(2).all(|pair| pair[0] != pair[1]));
game.board.end_marking_cells(true);
game.tick_update();
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
}
#[test]
fn drawing_another_task_extends_the_end_of_queued_work() {
let mut game = game();
for col in [3, 4, 5] {
tunnel(&mut game.board, 5, col);
}
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 4));
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking();
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 2);
advance(&mut game, 3 * travel_ticks());
assert_eq!(unit(&game.board, 5, 5).get_coords(), (5, 5));
assert!(unit(&game.board, 5, 5).get_ref_tasks().is_empty());
}
#[test]
fn freshly_dug_cell_is_entered_immediately_then_tunnel_travel_takes_three_seconds() {
let mut game = game();
tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
for _ in 0..200 {
game.tick_update();
if matches!(
game.board.cells[5][3].get_structure(),
Structures::Tunnel(_)
) {
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update();
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
return;
}
}
panic!("miner did not finish digging the stone");
}
#[test]
fn miner_symbol_in_tunnel_uses_owner_color_even_when_selected_or_marked() {
for owner in [Players::Player, Players::Enemy] {
for zoom in [ZoomLevel::Default, ZoomLevel::ZoomedIn] {
for (selected, marked) in [(false, false), (true, false), (false, true), (true, true)] {
let mut game = game();
let cell = &mut game.board.cells[5][3];
cell.set_structure(Structures::Tunnel(Tunnel::new()));
cell.set_selected(selected)
.set_marked(marked)
.set_zoom_level(zoom);
*cell.get_mut_option_unit() = Some(Units::Miner(MinerUnit::new(owner, (5, 3))));
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
cell.clone().render(area, &mut buffer);
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, owner.get_color(), "{owner:?} {zoom:?}");
assert!(buffer.content.iter().any(|cell| cell.symbol() == "T"));
}
}
}
}
#[test]
fn tui_shows_movement_percentage_and_clears_it_after_each_step() {
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut game = game();
for col in [2, 3, 4] {
tunnel(&mut game.board, 5, col);
}
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
let render = |board: &BoardState, col: usize| {
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
let mut cell = board.get_ref_cell(5, col).clone();
cell.set_zoom_level(zoom);
cell.render(area, &mut buffer);
buffer
};
let text = |buffer: &Buffer| -> String {
buffer.content.iter().map(|cell| cell.symbol()).collect()
};
assert!(!text(&render(&game.board, 2)).contains('%'));
advance(&mut game, 46);
let buffer = render(&game.board, 2);
assert!(text(&buffer).contains("50%"), "{zoom:?}");
if zoom != ZoomLevel::ZoomedOut {
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, Players::Player.get_color());
}
advance(&mut game, travel_ticks() - 46);
assert!(!text(&render(&game.board, 2)).contains('%'));
assert!(!text(&render(&game.board, 3)).contains('%'));
game.tick_update();
assert!(text(&render(&game.board, 3)).contains("1%"));
advance(&mut game, travel_ticks() - 1);
assert!(!text(&render(&game.board, 4)).contains('%'));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
}
}
#[test]
fn confirmed_route_splits_into_moving_and_digging_segments() {
let mut game = game();
for col in [3, 4, 7] {
tunnel(&mut game.board, 5, col);
}
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
for _ in 0..5 {
game.board.change_focused_cell(MoveFocusedCell::Right);
}
game.board.toggle_marking();
let tasks = unit(&game.board, 5, 2).get_ref_tasks();
assert_eq!(tasks.len(), 3);
assert!(matches!(tasks[0], Tasks::Moving(_)));
assert_eq!(
tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 3), (5, 4)]
);
assert!(matches!(tasks[1], Tasks::Digging(_)));
assert_eq!(
tasks[1].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 5), (5, 6)]
);
assert!(matches!(tasks[2], Tasks::Moving(_)));
assert_eq!(tasks[0].get_icon(), '→');
assert_eq!(tasks[1].get_icon(), '⛏');
advance(&mut game, 2 * travel_ticks());
assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Digging(_)
));
advance(&mut game, 2 * 182);
assert!(matches!(
unit(&game.board, 5, 6).get_ref_tasks()[0],
Tasks::Moving(_)
));
advance(&mut game, travel_ticks());
assert!(unit(&game.board, 5, 7).get_ref_tasks().is_empty());
}
#[test]
fn moving_task_does_not_damage_walkable_structure() {
let mut game = game();
tunnel(&mut game.board, 5, 3);
let structure = game.board.cells[5][3].get_structure();
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
advance(&mut game, travel_ticks());
assert_eq!(game.board.cells[5][3].get_structure(), structure);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn previously_dug_route_becomes_moving_without_instant_steps() {
let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4), (5, 3)]);
advance(&mut game, 2 * 182);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update();
assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Moving(_)
));
advance(&mut game, travel_ticks() - 2);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update();
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn route_reclassifies_as_digging_if_target_is_stone() {
let mut game = game();
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
game.tick_update();
assert!(matches!(
unit(&game.board, 5, 2).get_ref_tasks()[0],
Tasks::Digging(_)
));
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
advance(&mut game, 181);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn tab_panel_shows_current_step_and_total_for_active_and_pending_tasks() -> () {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let channels: (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, channels.0);
app.window_area = Rect::new(0, 0, 120, 60);
app.states = Some(GameStates::new(&app.args, &app.window_area));
let state: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
tunnel(&mut state.board, 5, 3);
tunnel(&mut state.board, 5, 4);
state.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3), (5, 4)].into())));
state.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 5)].into(), 0.55)));
advance(state, travel_ticks() + 46);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 60)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &ratatui::buffer::Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Step: 50% | Total: 75%"));
assert!(text.contains("Step: 0% | Total: 0%"));
}
#[test]
fn space_route_tick_and_tui_show_remaining_durability_at_every_zoom() {
let args = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let (tx, _) = 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));
for code in [
KeyCode::Left,
KeyCode::Char(' '),
KeyCode::Right,
KeyCode::Char(' '),
] {
handle_keybindings(&mut app, KeyEvent::new(code, KeyModifiers::NONE));
}
app.states_mut().unwrap().skirmish.tick_update();
let durability = app.states_ref().unwrap().skirmish.board.cells[5][3]
.get_structure()
.get_durability();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let state = &mut app.states_mut().unwrap().skirmish;
state.board.change_zoom(zoom);
state.board.vertical_offset.set_value(5);
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("99%"), "{zoom:?}");
}
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(&format!("{durability}/1000")));
assert_eq!(app.view, View::Skirmish);
}