From 76601e435ead2ef8f27e3ba84b77bd98cd004448 Mon Sep 17 00:00:00 2001 From: mrsqr Date: Fri, 21 Aug 2026 00:23:47 +0100 Subject: [PATCH] feat(ui): present the session record as a carousel Review was the last screen still built the simulator way: six rows, four lap panels and six buttons on a 49 mm panel. It is also the one screen a driver reads while stopped and unhurried, where there is least excuse for cramming. It reuses CarouselScreen rather than imitating it, so Review is not a second visual language for the same interaction, and it inherits readable typography rather than repeating the mistake that made the previous version look empty. Swipe cycles the values, press moves to the next session - what the OLDER button did, and the only gesture left once swipe belongs to the values - and a long press leaves, as it does everywhere else in the menu. Which session is being read stays in the title on every card, so cycling through values never loses it. CarouselEntry gains a caption, because a record value needs two things said and the entry carried one label. The value is large with its name beneath, the arrangement the time roller already uses. The field is appended: the struct is aggregate-initialised at every call site, and a field in the middle silently reassigns positional initialisers. The card list is free of LVGL and built from the summary alone, so what a session is worth reading can be tested without a display; icons are mapped in the router, which is the only part that needs to know about symbols. The recording card appears only when there is something to report - a partial log or a degraded subsystem. A card that always reads "fine" teaches a driver to swipe past the one place a problem would ever be shown. The old dense screen is left in place because the frozen simulator still builds against it, and removing it would break a build kept as a regression guard. Closes #165 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 + Makefile | 18 ++- firmware/components/ui/CMakeLists.txt | 2 +- firmware/components/ui/carousel_screen.cpp | 7 + .../track_timer/ui/carousel_screen.hpp | 6 + .../include/track_timer/ui/review_cards.hpp | 46 ++++++ firmware/components/ui/review_cards.cpp | 137 ++++++++++++++++++ firmware/main/screen_router.cpp | 127 ++++++++++++++++ tests/cpp/test_review_cards.cpp | 133 +++++++++++++++++ 9 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 firmware/components/ui/include/track_timer/ui/review_cards.hpp create mode 100644 firmware/components/ui/review_cards.cpp create mode 100644 tests/cpp/test_review_cards.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 994c049..3dbcb5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ 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 +- Review presents a session as a carousel, one value per screen, in the same visual + language as the configuration menus +- a caption line on carousel entries, for when a label alone does not say what it is - session records kept on the card, so a driver's sessions outlive a power cycle - a finished session leaves a record: duration, overrun and peak G on every axis, shown in Review diff --git a/Makefile b/Makefile index a0dca81..ae4d336 100644 --- a/Makefile +++ b/Makefile @@ -45,15 +45,16 @@ TIME_ROLLER_TEST_BINARY := build/host/time_roller_test SESSION_TRIGGER_TEST_BINARY := build/host/session_trigger_test SUMMARY_STORE_TEST_BINARY := build/host/summary_store_test SUMMARY_FILE_TEST_BINARY := build/host/summary_file_test +REVIEW_CARDS_TEST_BINARY := build/host/review_cards_test GATE_CAPTURE_TEST_BINARY := build/host/gate_capture_test GATE_SESSION_AUTOMATION_TEST_BINARY := build/host/gate_session_automation_test SIMULATOR_BUILD_DIR ?= build/simulator SIMULATOR_IMAGE ?= track-session-timer-simulator:lvgl-9.5.0 CMAKE ?= cmake -.PHONY: check test track-validate uk-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-device-info firmware-clean issue-preview label-preview +.PHONY: check test track-validate uk-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-device-info firmware-clean issue-preview label-preview -check: test track-validate track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test +check: test track-validate track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test test: $(PYTHON) -B -m unittest discover -s tests -p 'test_*.py' @@ -426,6 +427,19 @@ gate-session-automation-test: -o $(GATE_SESSION_AUTOMATION_TEST_BINARY) $(GATE_SESSION_AUTOMATION_TEST_BINARY) +review-cards-test: + mkdir -p build/host + $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ + -Ifirmware/components/domain/include \ + -Ifirmware/components/settings/include \ + -Ifirmware/components/logger/include \ + -Ifirmware/components/ui/include \ + firmware/components/settings/component.cpp \ + firmware/components/logger/formats.cpp \ + firmware/components/ui/review_cards.cpp \ + tests/cpp/test_review_cards.cpp -o $(REVIEW_CARDS_TEST_BINARY) + $(REVIEW_CARDS_TEST_BINARY) + summary-file-test: mkdir -p build/host $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ diff --git a/firmware/components/ui/CMakeLists.txt b/firmware/components/ui/CMakeLists.txt index b9902a5..7147922 100644 --- a/firmware/components/ui/CMakeLists.txt +++ b/firmware/components/ui/CMakeLists.txt @@ -2,7 +2,7 @@ idf_component_register( SRCS "component.cpp" "active_session.cpp" "input.cpp" "shell_navigation.cpp" "device_mode.cpp" "value_picker.cpp" "gesture_input.cpp" "carousel_screen.cpp" "time_roller.cpp" "time_roller_screen.cpp" "trackday_screen.cpp" "timer_font.cpp" "diagnostics.cpp" "diagnostics_screen.cpp" "display_policy.cpp" "foundation.cpp" "gate_capture.cpp" "gate_capture_screen.cpp" "g_meter_screen.cpp" "g_radar_screen.cpp" "imu_meter.cpp" "lvgl_visual_system.cpp" "navigation.cpp" - "presenter.cpp" "ready_screen.cpp" "rest_session.cpp" "session_trigger.cpp" "session_review.cpp" "session_review_screen.cpp" + "presenter.cpp" "ready_screen.cpp" "rest_session.cpp" "review_cards.cpp" "session_trigger.cpp" "session_review.cpp" "session_review_screen.cpp" "settings_editor.cpp" "settings_screen.cpp" "setup_menu_screen.cpp" "track_selection.cpp" "track_selection_screen.cpp" INCLUDE_DIRS "include" diff --git a/firmware/components/ui/carousel_screen.cpp b/firmware/components/ui/carousel_screen.cpp index d116805..b5a4d65 100644 --- a/firmware/components/ui/carousel_screen.cpp +++ b/firmware/components/ui/carousel_screen.cpp @@ -66,6 +66,11 @@ CarouselScreen::CarouselScreen(lv_obj_t* const root, const InputCallback callbac lv_obj_set_style_text_font(label_, &lv_font_montserrat_36, 0); lv_obj_align(label_, LV_ALIGN_CENTER, 0, kLabelOffsetY); + // Under the label, in the roller's arrangement: the value is what you read, and the + // name beneath tells you how to read it. + caption_ = create_label(root_, Typography::body, kMutedRgb); + lv_obj_align(caption_, LV_ALIGN_CENTER, 0, kLabelOffsetY + 44); + hint_ = create_label(root_, Typography::caption, kMutedRgb); lv_label_set_text(hint_, "swipe to change - press to select - swipe down to go back"); lv_obj_align(hint_, LV_ALIGN_BOTTOM_MID, 0, -12); @@ -142,12 +147,14 @@ void CarouselScreen::refresh() noexcept if (count_ == 0) { lv_label_set_text(icon_, ""); lv_label_set_text(label_, ""); + lv_label_set_text(caption_, ""); return; } const auto& entry = entries_[index_]; lv_label_set_text(icon_, entry.icon == nullptr ? "" : entry.icon); lv_obj_set_style_text_color(icon_, lv_color_hex(entry.icon_rgb), 0); lv_label_set_text(label_, entry.label == nullptr ? "" : entry.label); + lv_label_set_text(caption_, entry.caption == nullptr ? "" : entry.caption); // A long list gets a counter; dots stop conveying position once there are too many // to distinguish at a glance. diff --git a/firmware/components/ui/include/track_timer/ui/carousel_screen.hpp b/firmware/components/ui/include/track_timer/ui/carousel_screen.hpp index 706aeec..11ca748 100644 --- a/firmware/components/ui/include/track_timer/ui/carousel_screen.hpp +++ b/firmware/components/ui/include/track_timer/ui/carousel_screen.hpp @@ -21,6 +21,11 @@ struct CarouselEntry { const char* icon{nullptr}; // LV_SYMBOL_* const char* label{nullptr}; std::uint32_t icon_rgb{0xFFFFFF}; // colour-coded by function + // A second line under the label, for when the label alone does not say what it is: a + // record value needs its name, and a choice benefits from a word on what it does. + // Appended, because this struct is aggregate-initialised at every call site and a field + // in the middle silently reassigns positional initialisers. + const char* caption{nullptr}; }; // One choice fills the panel: a large icon, a large label, pressable chevrons and a @@ -53,6 +58,7 @@ class CarouselScreen { lv_obj_t* title_{nullptr}; lv_obj_t* icon_{nullptr}; lv_obj_t* label_{nullptr}; + lv_obj_t* caption_{nullptr}; lv_obj_t* hint_{nullptr}; std::array chevrons_{}; std::array dots_{}; diff --git a/firmware/components/ui/include/track_timer/ui/review_cards.hpp b/firmware/components/ui/include/track_timer/ui/review_cards.hpp new file mode 100644 index 0000000..41be3b4 --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/review_cards.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "track_timer/logger/formats.hpp" + +#include +#include +#include + +namespace track_timer::ui { + +// One value per card, cycled with a swipe, in the arrangement the rest of the device uses. +// Free of LVGL so the list a session produces can be tested without a display: what a +// record is worth reading is a question about the record, not about how it is drawn. +enum class ReviewMetric : std::uint8_t { + duration, + overrun, + peak_total, + longitudinal, + lateral, + vertical, + completion, + integrity, +}; + +inline constexpr std::size_t kReviewCardCapacity = 8; + +struct ReviewCard { + ReviewMetric metric{ReviewMetric::duration}; + // The value, large. Two figures where a metric has two sides, because braking and + // acceleration are read against each other rather than in isolation. + std::array value{}; +}; + +struct ReviewCardList { + std::array cards{}; + std::size_t count{0}; +}; + +[[nodiscard]] ReviewCardList review_cards(const logger::SessionSummaryV1& summary) noexcept; + +// The line under the value, saying what it is. +[[nodiscard]] const char* review_metric_caption(ReviewMetric metric) noexcept; +// Colour-coded by what it measures, as the menus are by what they do. +[[nodiscard]] std::uint32_t review_metric_rgb(ReviewMetric metric) noexcept; + +} // namespace track_timer::ui diff --git a/firmware/components/ui/review_cards.cpp b/firmware/components/ui/review_cards.cpp new file mode 100644 index 0000000..3c3c54a --- /dev/null +++ b/firmware/components/ui/review_cards.cpp @@ -0,0 +1,137 @@ +#include "track_timer/ui/review_cards.hpp" + +#include +#include + +namespace track_timer::ui { +namespace { + +void set_clock(std::array& value, const std::int64_t milliseconds) noexcept +{ + const auto total = std::max(0, milliseconds) / 1'000; + std::snprintf(value.data(), value.size(), "%lld:%02lld", + static_cast(total / 60), static_cast(total % 60)); +} + +void set_pair(std::array& value, const float first, const float second) noexcept +{ + std::snprintf(value.data(), value.size(), "%.2f / %.2f", static_cast(first), + static_cast(second)); +} + +void append(ReviewCardList& list, const ReviewMetric metric) noexcept +{ + if (list.count < list.cards.size()) { + list.cards[list.count].metric = metric; + ++list.count; + } +} + +const char* completion_text(const logger::SessionCompletionReason reason) noexcept +{ + switch (reason) { + case logger::SessionCompletionReason::driver_stop: + return "DRIVER"; + case logger::SessionCompletionReason::pit_entry: + return "PIT ENTRY"; + case logger::SessionCompletionReason::reset_recovery: + return "RECOVERED"; + case logger::SessionCompletionReason::none: + break; + } + return "UNKNOWN"; +} + +} // namespace + +ReviewCardList review_cards(const logger::SessionSummaryV1& summary) noexcept +{ + ReviewCardList list{}; + + append(list, ReviewMetric::duration); + set_clock(list.cards[list.count - 1].value, summary.session_duration_ms); + + append(list, ReviewMetric::overrun); + set_clock(list.cards[list.count - 1].value, summary.session_overrun_ms); + + append(list, ReviewMetric::peak_total); + std::snprintf(list.cards[list.count - 1].value.data(), + list.cards[list.count - 1].value.size(), "%.2f", + static_cast(summary.peaks.total_g)); + + append(list, ReviewMetric::longitudinal); + set_pair(list.cards[list.count - 1].value, summary.peaks.acceleration_g, + summary.peaks.braking_g); + + append(list, ReviewMetric::lateral); + set_pair(list.cards[list.count - 1].value, summary.peaks.left_g, summary.peaks.right_g); + + append(list, ReviewMetric::vertical); + set_pair(list.cards[list.count - 1].value, summary.peaks.up_g, summary.peaks.down_g); + + append(list, ReviewMetric::completion); + std::snprintf(list.cards[list.count - 1].value.data(), + list.cards[list.count - 1].value.size(), "%s", + completion_text(summary.completion_reason)); + + // Only when there is something to say. A card that always reads "fine" teaches the + // driver to swipe past the one place a problem would be reported. + if (summary.integrity == logger::SummaryIntegrity::partial_log || + summary.degraded_subsystems != logger::degraded_none) { + append(list, ReviewMetric::integrity); + const auto* text = summary.integrity == logger::SummaryIntegrity::partial_log + ? "PARTIAL LOG" + : "NO GPS"; + std::snprintf(list.cards[list.count - 1].value.data(), + list.cards[list.count - 1].value.size(), "%s", text); + } + return list; +} + +const char* review_metric_caption(const ReviewMetric metric) noexcept +{ + switch (metric) { + case ReviewMetric::duration: + return "SESSION TIME"; + case ReviewMetric::overrun: + return "OVER RUN"; + case ReviewMetric::peak_total: + return "PEAK G"; + case ReviewMetric::longitudinal: + return "ACCEL / BRAKE G"; + case ReviewMetric::lateral: + return "LEFT / RIGHT G"; + case ReviewMetric::vertical: + return "UP / DOWN G"; + case ReviewMetric::completion: + return "ENDED BY"; + case ReviewMetric::integrity: + return "RECORDING"; + } + return ""; +} + +std::uint32_t review_metric_rgb(const ReviewMetric metric) noexcept +{ + switch (metric) { + case ReviewMetric::duration: + return 0x39B6FF; // azure, as the timer is elsewhere + case ReviewMetric::overrun: + return 0x9A4DFF; // the overrun's own deep purple + case ReviewMetric::peak_total: + return 0xFF3B30; + case ReviewMetric::longitudinal: + return 0x2FD16D; + case ReviewMetric::lateral: + return 0xFFC02E; + case ReviewMetric::vertical: + return 0xFF8A24; + case ReviewMetric::completion: + return 0x7C8899; + case ReviewMetric::integrity: + return 0xFFC02E; + } + return 0xFFFFFF; +} + +} // namespace track_timer::ui diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index 2f96bec..6d02e45 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -24,6 +24,7 @@ #include "track_timer/ui/presenter.hpp" #include "track_timer/ui/ready_screen.hpp" #include "track_timer/logger/file_summary_store.hpp" +#include "track_timer/ui/review_cards.hpp" #include "track_timer/ui/rest_session.hpp" #include "track_timer/ui/session_trigger.hpp" #include "track_timer/ui/session_review.hpp" @@ -156,6 +157,31 @@ constexpr std::uint32_t kRuby = 0xFF8FA3; // per-session logs that #38 will write are a separate thing. constexpr const char* kSummaryPath = "/sdcard/sessions.bin"; +// Icons live here rather than with the card model, which stays free of LVGL so a session's +// card list can be tested without a display. +[[nodiscard]] const char* review_metric_icon(const ui::ReviewMetric metric) noexcept +{ + switch (metric) { + case ui::ReviewMetric::duration: + return LV_SYMBOL_LOOP; + case ui::ReviewMetric::overrun: + return LV_SYMBOL_WARNING; + case ui::ReviewMetric::peak_total: + return LV_SYMBOL_CHARGE; + case ui::ReviewMetric::longitudinal: + return LV_SYMBOL_UP; + case ui::ReviewMetric::lateral: + return LV_SYMBOL_SHUFFLE; + case ui::ReviewMetric::vertical: + return LV_SYMBOL_DOWNLOAD; + case ui::ReviewMetric::completion: + return LV_SYMBOL_OK; + case ui::ReviewMetric::integrity: + return LV_SYMBOL_EYE_OPEN; + } + return LV_SYMBOL_LIST; +} + constexpr ui::CarouselEntry kMenuEntries[] = { {LV_SYMBOL_LIST, "REVIEW", kAzure}, {LV_SYMBOL_POWER, "MODE", kRuby}, @@ -488,6 +514,14 @@ class ScreenRouter { if (!result.accepted || result.current == result.previous) { return; } + if (result.current == ui::Destination::review) { + reviewing_ = true; + review_index_ = 0; + review_controller_.begin(&summaries_); + show_review(); + lv_screen_load(carousel_root_); + return; + } if (result.current == ui::Destination::diagnostics) { diagnostics_controller_.update(device_snapshot()); diagnostics_->update(diagnostics_controller_.view_model()); @@ -815,6 +849,89 @@ class ScreenRouter { refresh_ready(); } + // The record as a carousel, one value per screen, in the same visual language as the + // menus. A session read while stopped has no reason to be crammed into rows. + void show_review() noexcept + { + const auto& view = review_controller_.view_model(); + review_cards_ = {}; + + logger::SessionSummaryV1 summary{}; + const auto readable = + (view.status == ui::SessionReviewStatus::ready || + view.status == ui::SessionReviewStatus::partial_log) && + summaries_.read_summary(view.history_index, summary) == + logger::SummaryReadResult::ready; + + if (!readable) { + // One card saying why, rather than an empty screen the driver has to interpret. + std::snprintf(review_values_[0].data(), review_values_[0].size(), "%s", + view.status == ui::SessionReviewStatus::empty ? "NONE YET" + : "UNREADABLE"); + review_entries_[0] = {LV_SYMBOL_LIST, review_values_[0].data(), kAmber, + "SESSIONS"}; + review_index_ = 0; + carousel_->set_entries(review_entries_.data(), 1); + carousel_->set_title("REVIEW"); + carousel_->set_position(0); + return; + } + + review_cards_ = ui::review_cards(summary); + for (std::size_t index = 0; index < review_cards_.count; ++index) { + const auto& card = review_cards_.cards[index]; + review_values_[index] = card.value; + review_entries_[index] = {review_metric_icon(card.metric), + review_values_[index].data(), + ui::review_metric_rgb(card.metric), + ui::review_metric_caption(card.metric)}; + } + if (review_index_ >= review_cards_.count) { + review_index_ = 0; + } + // Which session is being read stays on every card, so cycling values never loses it. + std::snprintf(review_title_.data(), review_title_.size(), "SESSION %u OF %u", + static_cast(view.history_index + 1), + static_cast(view.session_count)); + carousel_->set_entries(review_entries_.data(), review_cards_.count); + carousel_->set_title(review_title_.data()); + carousel_->set_position(review_index_); + } + + void handle_review_input(const ui::InputAction action) noexcept + { + const auto count = review_cards_.count; + switch (action) { + case ui::InputAction::swipe_left: + if (count > 0) { + review_index_ = (review_index_ + 1) % count; + carousel_->set_position(review_index_); + } + return; + case ui::InputAction::swipe_right: + if (count > 0) { + review_index_ = (review_index_ + count - 1) % count; + carousel_->set_position(review_index_); + } + return; + case ui::InputAction::press: + // What the OLDER button did. Values are the swipe, so the session is the press. + review_controller_.older_session(); + review_index_ = 0; + show_review(); + return; + case ui::InputAction::long_press: + case ui::InputAction::swipe_down: + reviewing_ = false; + shell_.close(); + go(ui::NavigationAction::back); + lv_screen_load(home_screen()); + return; + default: + return; + } + } + void show_menu(const ui::ShellState& state) noexcept { carousel_->set_entries(kMenuEntries, ui::kMenuItemCount); @@ -1096,6 +1213,10 @@ class ScreenRouter { } // While a clock is running the double tap belongs to the session, not the shell, // which refuses to open a menu then anyway. + if (reviewing_) { + handle_review_input(action); + return; + } if (armed_ && action == ui::InputAction::double_tap) { disarm_session(); return; @@ -1291,6 +1412,12 @@ class ScreenRouter { ui::ActiveSessionController active_session_{}; bool session_was_active_{false}; logger::FileSummaryStore summaries_{}; + ui::ReviewCardList review_cards_{}; + std::array, ui::kReviewCardCapacity> review_values_{}; + std::array review_entries_{}; + std::array review_title_{}; + std::size_t review_index_{0}; + bool reviewing_{false}; std::uint32_t session_ordinal_{0}; bool armed_{false}; bool armed_ready_{false}; diff --git a/tests/cpp/test_review_cards.cpp b/tests/cpp/test_review_cards.cpp new file mode 100644 index 0000000..ac0a9dc --- /dev/null +++ b/tests/cpp/test_review_cards.cpp @@ -0,0 +1,133 @@ +#include "track_timer/ui/review_cards.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace track_timer; + +logger::SessionSummaryV1 driven_session() +{ + logger::SessionSummaryV1 summary{}; + summary.record_size_bytes = static_cast(sizeof(summary)); + std::snprintf(summary.session_id.data(), summary.session_id.size(), "S001"); + summary.session_duration_ms = 22 * 60'000 + 34'000; + summary.session_overrun_ms = 2 * 60'000 + 34'000; + summary.completion_reason = logger::SessionCompletionReason::driver_stop; + summary.integrity = logger::SummaryIntegrity::complete; + summary.peaks.acceleration_g = 0.62F; + summary.peaks.braking_g = 1.14F; + summary.peaks.left_g = 0.98F; + summary.peaks.right_g = 1.02F; + summary.peaks.up_g = 0.44F; + summary.peaks.down_g = 0.71F; + summary.peaks.total_g = 1.21F; + return summary; +} + +[[nodiscard]] const ui::ReviewCard* find(const ui::ReviewCardList& list, + const ui::ReviewMetric metric) +{ + for (std::size_t index = 0; index < list.count; ++index) { + if (list.cards[index].metric == metric) { + return &list.cards[index]; + } + } + return nullptr; +} + +// Every value the record holds gets a card, and reads as a value rather than a label. +void every_recorded_value_gets_a_card() +{ + const auto list = ui::review_cards(driven_session()); + assert(list.count >= 7); + assert(list.count <= ui::kReviewCardCapacity); + + assert(std::strcmp(find(list, ui::ReviewMetric::duration)->value.data(), "22:34") == 0); + assert(std::strcmp(find(list, ui::ReviewMetric::overrun)->value.data(), "2:34") == 0); + assert(std::strcmp(find(list, ui::ReviewMetric::peak_total)->value.data(), "1.21") == 0); + // Two sides on one card: braking is read against acceleration, not in isolation. + assert(std::strcmp(find(list, ui::ReviewMetric::longitudinal)->value.data(), + "0.62 / 1.14") == 0); + assert(std::strcmp(find(list, ui::ReviewMetric::lateral)->value.data(), + "0.98 / 1.02") == 0); + assert(std::strcmp(find(list, ui::ReviewMetric::vertical)->value.data(), + "0.44 / 0.71") == 0); + assert(std::strcmp(find(list, ui::ReviewMetric::completion)->value.data(), "DRIVER") == 0); +} + +// Every card has to say what it is, or a number on its own means nothing. +void every_card_is_named_and_coloured() +{ + const auto list = ui::review_cards(driven_session()); + for (std::size_t index = 0; index < list.count; ++index) { + const auto metric = list.cards[index].metric; + assert(std::strlen(ui::review_metric_caption(metric)) > 0); + assert(std::strlen(list.cards[index].value.data()) > 0); + assert(ui::review_metric_rgb(metric) != 0); + } +} + +// A card that always reads "fine" teaches the driver to swipe past the one place a problem +// would be reported, so it only appears when there is something to report. +void the_recording_card_appears_only_when_it_has_something_to_say() +{ + auto clean = driven_session(); + clean.integrity = logger::SummaryIntegrity::complete; + clean.degraded_subsystems = logger::degraded_none; + assert(find(ui::review_cards(clean), ui::ReviewMetric::integrity) == nullptr); + + auto without_gnss = driven_session(); + without_gnss.degraded_subsystems = logger::degraded_gnss; + const auto* card = find(ui::review_cards(without_gnss), ui::ReviewMetric::integrity); + assert(card != nullptr); + assert(std::strcmp(card->value.data(), "NO GPS") == 0); + + auto partial = driven_session(); + partial.integrity = logger::SummaryIntegrity::partial_log; + card = find(ui::review_cards(partial), ui::ReviewMetric::integrity); + assert(card != nullptr); + assert(std::strcmp(card->value.data(), "PARTIAL LOG") == 0); +} + +// A session with nothing in it still has to produce a coherent set of cards rather than +// blanks the driver has to interpret. +void an_empty_session_still_reads_as_zeroes() +{ + logger::SessionSummaryV1 empty{}; + empty.completion_reason = logger::SessionCompletionReason::driver_stop; + const auto list = ui::review_cards(empty); + assert(list.count >= 7); + assert(std::strcmp(find(list, ui::ReviewMetric::duration)->value.data(), "0:00") == 0); + assert(std::strcmp(find(list, ui::ReviewMetric::peak_total)->value.data(), "0.00") == 0); + assert(std::strcmp(find(list, ui::ReviewMetric::vertical)->value.data(), + "0.00 / 0.00") == 0); +} + +// A session over an hour is minutes and seconds like everything else, not a truncated clock. +void a_long_session_keeps_counting_in_minutes() +{ + auto endurance = driven_session(); + endurance.session_duration_ms = 95 * 60'000 + 7'000; + endurance.session_overrun_ms = 0; + const auto list = ui::review_cards(endurance); + assert(std::strcmp(find(list, ui::ReviewMetric::duration)->value.data(), "95:07") == 0); +} + +} // namespace + +int main() +{ + every_recorded_value_gets_a_card(); + every_card_is_named_and_coloured(); + the_recording_card_appears_only_when_it_has_something_to_say(); + an_empty_session_still_reads_as_zeroes(); + a_long_session_keeps_counting_in_minutes(); + + std::cout << "Review cards: one value per card, named and coloured, with the recording " + "card shown only when it has something to report passed\n"; + return 0; +}