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; +}