Add MovingTask and progress tracking

Introduce a new MovingTask type for movement segments and extend
the Tasks enum
to include it.  Add total‑step tracking and a get_total_progress default
in the
Task trait, and implement the corresponding methods for MovingTask.
Update
units to allow coordinate changes via set_coords, add stone damage
handling,
and adjust board advancement logic to use the new task system.  Update
UI
code to display step and total percentages in the Tab panel.  Expand
documentation (README, AGENTS, CODE_MAP, PROGRESS, TESTING) to describe
digging
and moving, and add extensive tests (including tests/digging.rs) for the
new
behaviour.
This commit is contained in:
GarandPLG committed 2026-10-18 21:21:00 +02:00
1 parent fcdbbaf74c
commit 9e09aaf75e
21 files changed
+1049 -144

No files matched your search

+6 -5
View File
@@ -118,17 +118,18 @@ fn cancelling_path_clears_marks_without_queuing_task() {
#[test]
fn backtracking_revisited_cell_preserves_earlier_mark() {
let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left);
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!(board.get_ref_cell(5, 2).get_marked());
board.pop_marked_cell();
board.pop_marked_cell();
assert_eq!(board.get_focused_cell().get_coords(), (5, 2));
assert_eq!(board.get_marked_cells().len(), 1);
assert!(!board.get_ref_cell(5, 4).get_marked());
assert!(!board.get_ref_cell(5, 3).get_marked());
}
#[test]
+534
View File
@@ -0,0 +1,534 @@
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);
}
+74 -1
View File
@@ -1,6 +1,6 @@
use war_in_tunnels::app::states::skirmish_states::{
Players,
tasks::{DiggingTask, Task, Tasks},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit},
};
@@ -48,3 +48,76 @@ fn miners_keep_tasks_in_submission_order() {
);
assert_eq!(*miner.get_ref_tasks()[0].get_path_front_coords(), (6, 3));
}
#[test]
fn movement_progress_is_separate_from_digging_and_resets_for_next_cell() {
let mut task = MovingTask::new([(5, 3), (5, 4)].into());
assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished());
for _ in 0..46 {
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(50));
for _ in 46..91 {
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(100));
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
task.progress();
assert_eq!(task.get_progress(), 100.0);
task.pop_front();
assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished());
}
#[test]
fn overall_digging_progress_counts_completed_cells_and_current_step() -> () {
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
assert_eq!(task.get_total_progress(), 0.0);
task.progress();
assert_eq!(task.get_progress(), 25.0);
assert_eq!(task.get_total_progress(), 12.5);
task.pop_front();
assert_eq!(task.get_progress(), 0.0);
assert_eq!(task.get_total_progress(), 50.0);
task.progress();
assert_eq!(task.get_total_progress(), 62.5);
task.pop_front();
assert_eq!(task.get_total_progress(), 100.0);
let empty: DiggingTask = DiggingTask::new([].into(), 1.0);
assert_eq!(empty.get_total_progress(), 100.0);
}
#[test]
fn task_conversion_keeps_completed_steps_and_original_length() -> () {
let mut digging: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
digging.pop_front();
let mut moving: MovingTask = MovingTask::from_digging(digging);
assert_eq!(moving.get_total_steps(), 2);
assert_eq!(moving.get_total_progress(), 50.0);
for _ in 0..91 {
moving.progress();
}
assert_eq!(moving.get_total_progress(), 100.0);
let mut digging: DiggingTask = DiggingTask::from_moving(moving, 25.0);
assert_eq!(digging.get_total_steps(), 2);
assert_eq!(digging.get_total_progress(), 50.0);
digging.progress();
assert_eq!(digging.get_total_progress(), 62.5);
}
#[test]
fn digging_queue_formats_step_total_and_pending_progress() -> () {
let mut miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2));
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
task.pop_front();
task.progress();
miner.set_task(Tasks::Digging(task));
miner.set_task(Tasks::Moving(MovingTask::new([(5, 5)].into())));
let lines: Vec<ratatui::text::Line<'_>> = miner.get_tasks_formatted();
assert!(lines[1].to_string().contains("Step: 25% | Total: 62%"));
assert!(lines[3].to_string().contains("Step: 0% | Total: 0%"));
}