Add tab navigation for building and recruitment UI
Nix package / build (push) Successful in 7m59s
Linux package builds (test) / linux (push) Successful in 2m29s
Linux package builds (test) / aur (push) Successful in 7m18s
Rust checks / check (push) Successful in 3m21s

- Add selection indices and option lists to SkirmishState.
- Implement `cycle_popup_option` and expose Left/Right actions in
  keybindings.
- Introduce `cancel_focused_tasks` in BoardState for Delete handling.
- CellWidget now displays recruitment progress on Base structures.
- Update README and documentation to describe new tabs and arrow
  navigation.
- Adjust test counts and add extensive task‑cancellation tests.
This commit is contained in:
GarandPLG committed 2026-10-23 17:53:46 +02:00
1 parent 6c1141a711
commit 110d4c411a
16 files changed
+546 -48

No files matched your search

+7 -1
View File
@@ -354,7 +354,7 @@ fn cancellation_restores_original_ore_and_refunds_cost_exactly_once() -> () {
}
#[test]
fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> () {
fn panel_shows_progress_delete_cancels_and_escape_is_ignored() -> () {
let mut app: App = app();
for _ in 0..2 {
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
@@ -389,6 +389,7 @@ fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> (
assert!(text.contains("Construction: 0%"));
assert!(text.contains("Delete - Cancel construction"));
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.states_ref().unwrap().skirmish.building_view);
assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
Structures::MineConstruction(_)
@@ -397,6 +398,11 @@ fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> (
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
assert!(!app.states_ref().unwrap().skirmish.building_view);
handle_keybindings(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
+34 -2
View File
@@ -77,7 +77,7 @@ fn building_view_has_its_own_help_and_does_not_build_on_invalid_tile() -> () {
assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish),
[Action::Building, Action::Esc, Action::Quit2]
[Action::Building, Action::Left, Action::Right, Action::Quit2]
);
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
@@ -99,7 +99,11 @@ fn building_view_has_its_own_help_and_does_not_build_on_invalid_tile() -> () {
assert!(text.contains("Construction unavailable"));
assert!(text.contains("Close buildings"));
assert!(!text.contains("Marking cells"));
assert!(!text.contains("Esc -"));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
assert_eq!(app.view, View::Skirmish);
for row in &app.states_ref().unwrap().skirmish.board.cells {
@@ -183,7 +187,7 @@ fn construction_and_recruitment_popups_render_semantic_text_colors() -> () {
.unwrap();
let cells: &[Cell] = &terminal.backend().buffer().content;
for (text, color) in [
(name, Color::Yellow),
(name, Color::LightMagenta),
("Cost:", Color::Yellow),
(status, status_color),
] {
@@ -203,3 +207,31 @@ fn construction_and_recruitment_popups_render_semantic_text_colors() -> () {
}
}
}
#[test]
fn popup_tabs_cycle_without_moving_map_or_changing_queues() -> () {
for key in ['b', 'r'] {
let mut app: App = app();
if key == 'r' {
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
}
let before: war_in_tunnels::app::states::skirmish_states::BoardState =
app.states_ref().unwrap().skirmish.board.clone();
press(&mut app, KeyCode::Char(key), KeyModifiers::NONE);
for code in [KeyCode::Left, KeyCode::Right, KeyCode::Right, KeyCode::Left] {
press(&mut app, code, KeyModifiers::NONE);
let state: &war_in_tunnels::app::states::SkirmishState =
&app.states_ref().unwrap().skirmish;
assert_eq!(state.board, before);
assert_eq!(state.selected_building, 0);
assert_eq!(state.selected_recruit, 0);
assert!(available_skirmish_actions(state).contains(&Action::Left));
assert!(available_skirmish_actions(state).contains(&Action::Right));
}
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char(key), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
}
}
+79 -2
View File
@@ -2,9 +2,11 @@ use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Cell,
buffer::{Buffer, Cell},
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
widgets::Widget,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
@@ -13,7 +15,7 @@ use war_in_tunnels::{
states::{
SkirmishState,
skirmish_states::{
MoveFocusedCell, Players, Resources,
CellSizes, MoveFocusedCell, Players, Resources, ZoomLevel,
structures::{BaseBuilding, Structures, Tunnel},
units::{MinerUnit, Unit, Units},
},
@@ -44,6 +46,74 @@ fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () {
handle_keybindings(app, KeyEvent::new(code, modifiers));
}
#[test]
fn base_cell_shows_recruitment_progress_until_spawn_at_every_zoom() -> () {
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let render = |game: &SkirmishState| -> Buffer {
let area: Rect = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer: Buffer = Buffer::empty(area);
game.board.cells[5][2]
.clone()
.set_zoom_level(zoom)
.clone()
.render(area, &mut buffer);
buffer
};
let text = |buffer: &Buffer| -> String {
buffer
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect()
};
assert!(!text(&render(game)).contains('%'));
assert!(game.recruit_miner((5, 2), Players::Player));
assert!(text(&render(game)).contains("0%"));
for _ in 0..BaseBuilding::RECRUITMENT_TICKS / 2 {
game.tick_update();
}
let buffer: Buffer = render(game);
assert!(text(&buffer).contains("50%"));
assert_eq!(
buffer
.content
.iter()
.find(|cell: &&Cell| -> bool { cell.symbol() == "%" })
.unwrap()
.fg,
Color::Cyan
);
if zoom != ZoomLevel::ZoomedOut {
assert!(text(&buffer).contains('M'));
}
for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert!(
text(&render(game)).contains(if zoom == ZoomLevel::ZoomedOut {
"100"
} else {
"100%"
})
);
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update();
assert!(!text(&render(game)).contains('%'));
assert!(game.board.cells[5][2].get_ref_option_unit().is_some());
}
}
#[test]
fn recruitment_is_owned_by_base_and_invalid_requests_do_not_charge() -> () {
let mut app: App = app();
@@ -278,7 +348,14 @@ fn recruitment_panel_queues_with_enter_shows_ready_state_and_closes_without_losi
assert!(text.contains("Current progress: 100%"));
assert!(text.contains("Ready: waiting for free base cell"));
assert!(text.contains("Close recruitment"));
assert!(!text.contains("Esc -"));
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Esc)
);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
+207
View File
@@ -0,0 +1,207 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::skirmish_states::{
BoardState, MoveFocusedCell,
structures::{Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::Unit,
},
threads::AudioCmd,
},
cli::Cli,
};
fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area));
app.states_mut()
.unwrap()
.skirmish
.board
.change_focused_cell(MoveFocusedCell::Left);
app
}
fn press(app: &mut App, code: KeyCode) -> () {
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
}
fn route_color_count(app: &mut App) -> usize {
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(app, frame.area());
})
.unwrap();
terminal
.backend()
.buffer()
.content
.iter()
.filter(|cell: &&Cell| -> bool { matches!(cell.fg, Color::Magenta | Color::LightMagenta) })
.count()
}
#[test]
fn queued_route_colors_follow_progress_and_cancellation_without_marking_model() -> () {
let mut app: App = app();
let baseline: usize = route_color_count(&mut app);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][4].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
let queued: usize = route_color_count(&mut app);
assert!(queued > baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
assert!(!board.cells[5][3].get_marked());
assert!(!board.cells[5][4].get_marked());
for _ in 0..91 {
board.advance_turn();
}
let remaining: usize = route_color_count(&mut app);
assert!(remaining > baseline && remaining < queued);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.change_focused_cell(MoveFocusedCell::Right);
assert!(board.cancel_focused_tasks(true));
assert_eq!(route_color_count(&mut app), baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
for _ in 0..91 {
board.advance_turn();
}
assert_eq!(route_color_count(&mut app), baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][39]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
assert_eq!(route_color_count(&mut app), baseline);
}
#[test]
fn cancelling_last_preserves_active_work_and_delete_stops_without_restoring_stone() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
let power: f32 = board.cells[5][2].get_ref_option_unit().get_digging_power();
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 3)].into(), power)));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 4)].into(), power)));
board.advance_turn();
let stone: Structures = board.cells[5][3].get_structure();
assert!(stone.get_durability() < stone.get_max_durability());
let active: Tasks = board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].clone();
press(&mut app, KeyCode::Delete);
assert_eq!(
app.states_ref().unwrap().skirmish.board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
2
);
press(&mut app, KeyCode::Tab);
press(&mut app, KeyCode::Backspace);
let board: &BoardState = &app.states_ref().unwrap().skirmish.board;
assert_eq!(
board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
1
);
assert_eq!(
board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0],
active
);
press(&mut app, KeyCode::Delete);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.advance_turn();
assert!(
board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.is_empty()
);
assert_eq!(board.cells[5][3].get_structure(), stone);
assert!(!board.cancel_focused_tasks(true));
}
#[test]
fn cancelling_active_movement_keeps_unit_on_origin_and_discards_progress() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
for _ in 0..40 {
board.advance_turn();
}
assert!(board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() > 0.0);
assert!(board.cancel_focused_tasks(false));
for _ in 0..100 {
board.advance_turn();
}
assert!(board.cells[5][2].get_ref_option_unit().is_some());
assert!(board.cells[5][3].get_ref_option_unit().is_none());
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
board.advance_turn();
assert!(board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() < 2.0);
}
#[test]
fn cancellation_rejects_enemy_empty_cells_and_route_drawing() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
press(&mut app, KeyCode::Tab);
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Delete)
);
press(&mut app, KeyCode::Char(' '));
assert!(
!app.states_mut()
.unwrap()
.skirmish
.board
.cancel_focused_tasks(true)
);
press(&mut app, KeyCode::Esc);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
while board.get_focused_cell().get_coords().1 < 39 {
board.change_focused_cell(MoveFocusedCell::Right);
}
board.cells[5][39]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 38)].into())));
assert!(!board.cancel_focused_tasks(true));
board.change_focused_cell(MoveFocusedCell::Up);
assert!(!board.cancel_focused_tasks(true));
}