From 00c2d43cbdf74445d7545c198a15d3ac6b107a93 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Thu, 23 Jul 2026 15:37:05 +0300 Subject: [PATCH] # tft_app: UX test - dummy parameter --- docs/tft_app/ADDING_SETTING.md | 4 +- firmware/tft_app/src/menu/src/menu_tree.c | 90 ++++++++++-------- .../include/services/settings_store.h | 92 +++++++++---------- .../settings_store/src/settings_codec.c | 4 +- tests/host/tft_app_menu/test_menu_tree.c | 38 +++++++- 5 files changed, 137 insertions(+), 91 deletions(-) diff --git a/docs/tft_app/ADDING_SETTING.md b/docs/tft_app/ADDING_SETTING.md index 3a340eb..ecb7d2b 100644 --- a/docs/tft_app/ADDING_SETTING.md +++ b/docs/tft_app/ADDING_SETTING.md @@ -12,7 +12,7 @@ ```mermaid flowchart TD - START([Новая настройка]) --> Q{Имеет смысл ТОЛЬКО
для одного протокола?
(напр. адрес станции)} + START([Новая настройка]) --> Q{Имеет смысл ТОЛЬКО
для одного протокола?
} Q -->|да| PROTO["Протокольная
→ proto_slice + sul_settings_desc_t
см. §5"] Q -->|нет| USER["Пользовательская/device
→ своё поле в settings_t
см. §4"] ``` @@ -168,7 +168,7 @@ log_set_enabled(g_menu.settings->device.log_enabled != 0U); --- -## 7. Пример целиком — тумблер логов (эталон пользовательской BOOL-настройки) +## 7. Пример — тумблер логов (эталон пользовательской BOOL-настройки) | Шаг | Файл | Что именно | | --- | --- | --- | diff --git a/firmware/tft_app/src/menu/src/menu_tree.c b/firmware/tft_app/src/menu/src/menu_tree.c index c053161..3795de0 100644 --- a/firmware/tft_app/src/menu/src/menu_tree.c +++ b/firmware/tft_app/src/menu/src/menu_tree.c @@ -8,11 +8,14 @@ /* Метки значений (ярус B/устройство). */ static const char *const K_BOOL_LABELS[] = { "Выкл", "Вкл" }; +static const char *const K_DUMMY_LABELS[] = { "Гойда!", "Зрада!" }; /* Верхняя граница на число протоколов в реестре — только размер буфера меток * (menu_tree_refresh_protocol_section), не ограничение самого реестра. * Сейчас 2 (НКУ-CAN, демо), с запасом под Фазу 8 (+ УИМ/SD7/УЭЛ/УКЛ — 6). */ #define MENU_TREE_MAX_PROTOCOLS 8U -static const char *s_proto_labels[MENU_TREE_MAX_PROTOCOLS] = { "НКУ-CAN" }; /* фолбэк до refresh() */ +static const char *g_s_proto_labels[MENU_TREE_MAX_PROTOCOLS] = { + "НКУ-CAN" +}; /* фолбэк до refresh() */ /* Индексы пунктов дерева. */ enum @@ -22,7 +25,9 @@ enum T_PROTO_PARAM, /* единственный параметр АКТИВНОГО протокола (§8) — популируется * из sul_settings_desc_t, см. menu_tree_refresh_protocol_section() */ T_LOG, + T_DUMMY, T_EXIT, + T_COUNT, }; @@ -35,32 +40,39 @@ enum * протокола, не только к его параметрам. Значения ниже — safe-фолбэк на * случай, если refresh() почему-то не вызван (совпадает с тем, что было * до Фазы 3.3, когда протокол был всего один). */ -static menu_item_desc_t s_tree[T_COUNT] = { +static menu_item_desc_t g_s_tree[T_COUNT] = { [T_ROOT] = { .label = "Настройки", - .type = MENU_SUBMENU, - .parent = MENU_ROOT_INDEX, - .first_child = T_PROTO, - .last_child = T_EXIT }, + .type = MENU_SUBMENU, + .parent = MENU_ROOT_INDEX, + .first_child = T_PROTO, + .last_child = T_EXIT }, [T_PROTO] = { .label = "Протокол", - .type = MENU_SELECT, - .value_offset = offsetof(settings_t, device.protocol_id), - .min = 0U, - .max = 0U, - .parent = MENU_ROOT_INDEX, - .options = s_proto_labels }, + .type = MENU_SELECT, + .value_offset = offsetof(settings_t, device.protocol_id), + .min = 0U, + .max = 0U, + .parent = MENU_ROOT_INDEX, + .options = g_s_proto_labels }, [T_PROTO_PARAM] = { .label = "Адрес", - .type = MENU_BYTE, - .value_offset = offsetof(settings_t, user.proto_slice[0]), - .min = 0U, - .max = 15U, - .parent = MENU_ROOT_INDEX }, + .type = MENU_BYTE, + .value_offset = offsetof(settings_t, user.proto_slice[0]), + .min = 0U, + .max = 15U, + .parent = MENU_ROOT_INDEX }, [T_LOG] = { .label = "Логи", - .type = MENU_BOOL, - .value_offset = offsetof(settings_t, device.log_enabled), - .min = 0U, - .max = 1U, - .parent = MENU_ROOT_INDEX, - .options = K_BOOL_LABELS }, + .type = MENU_BOOL, + .value_offset = offsetof(settings_t, device.log_enabled), + .min = 0U, + .max = 1U, + .parent = MENU_ROOT_INDEX, + .options = K_BOOL_LABELS }, + [T_DUMMY] = { .label = "Общий?", + .type = MENU_BOOL, + .value_offset = offsetof(settings_t, user.dummy_option), + .min = 0U, + .max = 1U, + .parent = MENU_ROOT_INDEX, + .options = K_DUMMY_LABELS }, [T_EXIT] = { .label = "Выход", .type = MENU_BACK, .parent = MENU_ROOT_INDEX }, }; @@ -86,9 +98,9 @@ void menu_tree_refresh_protocol_section(settings_t *p_settings_rw) for (uint8_t i = 0U; i < VISIBLE; i++) { const sul_driver_t *p_drv = sul_registry_find(i); - s_proto_labels[i] = (p_drv != NULL) ? p_drv->p_name : "?"; + g_s_proto_labels[i] = (p_drv != NULL) ? p_drv->p_name : "?"; } - s_tree[T_PROTO].max = (uint8_t) (VISIBLE - 1U); + g_s_tree[T_PROTO].max = (uint8_t) (VISIBLE - 1U); /* Единственный параметр активного протокола (§8). Сейчас у каждого * зарегистрированного протокола ровно один (НКУ-CAN: адрес; демо: @@ -101,19 +113,19 @@ void menu_tree_refresh_protocol_section(settings_t *p_settings_rw) { const sul_settings_entry_t *p_entry = &p_settings->p_entries[0]; - s_tree[T_PROTO_PARAM].label = p_entry->p_label; - s_tree[T_PROTO_PARAM].type = menu_type_from_sul(p_entry->type); - s_tree[T_PROTO_PARAM].value_offset = + g_s_tree[T_PROTO_PARAM].label = p_entry->p_label; + g_s_tree[T_PROTO_PARAM].type = menu_type_from_sul(p_entry->type); + g_s_tree[T_PROTO_PARAM].value_offset = (uint16_t) (offsetof(settings_t, user.proto_slice) + p_entry->slice_offset); - s_tree[T_PROTO_PARAM].min = p_entry->min; - s_tree[T_PROTO_PARAM].max = p_entry->max; - s_tree[T_PROTO_PARAM].options = p_entry->p_options; + g_s_tree[T_PROTO_PARAM].min = p_entry->min; + g_s_tree[T_PROTO_PARAM].max = p_entry->max; + g_s_tree[T_PROTO_PARAM].options = p_entry->p_options; /* Клампим ТЕКУЩЕЕ значение под новый диапазон — proto_slice[0] мог * остаться от другого протокола с более широким диапазоном (напр. * адрес НКУ-CAN 0..15 -> скорость демо 0..2); без этого рендер читал * бы options[value] за пределами массива меток нового протокола. */ - uint8_t *p_val = (uint8_t *) p_settings_rw + s_tree[T_PROTO_PARAM].value_offset; + uint8_t *p_val = (uint8_t *) p_settings_rw + g_s_tree[T_PROTO_PARAM].value_offset; if (*p_val > p_entry->max) { *p_val = p_entry->max; @@ -123,18 +135,18 @@ void menu_tree_refresh_protocol_section(settings_t *p_settings_rw) { /* Протокол без параметров — инертный дефолт (не встречается пока * ни у одного зарегистрированного протокола). */ - s_tree[T_PROTO_PARAM].label = "—"; - s_tree[T_PROTO_PARAM].type = MENU_BYTE; - s_tree[T_PROTO_PARAM].value_offset = offsetof(settings_t, user.proto_slice[0]); - s_tree[T_PROTO_PARAM].min = 0U; - s_tree[T_PROTO_PARAM].max = 0U; - s_tree[T_PROTO_PARAM].options = NULL; + g_s_tree[T_PROTO_PARAM].label = "—"; + g_s_tree[T_PROTO_PARAM].type = MENU_BYTE; + g_s_tree[T_PROTO_PARAM].value_offset = offsetof(settings_t, user.proto_slice[0]); + g_s_tree[T_PROTO_PARAM].min = 0U; + g_s_tree[T_PROTO_PARAM].max = 0U; + g_s_tree[T_PROTO_PARAM].options = NULL; } } const menu_item_desc_t *menu_tree_items(void) { - return s_tree; + return g_s_tree; } uint8_t menu_tree_count(void) diff --git a/firmware/tft_app/src/services/settings_store/include/services/settings_store.h b/firmware/tft_app/src/services/settings_store/include/services/settings_store.h index 201056e..4485bda 100644 --- a/firmware/tft_app/src/services/settings_store/include/services/settings_store.h +++ b/firmware/tft_app/src/services/settings_store/include/services/settings_store.h @@ -29,60 +29,60 @@ extern "C" { #endif -/* ── Константы ───────────────────────────────────────────────────────────── */ + /* ── Константы ───────────────────────────────────────────────────────────── */ #define SETTINGS_SERIAL_LEN 10U /* серийный номер, ASCII + '\0' */ -#define SETTINGS_PROTO_SLICE_LEN 8U /* запас под протокольные параметры (§8) */ -#define SETTINGS_CORE_RESERVED 8U /* задел под будущие ядровые поля A/B */ -#define SETTINGS_VOLUME_LEVELS 5U /* индексы громкости: OFF/25/50/75/100 */ +#define SETTINGS_PROTO_SLICE_LEN 8U /* запас под протокольные параметры (§8) */ +#define SETTINGS_CORE_RESERVED 8U /* задел под будущие ядровые поля A/B */ +#define SETTINGS_VOLUME_LEVELS 5U /* индексы громкости: OFF/25/50/75/100 */ -/* ── Устройство/провиженинг (меняется редко, не в пользовательском меню) ──── */ + /* ── Устройство/провиженинг (меняется редко, не в пользовательском меню) ──── */ -typedef struct -{ - uint8_t panel_type; /**< bsp_display_type_t; хардкод TFT8, provisioning — Фаза 9 */ - uint8_t protocol_id; /**< активный протокол реестра sul (NKU_CAN = 0) */ - uint8_t log_enabled; /**< рантайм-тумблер логов (под-шаг 3.6) */ - uint8_t _pad; -} settings_device_t; + typedef struct + { + uint8_t panel_type; /**< bsp_display_type_t; хардкод TFT8, provisioning — Фаза 9 */ + uint8_t protocol_id; /**< активный протокол реестра sul (NKU_CAN = 0) */ + uint8_t log_enabled; /**< рантайм-тумблер логов (под-шаг 3.6) */ + uint8_t _pad; + } settings_device_t; -/* ── Пользовательские (редактируются в меню на объекте) ──────────────────── */ + /* ── Пользовательские (редактируются в меню на объекте) ──────────────────── */ -typedef struct -{ - /* Ярус A — железобетонные (хранятся сейчас; редактор/эффект — Фазы 5/6). */ - uint16_t max_load_kg; /**< грузоподъёмность, кг 0..9999 */ - uint8_t max_cap_persons; /**< вместимость, чел 0..99 */ - uint8_t sound_volume_idx; /**< 0..SETTINGS_VOLUME_LEVELS-1 */ - uint8_t music_volume_idx; - uint8_t year_production; /**< 0 = скрыть, иначе 2000+N (25 = 2025) */ - char serial[SETTINGS_SERIAL_LEN]; /**< ASCII, '\0'-терминирован */ - - /* Ярус B — протокольные: активный протокол трактует slice через + typedef struct + { + /* Ярус A — железобетонные (хранятся сейчас; редактор/эффект — Фазы 5/6). */ + uint16_t max_load_kg; /**< грузоподъёмность, кг 0..9999 */ + uint8_t max_cap_persons; /**< вместимость, чел 0..99 */ + uint8_t sound_volume_idx; /**< 0..SETTINGS_VOLUME_LEVELS-1 */ + uint8_t music_volume_idx; + uint8_t year_production; /**< 0 = скрыть, иначе 2000+N (25 = 2025) */ + char serial[SETTINGS_SERIAL_LEN]; /**< ASCII, '\0'-терминирован */ + uint8_t dummy_option; + /* Ярус B — протокольные: активный протокол трактует slice через sul_settings_desc_t (§8). НКУ-CAN: proto_slice[0] = адрес 0..15. */ - uint8_t proto_slice[SETTINGS_PROTO_SLICE_LEN]; -} settings_user_t; + uint8_t proto_slice[SETTINGS_PROTO_SLICE_LEN]; + } settings_user_t; -/* ── Ядро настроек ───────────────────────────────────────────────────────── */ + /* ── Ядро настроек ───────────────────────────────────────────────────────── */ -typedef struct -{ - settings_device_t device; - settings_user_t user; - uint8_t _reserved[SETTINGS_CORE_RESERVED]; /**< размер фикс — offset'ы полей стабильны */ -} settings_t; + typedef struct + { + settings_device_t device; + settings_user_t user; + uint8_t _reserved[SETTINGS_CORE_RESERVED]; /**< размер фикс — offset'ы полей стабильны */ + } settings_t; -/* ── API ─────────────────────────────────────────────────────────────────── */ + /* ── API ─────────────────────────────────────────────────────────────────── */ -/** + /** * @brief Инициализировать активные настройки значениями по умолчанию (без QSPI). * * Вызывать, когда QSPI недоступен (или до settings_store_load()) — get() после * этого всегда валиден. settings_store_load() при валидном флеше перезапишет. */ -void settings_store_init_defaults(void); + void settings_store_init_defaults(void); -/** + /** * @brief Загрузить настройки из QSPI (сектор §10). * * Предусловие: bsp_qspi_init() уже вызван. Читает сектор, проверяет @@ -93,9 +93,9 @@ void settings_store_init_defaults(void); * @retval BSP_ERR_INVALID magic/version/CRC не совпал — загружены дефолты. * @retval BSP_ERR_HW ошибка чтения флеша — загружены дефолты. */ -bsp_status_t settings_store_load(void); + bsp_status_t settings_store_load(void); -/** + /** * @brief Сохранить текущие настройки на QSPI. * * Стирает рабочий сектор, пересчитывает CRC32, пишет постранично. Вызывать по @@ -103,16 +103,16 @@ bsp_status_t settings_store_load(void); * * @retval BSP_OK / BSP_ERR_HW. */ -bsp_status_t settings_store_save(void); + bsp_status_t settings_store_save(void); -/** @brief Указатель на активные настройки (только чтение). Валиден после init/load. */ -const settings_t *settings_store_get(void); + /** @brief Указатель на активные настройки (только чтение). Валиден после init/load. */ + const settings_t *settings_store_get(void); -/** @brief Указатель на активные настройки для изменения (меню). save() — отдельно. */ -settings_t *settings_store_get_mutable(void); + /** @brief Указатель на активные настройки для изменения (меню). save() — отдельно. */ + settings_t *settings_store_get_mutable(void); -/** @brief Сбросить пользовательский ярус к дефолтам (device не трогает). save() — отдельно. */ -void settings_store_reset_user_defaults(void); + /** @brief Сбросить пользовательский ярус к дефолтам (device не трогает). save() — отдельно. */ + void settings_store_reset_user_defaults(void); #ifdef __cplusplus } diff --git a/firmware/tft_app/src/services/settings_store/src/settings_codec.c b/firmware/tft_app/src/services/settings_store/src/settings_codec.c index 915b4df..c1338f9 100644 --- a/firmware/tft_app/src/services/settings_store/src/settings_codec.c +++ b/firmware/tft_app/src/services/settings_store/src/settings_codec.c @@ -27,6 +27,7 @@ static const settings_t K_DEFAULTS = { .music_volume_idx = 1U, .year_production = 0U, /* скрыть */ .serial = "", + .dummy_option = 0U, // Моя опция .proto_slice = {0}, /* НКУ: адрес 0 */ }, }; @@ -70,7 +71,8 @@ bool settings_deserialize(const settings_page_t *p_page, settings_t *p_out) return false; } - const uint32_t CRC = settings_crc32((const uint8_t *) p_page, sizeof(*p_page) - sizeof(uint32_t)); + const uint32_t CRC = + settings_crc32((const uint8_t *) p_page, sizeof(*p_page) - sizeof(uint32_t)); if (CRC != p_page->crc32) { return false; diff --git a/tests/host/tft_app_menu/test_menu_tree.c b/tests/host/tft_app_menu/test_menu_tree.c index a43831d..8e12310 100644 --- a/tests/host/tft_app_menu/test_menu_tree.c +++ b/tests/host/tft_app_menu/test_menu_tree.c @@ -13,8 +13,12 @@ #include #include -void setUp(void) {} -void tearDown(void) {} +void setUp(void) +{ +} +void tearDown(void) +{ +} /* T_PROTO — первый ребёнок корня (гарантия menu.h: items[MENU_ROOT_INDEX] — * корневой SUBMENU). T_PROTO_PARAM — следующий по порядку в дереве @@ -46,7 +50,7 @@ static void test_protocol_section_built_from_nku_can_descriptor(void) TEST_ASSERT_EQUAL_UINT8(0U, p_items[T_PROTO_PARAM].min); TEST_ASSERT_EQUAL_UINT8(15U, p_items[T_PROTO_PARAM].max); TEST_ASSERT_EQUAL_UINT16((uint16_t) offsetof(settings_t, user.proto_slice[0]), - p_items[T_PROTO_PARAM].value_offset); + p_items[T_PROTO_PARAM].value_offset); } static void test_protocol_section_switches_to_demo_descriptor(void) @@ -111,6 +115,33 @@ static void test_protocol_param_edits_correct_settings_field(void) TEST_ASSERT_EQUAL_UINT8(1U, s.user.proto_slice[0]); } +/* Конец-в-конец: правка пункта, построенного из дескриптора, действительно + * попадает в то самое поле settings_t, которое назвал дескриптор протокола — + * не только структура данных совпадает, но и реальный edit через menu.c. */ +static void test_dummy_param_edits_correct_settings_field(void) +{ + settings_t s; + memset(&s, 0, sizeof(s)); + + sul_registry_set_active(SUL_PROTOCOL_NKU_CAN); + menu_tree_refresh_protocol_section(&s); + + menu_ctx_t ctx; + menu_init(&ctx, menu_tree_items(), menu_tree_count(), &s); + menu_open(&ctx); + menu_next(&ctx); /* T_PROTO -> T_PROTO_PARAM */ + menu_next(&ctx); /* T_PROTO_PARAM -> T_LOG */ + menu_next(&ctx); /* T_LOG -> T_DUMMY */ + + menu_action(&ctx); /* инкремент значения 0 -> 1 */ + + TEST_ASSERT_EQUAL_UINT8(1U, s.user.dummy_option); + + menu_action(&ctx); /* инкремент значения 1 -> 0 */ + + TEST_ASSERT_EQUAL_UINT8(0U, s.user.dummy_option); +} + int main(void) { UNITY_BEGIN(); @@ -119,6 +150,7 @@ int main(void) RUN_TEST(test_protocol_section_switches_to_demo_descriptor); RUN_TEST(test_stale_value_clamped_on_protocol_switch); RUN_TEST(test_protocol_param_edits_correct_settings_field); + RUN_TEST(test_dummy_param_edits_correct_settings_field); return UNITY_END(); }