From 2f20ae8d6d6354c555fa198ffaf9501a128a3dca Mon Sep 17 00:00:00 2001 From: mrsqr Date: Thu, 20 Aug 2026 14:35:31 +0100 Subject: [PATCH] feat(ui): show the overrun counting up and route the rest period A session that ran out simply stopped. The countdown sat at 00:00 with nothing to say it had ended, which on a track day is the moment a driver most needs telling, and the rest period they had configured never appeared at all. Nothing was missing from the state machine. SessionController already modelled overtime, session_overrun_ms, rest and rest_remaining_ms, with the transitions between them host tested since the rest workflow landed. What was missing was routing: the router reduced six states to one boolean and sent everything that was not running or overtime to the dashboard, so rest was invisible and overtime was indistinguishable from a session still going. The countdown field now turns round and counts up once the session is spent, and the line beneath it stops reporting a lap estimate, which is meaningless when the session is over, and says OVER RUN instead. Rest is drawn on the same screen with the same fixed cells and the same green-through-red ramp, because it is the same kind of thing: a clock the driver is watching run out. The overrun colour is chosen on measured contrast rather than by name. The literal "deep purple" #6A0DAD manages 2.27:1 against the black panel where every other state here sits between 5.9 and 10.5, and a 10 mm numeral at 2.27:1 is hard work in daylight. #9A4DFF reads as a deep saturated purple and lands beside the ramp's red. The overrun clock carries no sign. The field is five fixed cells and a sixth would need 664 px of a 600 px panel; the colour and the caption already say which way it counts. Double tap ends whichever clock is running because this ends a session, and a stray touch in a moving car must not. The first tap is still delivered as a press: holding it back pending a possible second would delay every press by the 400 ms window, and the screens that act on a double tap ignore single presses. confirm_stop lands in review by design, so the double tap carries through to rest programmatically rather than rearranging a tested state machine; a review screen later inserts itself there. Verified on the panel end to end. A 60 second session ran for 60,010 ms, transitioned to overtime by itself at zero, moved to rest on a double tap with 20 minutes on the clock, and returned to ready on a second, with no accidental transitions in the seconds either side of each tap. Closes #142 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++ firmware/components/ui/active_session.cpp | 79 ++++++++++++++++--- firmware/components/ui/gesture_input.cpp | 19 ++++- .../include/track_timer/ui/active_session.hpp | 29 +++++++ .../ui/include/track_timer/ui/input.hpp | 5 ++ .../track_timer/ui/trackday_screen.hpp | 2 +- firmware/components/ui/input.cpp | 2 + firmware/components/ui/shell_navigation.cpp | 2 + firmware/components/ui/trackday_screen.cpp | 35 ++++---- firmware/main/screen_router.cpp | 48 ++++++++++- simulator/CMakeLists.txt | 2 +- tests/cpp/test_active_session.cpp | 72 ++++++++++++++++- 12 files changed, 273 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66adab3..2b4fe0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The project follows Semantic Versioning once the first firmware release is tagge ### Fixed +- a session that ran out simply stopped: the rest period was modelled and tested but never + routed to a screen, and overtime rendered identically to a session still running + - time settings could not reach most of their own range: the average lap picker stopped at 3:00 against a field holding 59:59, and the durations at 60 minutes against 24 hours - saving on the roller used LVGL's 400 ms long press, which committed a value while it was @@ -50,6 +53,11 @@ The project follows Semantic Versioning once the first firmware release is tagge - vertical G alongside the lateral and longitudinal pair, for kerbs and compressions - gyroscope zero-rate offset measured at rest and removed, 4.4 dps on this board - device settings persisted in NVS, so Mode survives a reboot +- overrun timer counting up in deep purple once a session reaches 00:00, with the lap + estimate replaced by OVER RUN +- rest period shown on the running screen, counting down on the same ramp as a session +- double tap to end a session or its overrun for the rest period, and rest for the + dashboard - two-column minutes-and-seconds roller for average lap, session and rest duration, with drag, flick momentum, and a deliberate hold to save - session and rest durations stored as seconds rather than whole minutes diff --git a/firmware/components/ui/active_session.cpp b/firmware/components/ui/active_session.cpp index ef42d60..c355439 100644 --- a/firmware/components/ui/active_session.cpp +++ b/firmware/components/ui/active_session.cpp @@ -34,6 +34,20 @@ void format_delta(std::array& output, const std::int64_t delta_ms, static_cast(absolute_ms % 1'000), suffix); } +void format_running_clock(std::array& output, const std::int64_t value_ms) noexcept +{ + if (value_ms == domain::kUnavailableTime) { + std::snprintf(output.data(), output.size(), "--:--"); + return; + } + const auto absolute_ms = static_cast(std::max(0, value_ms)); + // No sign: the field is five fixed cells wide and a sixth would not fit the panel, and + // the deep purple plus the OVER RUN caption already say the clock is running up. + std::snprintf(output.data(), output.size(), "%02llu:%02llu", + static_cast(absolute_ms / 60'000), + static_cast((absolute_ms / 1'000) % 60)); +} + void format_estimated_laps(TrackdayModeViewModel& model, const std::int64_t remaining_ms, const std::uint16_t average_lap_seconds) noexcept @@ -139,6 +153,24 @@ std::uint32_t urgency_rgb(const SessionUrgency urgency) noexcept return 0xFFFFFF; } +const char* running_phase_caption(const RunningPhase phase) noexcept +{ + switch (phase) { + case RunningPhase::overrun: + return "OVER RUN"; + case RunningPhase::rest: + return "REST"; + case RunningPhase::session: + break; + } + return nullptr; +} + +std::uint32_t trackday_rgb(const RunningPhase phase, const SessionUrgency urgency) noexcept +{ + return phase == RunningPhase::overrun ? kOverrunRgb : urgency_rgb(urgency); +} + const char* session_urgency_name(const SessionUrgency urgency) noexcept { switch (urgency) { @@ -182,15 +214,44 @@ void ActiveSessionController::update(const domain::UiSnapshot& snapshot, if (display.trackday_mode_enabled) { view_.feedback = {}; view_.trackday.visible = true; - view_.trackday.countdown = view_.timing.session_remaining; - format_estimated_laps(view_.trackday, snapshot.session_remaining_ms, - display.average_lap_seconds); - const auto total_ms = - static_cast(display.session_duration_seconds) * 1'000; - view_.trackday.remaining_ratio = - session_remaining_ratio(snapshot.session_remaining_ms, total_ms); - view_.trackday.urgency = - session_urgency(snapshot.session_remaining_ms, total_ms); + view_.trackday.phase = display.phase; + switch (display.phase) { + case RunningPhase::overrun: { + // Counting up, so there is no proportion left to show and the bar is spent. + format_running_clock(view_.trackday.countdown, display.overrun_ms); + format_estimated_laps(view_.trackday, domain::kUnavailableTime, + display.average_lap_seconds); + view_.trackday.remaining_ratio = 0.0F; + view_.trackday.urgency = SessionUrgency::overtime; + break; + } + case RunningPhase::rest: { + // Rest counts down exactly as the session does, against its own duration. + const auto rest_total_ms = + static_cast(display.rest_duration_seconds) * 1'000; + format_running_clock(view_.trackday.countdown, display.rest_remaining_ms); + format_estimated_laps(view_.trackday, domain::kUnavailableTime, + display.average_lap_seconds); + view_.trackday.remaining_ratio = + session_remaining_ratio(display.rest_remaining_ms, rest_total_ms); + view_.trackday.urgency = + session_urgency(display.rest_remaining_ms, rest_total_ms); + break; + } + case RunningPhase::session: + default: { + const auto total_ms = + static_cast(display.session_duration_seconds) * 1'000; + format_running_clock(view_.trackday.countdown, snapshot.session_remaining_ms); + format_estimated_laps(view_.trackday, snapshot.session_remaining_ms, + display.average_lap_seconds); + view_.trackday.remaining_ratio = + session_remaining_ratio(snapshot.session_remaining_ms, total_ms); + view_.trackday.urgency = + session_urgency(snapshot.session_remaining_ms, total_ms); + break; + } + } view_.timing.lap_label.fill('\0'); view_.timing.current_lap.fill('\0'); view_.timing.previous_lap.fill('\0'); diff --git a/firmware/components/ui/gesture_input.cpp b/firmware/components/ui/gesture_input.cpp index c50448c..4395732 100644 --- a/firmware/components/ui/gesture_input.cpp +++ b/firmware/components/ui/gesture_input.cpp @@ -11,6 +11,10 @@ struct Binding { // before LV_EVENT_SHORT_CLICKED, so clearing on release let the click through and // every swipe also fired a spurious press. bool handled_this_touch{false}; + // When the previous short click landed, so a second one inside the window can be + // reported as a double tap. + std::uint32_t last_click_ms{0}; + bool has_last_click{false}; }; // One binding per attached screen. Fixed storage keeps the no-dynamic-allocation rule. @@ -68,8 +72,19 @@ void handle(lv_event_t* event) noexcept ++counters.suppressed; return; } - ++counters.dispatched; - binding->callback(InputAction::press, binding->context); + { + const auto now_ms = lv_tick_get(); + const auto doubled = binding->has_last_click && + now_ms - binding->last_click_ms <= kDoubleTapWindowMs; + // The first tap is still reported. Suppressing it pending a possible second + // would delay every press by the window, and the screens that act on a double + // tap ignore single presses anyway. + binding->has_last_click = !doubled; + binding->last_click_ms = now_ms; + ++counters.dispatched; + binding->callback(doubled ? InputAction::double_tap : InputAction::press, + binding->context); + } break; case LV_EVENT_PRESSED: ++counters.pressed; diff --git a/firmware/components/ui/include/track_timer/ui/active_session.hpp b/firmware/components/ui/include/track_timer/ui/active_session.hpp index b73ac65..cba0543 100644 --- a/firmware/components/ui/include/track_timer/ui/active_session.hpp +++ b/firmware/components/ui/include/track_timer/ui/active_session.hpp @@ -45,6 +45,14 @@ struct StopControlViewModel { bool confirmation_visible{false}; }; +// Which clock the running screen is showing. The session counts down, the overrun counts +// up past zero, and rest counts down again through the configured break. +enum class RunningPhase : std::uint8_t { + session, + overrun, + rest, +}; + struct ActiveSessionDisplayConfig { std::uint16_t average_lap_seconds{0}; bool trackday_mode_enabled{false}; @@ -53,6 +61,13 @@ struct ActiveSessionDisplayConfig { // Needed for the decaying bar and the proportional part of the colour ramp, since // remaining time alone cannot say what fraction of the session is left. std::uint32_t session_duration_seconds{0}; + // Appended for the same reason as the field above. The session snapshot carries these + // separately from the remaining time, and the overrun is a count up rather than a + // negative count down. + RunningPhase phase{RunningPhase::session}; + std::int64_t overrun_ms{0}; + std::int64_t rest_remaining_ms{0}; + std::uint32_t rest_duration_seconds{0}; }; // Urgency bands for the countdown. Named rather than raw colours so the thresholds are @@ -68,6 +83,7 @@ enum class SessionUrgency : std::uint8_t { struct TrackdayModeViewModel { std::array countdown{}; + RunningPhase phase{RunningPhase::session}; std::array estimated_laps{}; // 1.0 at the start of a session falling to 0.0 at its end, for the decaying bar. float remaining_ratio{0.0F}; @@ -84,6 +100,19 @@ struct TrackdayModeViewModel { [[nodiscard]] float session_remaining_ratio(std::int64_t remaining_ms, std::int64_t total_ms) noexcept; [[nodiscard]] std::uint32_t urgency_rgb(SessionUrgency urgency) noexcept; + +// The line under the counter: the lap estimate is meaningless once a session is over, so +// the overrun and rest phases say what the timer is doing instead. Null while running, so +// the caller falls back to the estimate. +[[nodiscard]] const char* running_phase_caption(RunningPhase phase) noexcept; + +// What colour the numerals take. Deep purple for the overrun, unmistakably apart from the +// green-through-red ramp that preceded it; the ramp itself for the two counting-down +// phases. Chosen on measured contrast against the black panel rather than by name: the +// literal "deep purple" #6A0DAD manages 2.27:1, where every other state here sits between +// 5.9 and 10.5, and a 10 mm numeral at 2.27:1 is hard work in daylight. +inline constexpr std::uint32_t kOverrunRgb = 0x9A4DFF; +[[nodiscard]] std::uint32_t trackday_rgb(RunningPhase phase, SessionUrgency urgency) noexcept; [[nodiscard]] const char* session_urgency_name(SessionUrgency urgency) noexcept; struct ActiveSessionViewModel { diff --git a/firmware/components/ui/include/track_timer/ui/input.hpp b/firmware/components/ui/include/track_timer/ui/input.hpp index 0ca93b8..8a49d63 100644 --- a/firmware/components/ui/include/track_timer/ui/input.hpp +++ b/firmware/components/ui/include/track_timer/ui/input.hpp @@ -18,8 +18,13 @@ enum class InputAction : std::uint8_t { swipe_right, // move to the previous item in a carousel swipe_down, // back one level swipe_up, // only meaningful where a screen drives a value with vertical gestures + double_tap, // ends a running session; deliberately not a single touch }; +// Two taps inside this window are one double tap. Long enough to be comfortable with +// gloves, short enough that two unrelated taps do not merge into one. +inline constexpr std::uint32_t kDoubleTapWindowMs = 400; + using InputCallback = void (*)(InputAction action, void* context) noexcept; [[nodiscard]] const char* input_action_name(InputAction action) noexcept; diff --git a/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp b/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp index f9ef01f..e3f4ddf 100644 --- a/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp +++ b/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp @@ -53,7 +53,7 @@ class TrackdayScreen { // which a single auto-sizing label cannot honour: a 1 is narrower than an 8. std::array cells_{}; std::array shown_countdown_{}; - std::array shown_laps_{}; + std::array shown_laps_{}; std::uint32_t shown_colour_{0}; std::int32_t shown_bar_width_{-1}; }; diff --git a/firmware/components/ui/input.cpp b/firmware/components/ui/input.cpp index 7fc430a..4cd473f 100644 --- a/firmware/components/ui/input.cpp +++ b/firmware/components/ui/input.cpp @@ -19,6 +19,8 @@ const char* input_action_name(const InputAction action) noexcept return "swipe-down"; case InputAction::swipe_up: return "swipe-up"; + case InputAction::double_tap: + return "double-tap"; } return "unknown"; } diff --git a/firmware/components/ui/shell_navigation.cpp b/firmware/components/ui/shell_navigation.cpp index a113833..3c444dd 100644 --- a/firmware/components/ui/shell_navigation.cpp +++ b/firmware/components/ui/shell_navigation.cpp @@ -195,6 +195,8 @@ ShellResult ShellNavigation::dispatch(const InputAction action) noexcept case InputAction::swipe_up: // Vertical gestures belong to the screen that draws a value, not to navigation. // The shell has nothing above the current level to move to. + case InputAction::double_tap: + // Owned by the running screen, which ends a session with it. case InputAction::none: break; } diff --git a/firmware/components/ui/trackday_screen.cpp b/firmware/components/ui/trackday_screen.cpp index b42df77..7a2c287 100644 --- a/firmware/components/ui/trackday_screen.cpp +++ b/firmware/components/ui/trackday_screen.cpp @@ -117,7 +117,7 @@ void TrackdayScreen::update(const TrackdayModeViewModel& model) noexcept } } - const auto colour = urgency_rgb(model.urgency); + const auto colour = trackday_rgb(model.phase, model.urgency); if (colour != shown_colour_) { shown_colour_ = colour; for (auto* cell : cells_) { @@ -139,24 +139,29 @@ void TrackdayScreen::update(const TrackdayModeViewModel& model) noexcept } } - if (std::strncmp(shown_laps_.data(), model.estimated_laps.data(), - shown_laps_.size()) == 0 && model.estimate_available) { - return; + // Once the session is over a lap estimate says nothing, so the line under the counter + // reports what the timer is doing instead. + const auto* caption = running_phase_caption(model.phase); + const auto* line = caption != nullptr ? caption + : model.estimate_available ? model.estimated_laps.data() + : "SET AVERAGE LAP FOR ESTIMATE"; + if (std::strncmp(shown_laps_.data(), line, shown_laps_.size()) != 0) { + std::snprintf(shown_laps_.data(), shown_laps_.size(), "%s", line); + lv_label_set_text(laps_label_, line); + lv_obj_set_style_text_color( + laps_label_, + lv_color_hex(caption != nullptr ? colour + : model.estimate_available ? 0xFFFFFF + : kMuted), + 0); } - std::snprintf(shown_laps_.data(), shown_laps_.size(), "%s", - model.estimated_laps.data()); - lv_label_set_text(laps_label_, model.estimate_available - ? model.estimated_laps.data() - : "SET AVERAGE LAP FOR ESTIMATE"); - lv_obj_set_style_text_color( - laps_label_, lv_color_hex(model.estimate_available ? 0xFFFFFF : kMuted), 0); - lv_label_set_text(status_label_, model.urgency == SessionUrgency::overtime - ? "OVERTIME" - : mode_note_); + lv_label_set_text(status_label_, + model.phase == RunningPhase::session ? mode_note_ + : "DOUBLE TAP TO CONTINUE"); lv_obj_set_style_text_color( status_label_, - lv_color_hex(model.urgency == SessionUrgency::overtime ? colour : kMuted), 0); + lv_color_hex(model.phase == RunningPhase::session ? kMuted : colour), 0); } void TrackdayScreen::set_mode_note(const char* const note) noexcept diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index 85bd48f..4c3824c 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -23,6 +23,7 @@ #include "track_timer/ui/navigation.hpp" #include "track_timer/ui/presenter.hpp" #include "track_timer/ui/ready_screen.hpp" +#include "track_timer/ui/rest_session.hpp" #include "track_timer/ui/session_review.hpp" #include "track_timer/ui/session_review_screen.hpp" #include "track_timer/ui/setup_menu_screen.hpp" @@ -208,8 +209,11 @@ class ScreenRouter { const auto now_ms = static_cast(esp_timer_get_time() / 1000); (void)session_.advance(now_ms); const auto snapshot = session_.snapshot(); + // Rest belongs on the running screen too: it is a clock the driver is watching, + // and dropping to the dashboard for it was why the rest period was invisible. const auto active = snapshot.state == session::SessionState::running || - snapshot.state == session::SessionState::overtime; + snapshot.state == session::SessionState::overtime || + snapshot.state == session::SessionState::rest; shell_.synchronize_session(active); if (active != session_was_active_) { @@ -228,6 +232,14 @@ class ScreenRouter { display.average_lap_seconds = settings_.average_lap_seconds; display.trackday_mode_enabled = true; display.session_duration_seconds = settings_.session_duration_seconds; + display.phase = snapshot.state == session::SessionState::overtime + ? ui::RunningPhase::overrun + : snapshot.state == session::SessionState::rest + ? ui::RunningPhase::rest + : ui::RunningPhase::session; + display.overrun_ms = snapshot.session_overrun_ms; + display.rest_remaining_ms = snapshot.rest_remaining_ms; + display.rest_duration_seconds = settings_.rest_duration_seconds; active_session_.update(ui_snapshot, static_cast(now_ms), display); trackday_->update(active_session_.view_model().trackday); @@ -769,6 +781,33 @@ class ScreenRouter { lv_screen_load(home_screen()); } + // A double tap ends whatever clock is running: the session or its overrun give way to + // the rest period, and rest gives way to the dashboard. Two taps rather than one + // because this ends a session, and a stray touch on a moving car must not. + void advance_session_phase() noexcept + { + const auto now_ms = static_cast(esp_timer_get_time() / 1000); + const auto state = session_.snapshot().state; + if (session_.request_stop(now_ms) != session::TransitionResult::accepted) { + return; + } + if (session_.confirm_stop(now_ms) != session::TransitionResult::accepted) { + return; + } + if (state == session::SessionState::running || + state == session::SessionState::overtime) { + // confirm_stop lands in review, which has no screen yet (#142). Carrying + // straight on keeps the tested path intact while the double tap does what it + // says; a review screen later inserts itself here rather than rearranging the + // state machine. + (void)session_.complete_review(now_ms); + } + ESP_LOGI("track_timer", "session phase: %s -> %s", + ui::session_state_name(state), + ui::session_state_name(session_.snapshot().state)); + service_session(); + } + void handle_input(const ui::InputAction action) noexcept { if (roller_active_ && roller_ != nullptr) { @@ -795,6 +834,13 @@ class ScreenRouter { return; } } + // While a clock is running the double tap belongs to the session, not the shell, + // which refuses to open a menu then anyway. + if (session_was_active_ && action == ui::InputAction::double_tap) { + advance_session_phase(); + return; + } + const auto result = shell_.dispatch(action); switch (result.outcome) { case ui::ShellOutcome::menu_opened: diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 325df10..44a2ded 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -324,7 +324,7 @@ if(BUILD_TESTING) set_tests_properties( simulator-active-session-model PROPERTIES - PASS_REGULAR_EXPRESSION "Deterministic lap feedback and hold-confirm stop controls passed" + PASS_REGULAR_EXPRESSION "Deterministic lap feedback, hold-confirm stop controls, and the overrun and rest phases passed" TIMEOUT 20 ) diff --git a/tests/cpp/test_active_session.cpp b/tests/cpp/test_active_session.cpp index 1357e13..5ff73c4 100644 --- a/tests/cpp/test_active_session.cpp +++ b/tests/cpp/test_active_session.cpp @@ -164,6 +164,76 @@ int main() controls.confirm_stop(20'001); assert(!controls.consume_stop_request()); - std::cout << "Deterministic lap feedback and hold-confirm stop controls passed\n"; + // A session that runs out has to say so. The countdown field turns round and counts + // up, and the line that carried a lap estimate reports the state instead, because an + // estimate is meaningless once the session is over. + ui::ActiveSessionController phases; + auto running = active_snapshot(); + running.session_remaining_ms = 4 * 60'000 + 30'000; + ui::ActiveSessionDisplayConfig config{}; + config.average_lap_seconds = 90; + config.trackday_mode_enabled = true; + config.session_duration_seconds = 20 * 60; + config.rest_duration_seconds = 10 * 60; + + phases.update(running, 0, config); + { + const auto& view = phases.view_model().trackday; + assert(view.phase == ui::RunningPhase::session); + assert(std::strcmp(view.countdown.data(), "04:30") == 0); + assert(ui::running_phase_caption(view.phase) == nullptr); // the estimate shows + assert(view.estimate_available); + assert(ui::trackday_rgb(view.phase, view.urgency) == ui::urgency_rgb(view.urgency)); + } + + config.phase = ui::RunningPhase::overrun; + config.overrun_ms = 95'000; + phases.update(running, 100, config); + { + const auto& view = phases.view_model().trackday; + assert(view.phase == ui::RunningPhase::overrun); + // Counting up, and without a sign: the field is five fixed cells wide. + assert(std::strcmp(view.countdown.data(), "01:35") == 0); + assert(std::strcmp(ui::running_phase_caption(view.phase), "OVER RUN") == 0); + assert(!view.estimate_available); + assert(view.remaining_ratio == 0.0F); + assert(ui::trackday_rgb(view.phase, view.urgency) == ui::kOverrunRgb); + } + + config.overrun_ms = 61 * 60'000 + 7'000; // past an hour, still minutes and seconds + phases.update(running, 200, config); + assert(std::strcmp(phases.view_model().trackday.countdown.data(), "61:07") == 0); + + // Rest counts down exactly as the session does, against its own duration. + config.phase = ui::RunningPhase::rest; + config.rest_remaining_ms = 10 * 60'000; + phases.update(running, 300, config); + { + const auto& view = phases.view_model().trackday; + assert(std::strcmp(view.countdown.data(), "10:00") == 0); + assert(std::strcmp(ui::running_phase_caption(view.phase), "REST") == 0); + assert(view.remaining_ratio > 0.99F); + assert(view.urgency == ui::SessionUrgency::ample); + assert(ui::trackday_rgb(view.phase, view.urgency) == ui::urgency_rgb(view.urgency)); + } + + config.rest_remaining_ms = 30'000; + phases.update(running, 400, config); + { + const auto& view = phases.view_model().trackday; + assert(std::strcmp(view.countdown.data(), "00:30") == 0); + assert(view.remaining_ratio < 0.1F); + assert(view.urgency == ui::SessionUrgency::critical); + } + + // The overrun colour must not be mistakable for any point on the ramp. + for (const auto urgency : {ui::SessionUrgency::ample, ui::SessionUrgency::easing, + ui::SessionUrgency::closing, ui::SessionUrgency::urgent, + ui::SessionUrgency::critical}) { + assert(ui::urgency_rgb(urgency) != ui::kOverrunRgb); + } + + std::cout << "Deterministic lap feedback, hold-confirm stop controls, and the " + "overrun and rest phases passed\n"; return 0; }