diff --git a/CMakePresets.json b/CMakePresets.json index 5fea8c8..8233cb8 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -170,7 +170,9 @@ "test_recovery", "test_tft_app_smoke", "test_sul_nku_can", - "test_tft_app_controller" + "test_tft_app_controller", + "test_tft_app_settings_store", + "test_tft_app_menu" ] }, { @@ -196,7 +198,9 @@ "test_recovery", "test_tft_app_smoke", "test_sul_nku_can", - "test_tft_app_controller" + "test_tft_app_controller", + "test_tft_app_settings_store", + "test_tft_app_menu" ] }, { diff --git a/docs/tft_app/MENU.md b/docs/tft_app/MENU.md new file mode 100644 index 0000000..35f9581 --- /dev/null +++ b/docs/tft_app/MENU.md @@ -0,0 +1,132 @@ +# tft-app — движок меню и связь с настройками + +Документ описывает **реализованный** движок меню (Фаза 3.2.1: чистая модель) и его +связь с модулем настроек `settings_store`. Проектная основа — [ARCH.md §8](../../firmware/tft_app/ARCH.md); +реализация — [menu.c](../../firmware/tft_app/src/menu/src/menu.c), +[settings_store](../../firmware/tft_app/src/services/settings_store/). + +Движущее требование (§8): клиент приносит уникальные настройки, и добавление их **не должно +требовать переписывания** движка. Отсюда два принципа: меню — **данные**, редакторы — +**подключаемые по типу**. + +--- + +## 1. Разделение слоёв + +Модель меню — чистый C (host-тест), отделена от рендера (HIL) и от записи на флеш. + +```mermaid +flowchart TB + BTN["bsp_button
долгое BUTTON_2 / next / action"] + subgraph MODEL["menu (чистая модель, host-тест)"] + TREE["дерево-данные
menu_item_desc_t[]"] + NAV["навигация + редактор
menu_next / menu_action"] + end + SET["settings_t
(RAM, get_mutable)"] + APP["app-слой
(проверяет save_requested)"] + STORE["settings_store
save() → QSPI 0x450000"] + REND["рендер меню
окно 480×272 @ (0,0)"] + + BTN --> NAV + TREE --> NAV + NAV -->|"мутация по offset"| SET + NAV -->|"save_requested"| APP + APP --> STORE + NAV -.запросы.-> REND + SET -.значения.-> REND +``` + +Ключ: **модель сама не сохраняет и не рисует**. Она мутирует переданный `settings_t*` и на +выходе-с-сохранением выставляет `save_requested`; фактический `settings_store_save()` вызывает +app-слой. Это держит навигацию/редактирование host-тестируемыми без QSPI и без рендера. + +--- + +## 2. Меню — данные + +Пункт меню — строка-дескриптор ([menu.h](../../firmware/tft_app/src/menu/include/menu/menu.h)): + +```c +typedef struct { + const char *label; + menu_item_type_t type; /* редактор: SUBMENU/BACK/SELECT/BYTE/BOOL */ + uint16_t value_offset; /* offsetof(settings_t, ) */ + uint8_t min, max; /* диапазон для SELECT/BYTE/BOOL */ + uint8_t parent, first_child, last_child; /* дерево (плоский массив+индексы) */ +} menu_item_desc_t; +``` + +Дерево — плоский массив; уровень = непрерывный диапазон детей `[first_child..last_child]` одного +родителя. `items[0]` — корневое `SUBMENU`, его дети — верхний уровень. + +**Добавить пункт = добавить строку** массива (тот же принцип, что `k_mode_priority[]`). Добавить +причудливый редактор = добавить значение в `menu_item_type_t` + ветку в `menu_action` — движок +навигации не меняется. Фаза 3 использует `SELECT/BYTE/BOOL`; `ARRAY/SERIAL/YEAR/PERCENT/BOOL_ARRAY` +придут со своими фазами (5/6). + +--- + +## 3. Навигация + +Две кнопки: BUTTON_1 → `menu_next` (следующий пункт уровня, с заворотом); короткое BUTTON_2 → +`menu_action` (по типу пункта). Вход в меню — долгое BUTTON_2 (app-слой). + +```mermaid +flowchart TD + START([menu_action на текущем пункте]) --> T{тип пункта} + T -->|SUBMENU| ENTER["курсор → first_child (вход)"] + T -->|"BACK, parent=корень"| EXIT["save_requested = dirty
меню закрыто"] + T -->|"BACK, в подменю"| UP["курсор → пункт-подменю (возврат)"] + T -->|SELECT/BYTE/BOOL| EDIT["значение min..max с заворотом
dirty = true"] +``` + +`dirty` взводится любой правкой; при выходе из корня `save_requested = dirty` (сохраняем только +если что-то менялось). Отдельного «отменить» нет — правки живут в RAM, на флеш попадают лишь при +выходе-с-сохранением. + +--- + +## 4. Связь с настройками + +Модель привязана к `settings_t` (ядро настроек, [SETTINGS](../../firmware/tft_app/src/services/settings_store/include/services/settings_store.h)) +через **байтовый offset** — читает/пишет `uint8`-поле по `value_offset`: + +| Ярус настройки | Пример пункта | Привязка | +| --- | --- | --- | +| **A** железобетонные | громкость, год | `offsetof(settings_t, user.<поле>)` | +| device/провиженинг | тумблер логов | `offsetof(settings_t, device.log_enabled)` | +| **B** протокольные | адрес НКУ | `offsetof(settings_t, user.proto_slice[0])` | + +Ярус B (протокольные параметры) в 3.2 привязан прямым offset к `proto_slice`; в 3.3 это обобщается +на дескриптор протокола `sul_settings_desc_t` (§8) — меню строит раздел «Настройки протокола» из +дескриптора активного протокола, не хардкодом. + +**Поток сохранения** (app-слой связывает модель и flash): + +```mermaid +sequenceDiagram + participant U as Пользователь (кнопки) + participant M as menu (модель) + participant S as settings_t (RAM) + participant A as app + participant St as settings_store + + U->>M: menu_action (правка / выход) + M->>S: запись поля по offset (dirty=true) + M-->>A: open=false, save_requested=true + A->>St: settings_store_save() + St->>St: serialize + CRC → QSPI 0x450000 + A->>A: пере-применить настройки (напр. адрес → декодер) +``` + +После сохранения app пере-применяет изменившиеся настройки к рантайму (например, новый адрес — +в `nku_can_set_address()`), т.к. декодер держит свою копию адреса. + +--- + +## 5. Рендер (Фаза 3.2.2) + +Рендер — отдельный слой, читает модель запросами (`menu_current`, `menu_level_range`, +`menu_read_value`) и рисует в **фиксированном окне 480×272 в логических (0,0)** — одинаково на всех +панелях (на больших — левый-верхний угол, остальное чёрное). Меню модально: пока `menu_is_open()`, +индикация под ним не рисуется. Детали — [PLAN.md, Фаза 3.2](../../firmware/tft_app/PLAN.md). diff --git a/docs/tft_app/README.md b/docs/tft_app/README.md index 0beab30..f13897b 100644 --- a/docs/tft_app/README.md +++ b/docs/tft_app/README.md @@ -9,6 +9,7 @@ Dev-документация по **реализованному** доменн | [DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md) | Путь данных: транспорт → декодер → контроллер → презентация. Слои, контракт `decode()`, карта пакетов НКУ-CAN → поля модели, таймаут→default. | | [MODE_PRIORITY.md](MODE_PRIORITY.md) | Свёртка ортогональных сигналов в один экранный режим. Таблица приоритетов как данные, резолвер, как менять/кастомизировать. | | [FALLBACK.md](FALLBACK.md) | Safe-mode рендер: этаж/стрелка/метка режима, по-символьный fallback шрифта, триггеры перерисовки, ограничение single-buffer. | +| [MENU.md](MENU.md) | Движок меню (Фаза 3.2): дерево-данные, редакторы по типу, чистая модель, связь с `settings_store` (offset-привязка, `proto_slice`, поток сохранения). | ## Связанные документы @@ -18,6 +19,7 @@ Dev-документация по **реализованному** доменн ## Границы -Описан домен и fallback-презентация. Ещё не реализовано (см. PLAN.md): настройки/меню (Фаза 3), -ассеты и layout-движок со спрайтами (Фазы 4–5), аудио (Фаза 6), мультипротокол (Фаза 8). Удалённая -установка адреса НКУ-CAN отложена в Фазу 3. +Описаны домен, fallback-презентация, ядро настроек (`settings_store`) и модель меню. +В процессе (Фаза 3): рендер меню + wiring кнопок (3.2.2/3.2.3), per-protocol дескрипторы (3.3), +opto-входы (3.4), удалённая адресация НКУ-CAN (3.5). Ещё не реализовано (см. PLAN.md): ассеты и +layout-движок со спрайтами (Фазы 4–5), аудио (Фаза 6), мультипротокол (Фаза 8). diff --git a/firmware/tft_app/CMakeLists.txt b/firmware/tft_app/CMakeLists.txt index e56c841..17ba1b0 100644 --- a/firmware/tft_app/CMakeLists.txt +++ b/firmware/tft_app/CMakeLists.txt @@ -49,6 +49,7 @@ target_compile_options(freertos_kernel PRIVATE -w) add_subdirectory(src/domain) add_subdirectory(src/services) add_subdirectory(src/ui) +add_subdirectory(src/menu) # ----------------------------------------------------------------------------- # app — исполняемый образ слота @@ -120,7 +121,9 @@ target_link_libraries( tft_app_gfx tft_app_ui_fallback # Фаза 3.1: персист ядра настроек (QSPI 0x450000) - tft_app_settings_store) + tft_app_settings_store + # Фаза 3.2.1: движок меню (чистая модель; рендер/wiring — 3.2.2/3.2.3) + tft_app_menu) target_link_options( app diff --git a/firmware/tft_app/PLAN.md b/firmware/tft_app/PLAN.md index e1cc3e9..20cf114 100644 --- a/firmware/tft_app/PLAN.md +++ b/firmware/tft_app/PLAN.md @@ -274,7 +274,7 @@ marks_both_pending`, аппаратно на реальном обрыве св | Под-шаг | Содержание | | --- | --- | | **3.1 `services/settings_store`** | Персист ядра (magic/version/CRC, load/save/get/defaults) через `bsp_qspi_flash`, фикс-сектор `0x450000` (§10, размер-независимо). Сразу: `proto_slice[0]` → `nku_address` в декодер (замена хардкода `=0`). | -| **3.2 движок меню + рендер** | Чистая модель (дерево-данные, навигация, edit, offset-привязка) — host-тест; рендер (примитивы `gfx`: список/курсор/значение) + `bsp_button`; модальный экран. | +| **3.2 движок меню + рендер** | **Чистая модель** (дерево-данные, навигация, edit, offset-привязка) — оперирует переданным `settings_t*`, **сама не сохраняет** (выставляет флаг save, app зовёт `settings_store_save()`) → host-тест без QSPI. **Рендер** (примитивы `gfx`: список/курсор/значение) в **переносимом окне 480×272 @ логич.(0,0)** (одинаково на всех панелях; на больших — левый-верхний угол, остальное чёрное). Вход — **долгое нажатие BUTTON_2** (~1.5–2 с); BUTTON_1=следующий, короткое BUTTON_2=выбор/инкремент. Опрос кнопок — задача 5 мс. Модальный экран. | | **3.3 per-protocol дескрипторы** | `sul_settings_desc_t`: протокол регистрирует параметры (NKU-CAN: адрес 0..15); секция меню строится из дескриптора. | | **3.4 opto-входы** | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). | | **3.5 удалённая адресация NKU-CAN** | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). | diff --git a/firmware/tft_app/src/app/main.c b/firmware/tft_app/src/app/main.c index 6b970cd..45bfa74 100644 --- a/firmware/tft_app/src/app/main.c +++ b/firmware/tft_app/src/app/main.c @@ -54,15 +54,23 @@ * settings, см. ARCH §9 — TFT7/8/10 рантайм-выбор внутри профиля app-big). */ #define PANEL_TYPE BSP_DISPLAY_TFT8 -#define HEARTBEAT_PERIOD_MS 500U -#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */ -#define STATUS_LOG_PERIOD_MS 2000U /* периодический re-log трейлера — виден независимо +#define HEARTBEAT_PERIOD_MS 500U +#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */ +#define STATUS_LOG_PERIOD_MS \ + 2000U /* периодический re-log трейлера — виден независимо * от момента подключения терминала */ -#define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */ -#define CONNECTION_TIMEOUT_MS 3000U /* «пропадание трафика» — см. ARCH, поток данных: +#define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */ +#define CONNECTION_TIMEOUT_MS \ + 3000U /* «пропадание трафика» — см. ARCH, поток данных: * poll + timeout→default. Порядок величины — как * в OLD_PROJECT (там ~3 с на отметку потери связи) */ +/* TEMP (Фаза 3.1): двухзагрузочная HW-проверка пути save/персист. УБРАТЬ в 3.2, + * когда save начнёт вызываться из меню. Сентинел в ИНЕРТНОМ поле max_load_kg + * (рендер грузоподъёмности — Фаза 5), proto_slice/адрес НЕ трогаем — индикатор + * продолжает работать с адресом 0. */ +#define SETTINGS_SELFTEST_SENTINEL 4242U + /* Передача render_task самого свежего состояния — не истории. */ typedef struct { @@ -70,7 +78,7 @@ typedef struct sul_result_t result; } render_msg_t; -static QueueHandle_t s_render_queue; +static QueueHandle_t g_s_render_queue; /* ── Диагностика трейлера слота (read-only, безопасно звать многократно) ── */ @@ -84,8 +92,8 @@ static void log_slot_status(const char *p_when) return; } - struct boot_swap_state st = {0}; - const int RC_RD = boot_read_swap_state(p_fap, &st); + struct boot_swap_state st = { 0 }; + const int RC_RD = boot_read_swap_state(p_fap, &st); LOG_I(LOG_TAG, "%s: slot%d magic=%d copy_done=%d image_ok=%d (rd=%d)", p_when, APP_OWN_SLOT_ID, st.magic, st.copy_done, st.image_ok, RC_RD); @@ -158,19 +166,39 @@ static void render_task(void *p_arg) (void) p_arg; /* Безусловная первая отрисовка — не ждём первого diff (см. ui/fallback.h). */ - const sul_result_t initial = sul_default_state(); - ui_fallback_render_initial(&initial); + const sul_result_t INITIAL = sul_default_state(); + ui_fallback_render_initial(&INITIAL); render_msg_t msg; for (;;) { - if (xQueueReceive(s_render_queue, &msg, portMAX_DELAY) == pdTRUE) + if (xQueueReceive(g_s_render_queue, &msg, portMAX_DELAY) == pdTRUE) { ui_fallback_render(&msg.task, &msg.result); } } } +/* TEMP (Фаза 3.1): проверка save/персист через power cycle. Первый запуск — + * пишет сентинел и просит перезагрузку; после перезагрузки load() читает его с + * флеша → «PERSIST OK». Сентинел остаётся (инертен), затирается первым + * сохранением из меню (3.2) или factory-reset. УБРАТЬ вместе с #define в 3.2. */ +static void settings_selftest(void) +{ + settings_t *p_s = settings_store_get_mutable(); + if (p_s->user.max_load_kg == SETTINGS_SELFTEST_SENTINEL) + { + LOG_I(LOG_TAG, "settings self-test: PERSIST OK (сентинел пережил перезагрузку)"); + return; + } + + p_s->user.max_load_kg = SETTINGS_SELFTEST_SENTINEL; + const bsp_status_t RC = settings_store_save(); + LOG_I(LOG_TAG, + "settings self-test: записан сентинел rc=%d — СДЕЛАЙТЕ POWER-CYCLE для проверки персиста", + RC); +} + static void sul_rx_task(void *p_arg) { (void) p_arg; @@ -190,6 +218,7 @@ static void sul_rx_task(void *p_arg) const bsp_status_t S_RC = settings_store_load(); LOG_I(LOG_TAG, "settings: load rc=%d proto_addr=%u", S_RC, settings_store_get()->user.proto_slice[0]); + settings_selftest(); /* TEMP (3.1): HW-проверка save/персист, убрать в 3.2 */ } else { @@ -220,11 +249,11 @@ static void sul_rx_task(void *p_arg) controller_ctx_t ctrl_ctx; controller_init(&ctrl_ctx); - const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS); - uint32_t elapsed_ms = 0U; - uint32_t since_status_ms = 0U; - TickType_t last_wake = xTaskGetTickCount(); - TickType_t last_frame_tick = xTaskGetTickCount(); + const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS); + uint32_t elapsed_ms = 0U; + uint32_t since_status_ms = 0U; + TickType_t last_wake = xTaskGetTickCount(); + TickType_t last_frame_tick = xTaskGetTickCount(); for (;;) { @@ -277,8 +306,8 @@ static void sul_rx_task(void *p_arg) const indication_task_t DIFF = controller_process(&ctrl_ctx, &decoded); if (DIFF.pos_pending || DIFF.direction_pending) { - const render_msg_t MSG = {.task = DIFF, .result = decoded}; - (void) xQueueOverwrite(s_render_queue, &MSG); + const render_msg_t MSG = { .task = DIFF, .result = decoded }; + (void) xQueueOverwrite(g_s_render_queue, &MSG); } } } @@ -292,8 +321,8 @@ int main(void) board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */ bsp_led_init(); - s_render_queue = xQueueCreate(1, sizeof(render_msg_t)); - configASSERT(s_render_queue != NULL); + g_s_render_queue = xQueueCreate(1, sizeof(render_msg_t)); + configASSERT(g_s_render_queue != NULL); /* sul_rx выше приоритетом render — приём CAN/WDOG важнее своевременности * перерисовки. Стеки x6 (3 КБ) с запасом (см. PLAN.md, Фаза 0 — тонкий diff --git a/firmware/tft_app/src/menu/CMakeLists.txt b/firmware/tft_app/src/menu/CMakeLists.txt new file mode 100644 index 0000000..b3d9a8c --- /dev/null +++ b/firmware/tft_app/src/menu/CMakeLists.txt @@ -0,0 +1,7 @@ +# menu — движок меню настроек (§8). Фаза 3.2.1: чистая модель (без рендера/QSPI). +add_library(tft_app_menu STATIC src/menu.c) + +target_include_directories(tft_app_menu PUBLIC include/) + +# settings_store — только типы settings_t (заголовок чистый, без QSPI-кода). +target_link_libraries(tft_app_menu PUBLIC tft_app_settings_store) diff --git a/firmware/tft_app/src/menu/include/menu/menu.h b/firmware/tft_app/src/menu/include/menu/menu.h new file mode 100644 index 0000000..37d55b3 --- /dev/null +++ b/firmware/tft_app/src/menu/include/menu/menu.h @@ -0,0 +1,110 @@ +/** + * @file menu.h + * @brief Движок меню настроек (ARCH.md §8) — ЧИСТАЯ модель, без рендера и QSPI. + * + * Дерево пунктов — ДАННЫЕ (плоский массив + индексы parent/child). Редакторы + * подключаются по типу (menu_item_type_t) — добавить причудливый параметр = + * добавить тип + строку дерева, движок не меняется (§8, требование + * расширяемости под клиента). + * + * Модель ОПЕРИРУЕТ ПЕРЕДАННЫМ `settings_t*` (мутирует RAM на месте) и **сама не + * сохраняет** — на выходе-с-сохранением выставляет `save_requested`, а + * `settings_store_save()` вызывает app-слой. Это держит модель host-тестируемой + * (без QSPI) и разделяет логику/side-effect по слоям. + * + * Навигация: BUTTON_1 → menu_next() (следующий пункт уровня, с заворотом); + * короткое BUTTON_2 → menu_action() (вход в подменю / инкремент значения / + * выход-с-сохранением). Вход в меню (долгое BUTTON_2) и модальность — уровень app. + * + * Соглашение: items[MENU_ROOT_INDEX] — корневое SUBMENU; его дети — верхний + * уровень (их parent == MENU_ROOT_INDEX). Дети одного уровня — непрерывный + * диапазон [first_child..last_child]. + */ + +#ifndef MENU_MENU_H_ +#define MENU_MENU_H_ + +#include "services/settings_store.h" + +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + +#define MENU_ITEMS_PER_PAGE 6U /* строк на экране (окно 480×272, §PLAN 3.2) */ +#define MENU_ROOT_INDEX 0U /* items[0] — корневое SUBMENU */ + +/** Тип пункта = редактор. Расширяется добавлением значения (Фазы 5/6: + * ARRAY/SERIAL/YEAR/PERCENT/BOOL_ARRAY) — движок не меняется. */ +typedef enum +{ + MENU_SUBMENU, /**< вход в подменю [first_child..last_child] */ + MENU_BACK, /**< возврат уровнем выше; в корне — выход (+save) */ + MENU_SELECT, /**< выбор из списка 0..max (напр. протокол) */ + MENU_BYTE, /**< число min..max (напр. адрес) */ + MENU_BOOL, /**< да/нет */ +} menu_item_type_t; + +/** + * @brief Пункт меню — данные. Привязка к настройке — байтовый offset uint8-поля + * в settings_t (для SELECT/BYTE/BOOL). Для SUBMENU/BACK offset игнорируется. + */ +typedef struct +{ + const char *label; + menu_item_type_t type; + uint16_t value_offset; /**< offsetof(settings_t, ) */ + uint8_t min; /**< для SELECT/BYTE/BOOL */ + uint8_t max; + uint8_t parent; /**< индекс родителя (MENU_ROOT_INDEX — верхний уровень) */ + uint8_t first_child; /**< для SUBMENU — диапазон детей */ + uint8_t last_child; +} menu_item_desc_t; + +/** Состояние навигации — чистое. */ +typedef struct +{ + const menu_item_desc_t *items; + uint8_t count; + settings_t *settings; /**< мутируется на месте */ + uint8_t cur; /**< индекс выделенного пункта */ + uint8_t page; /**< страница для рендера */ + bool open; + bool dirty; /**< значение менялось */ + bool save_requested; /**< выход-с-сохранением: app зовёт save() */ +} menu_ctx_t; + +/** Инициализировать (меню закрыто). @p items[MENU_ROOT_INDEX] — корневое SUBMENU. */ +void menu_init(menu_ctx_t *p_ctx, const menu_item_desc_t *p_items, uint8_t count, + settings_t *p_settings); + +/** Открыть меню: курсор на первый пункт верхнего уровня, флаги сброшены. */ +void menu_open(menu_ctx_t *p_ctx); + +/** BUTTON_1: следующий пункт текущего уровня (заворот с последнего на первый). */ +void menu_next(menu_ctx_t *p_ctx); + +/** Короткое BUTTON_2: вход в подменю / инкремент значения / выход-с-сохранением. */ +void menu_action(menu_ctx_t *p_ctx); + +bool menu_is_open(const menu_ctx_t *p_ctx); + +/* ── Запросы для рендера ─────────────────────────────────────────────────── */ + +/** Индекс выделенного пункта. */ +uint8_t menu_current(const menu_ctx_t *p_ctx); + +/** Диапазон пунктов текущего уровня (соседи выделенного) — для отрисовки списка. */ +void menu_level_range(const menu_ctx_t *p_ctx, uint8_t *p_first, uint8_t *p_last); + +/** Текущее uint8-значение editable-пункта (SELECT/BYTE/BOOL) для отрисовки. */ +uint8_t menu_read_value(const menu_ctx_t *p_ctx, uint8_t idx); + +#ifdef __cplusplus +} +#endif + +#endif /* MENU_MENU_H_ */ diff --git a/firmware/tft_app/src/menu/src/menu.c b/firmware/tft_app/src/menu/src/menu.c new file mode 100644 index 0000000..24c177b --- /dev/null +++ b/firmware/tft_app/src/menu/src/menu.c @@ -0,0 +1,123 @@ +#include "menu/menu.h" + +/* Указатель на uint8-поле настройки, к которому привязан пункт idx. */ +static uint8_t *field_ptr(const menu_ctx_t *p_ctx, uint8_t idx) +{ + return (uint8_t *) p_ctx->settings + p_ctx->items[idx].value_offset; +} + +static uint8_t page_of(const menu_ctx_t *p_ctx, uint8_t idx, uint8_t first) +{ + return (uint8_t) ((idx - first) / MENU_ITEMS_PER_PAGE); +} + +void menu_init(menu_ctx_t *p_ctx, const menu_item_desc_t *p_items, uint8_t count, + settings_t *p_settings) +{ + p_ctx->items = p_items; + p_ctx->count = count; + p_ctx->settings = p_settings; + p_ctx->cur = MENU_ROOT_INDEX; + p_ctx->page = 0U; + p_ctx->open = false; + p_ctx->dirty = false; + p_ctx->save_requested = false; +} + +void menu_open(menu_ctx_t *p_ctx) +{ + p_ctx->open = true; + p_ctx->dirty = false; + p_ctx->save_requested = false; + p_ctx->cur = p_ctx->items[MENU_ROOT_INDEX].first_child; /* первый пункт верхнего уровня */ + p_ctx->page = 0U; +} + +bool menu_is_open(const menu_ctx_t *p_ctx) +{ + return p_ctx->open; +} + +uint8_t menu_current(const menu_ctx_t *p_ctx) +{ + return p_ctx->cur; +} + +void menu_level_range(const menu_ctx_t *p_ctx, uint8_t *p_first, uint8_t *p_last) +{ + const uint8_t PARENT = p_ctx->items[p_ctx->cur].parent; + *p_first = p_ctx->items[PARENT].first_child; + *p_last = p_ctx->items[PARENT].last_child; +} + +uint8_t menu_read_value(const menu_ctx_t *p_ctx, uint8_t idx) +{ + return *field_ptr(p_ctx, idx); +} + +void menu_next(menu_ctx_t *p_ctx) +{ + if (!p_ctx->open) + { + return; + } + + uint8_t first; + uint8_t last; + menu_level_range(p_ctx, &first, &last); + + p_ctx->cur = (p_ctx->cur >= last) ? first : (uint8_t) (p_ctx->cur + 1U); /* заворот */ + p_ctx->page = page_of(p_ctx, p_ctx->cur, first); +} + +/* Инкремент editable-значения с заворотом min→max→min. */ +static void cycle_value(menu_ctx_t *p_ctx) +{ + const menu_item_desc_t *p_it = &p_ctx->items[p_ctx->cur]; + uint8_t *p_v = field_ptr(p_ctx, p_ctx->cur); + + *p_v = (*p_v >= p_it->max) ? p_it->min : (uint8_t) (*p_v + 1U); + p_ctx->dirty = true; +} + +void menu_action(menu_ctx_t *p_ctx) +{ + if (!p_ctx->open) + { + return; + } + + const menu_item_desc_t *p_it = &p_ctx->items[p_ctx->cur]; + + switch (p_it->type) + { + case MENU_SUBMENU: + p_ctx->cur = p_it->first_child; + p_ctx->page = 0U; + break; + + case MENU_BACK: + if (p_it->parent == MENU_ROOT_INDEX) + { + /* Корневой выход — сохранить, если что-то менялось. */ + p_ctx->save_requested = p_ctx->dirty; + p_ctx->open = false; + } + else + { + /* Вернуться к пункту-подменю, из которого вошли. */ + p_ctx->cur = p_it->parent; + uint8_t first; + uint8_t last; + menu_level_range(p_ctx, &first, &last); + p_ctx->page = page_of(p_ctx, p_ctx->cur, first); + } + break; + + case MENU_SELECT: + case MENU_BYTE: + case MENU_BOOL: + cycle_value(p_ctx); + break; + } +} diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 0314db5..b06008c 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -367,3 +367,15 @@ add_host_test( ${TFT_APP_SETTINGS_DIR}/src ${PROJECT_SOURCE_DIR}/bsp/qspi_flash/include ${PROJECT_SOURCE_DIR}/bsp/common/include) + +# tft_app — menu (Фаза 3.2.1: чистая модель навигации/редактирования, без QSPI) +add_host_test( + NAME + test_tft_app_menu + SOURCES + tft_app_menu/test_menu.c + ${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/src/menu.c + INCLUDES + ${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/include + ${TFT_APP_SETTINGS_DIR}/include + ${PROJECT_SOURCE_DIR}/bsp/common/include) diff --git a/tests/host/tft_app_controller/README.md b/tests/host/tft_app_controller/README.md new file mode 100644 index 0000000..082e496 --- /dev/null +++ b/tests/host/tft_app_controller/README.md @@ -0,0 +1,43 @@ +# test_tft_app_controller + +## Модуль под тестом + +`firmware/tft_app/src/domain/controller/src/controller.c` — редьюсер +`sul_result_t` (+кэш) → `indication_task_t` (diff). Плюс +`controller/src/mode_priority.c` (свёртка сигналов в экранный режим) и +`elevator_model.c`. + +## Категория + +A — платформонезависимый, без HAL-вызовов и моков. + +## Что проверяется + +**Diff контроллера:** +- Кэш засеян дефолтом при init: результат, равный дефолту, — не изменение. +- Раздельные pending-флаги (`pos`, `next`, `direction`, `mode`) — помечается + только реально изменившееся поле. +- Таймаут связи — тем же путём, что обычный кадр (`controller_process()` с + `sul_default_state()`), без отдельного API. +- `arrival_pending` / `movement_pending` — по **фронту** `false→true` (уровень + держится → повторно не взводится). + +**Таблица приоритетов (`sul_resolve_mode`):** +- Нет сигналов → `SUL_MODE_NORMAL`. +- Каждый одиночный сигнал → свой режим. +- Порядок при одновременной активности: fireman > пожар > перегруз > сейсмо > + сервис > погрузка (проверка каждой соседней пары). +- `mode_pending` поднимается на смену **разрешённого** режима, а не сырого + сигнала (перегруз при активном пожаре режим не меняет → `mode_pending` = false). + +## Гарантии + +- Приоритет определяется порядком строк таблицы-данных, не значениями enum. +- `indication_task_t.mode` несёт разрешённый режим нового результата (презентация + читает его при `mode_pending`). + +## Запуск + +```bash +ctest --preset host-debug-test -R test_tft_app_controller -V +``` diff --git a/tests/host/tft_app_controller/test_controller.c b/tests/host/tft_app_controller/test_controller.c index fc28696..8cddfdf 100644 --- a/tests/host/tft_app_controller/test_controller.c +++ b/tests/host/tft_app_controller/test_controller.c @@ -1,8 +1,6 @@ /** * @file test_controller.c - * @brief Host unit-тесты controller.c — diff между кэшем и новым sul_result_t. - * - * Чистый C, без единого HAL-вызова — fff-моков не нужно. + * @brief Host unit-тесты controller.c + mode_priority.c. См. README.md. */ #include "unity.h" @@ -23,14 +21,14 @@ static sul_result_t make_result(const char *p_pos, sul_direction_t dir) return r; } -/* Кэш засеян дефолтом при init — результат, совпадающий с дефолтом, не - * считается изменением (см. докстрок controller_init()). */ +/* ── Diff контроллера ────────────────────────────────────────────────── */ + static void test_first_frame_matching_default_has_no_pending(void) { controller_ctx_t ctx; controller_init(&ctx); - const sul_result_t r = sul_default_state(); + const sul_result_t r = sul_default_state(); const indication_task_t task = controller_process(&ctx, &r); TEST_ASSERT_FALSE(task.pos_pending); @@ -93,8 +91,6 @@ static void test_only_direction_change_marks_only_direction_pending(void) TEST_ASSERT_TRUE(task.direction_pending); } -/* Таймаут связи = caller зовёт controller_process(sul_default_state()) тем - * же путём, что и обычный кадр — отдельного API не существует (см. header). */ static void test_feeding_default_state_after_real_data_marks_both_pending(void) { controller_ctx_t ctx; @@ -122,60 +118,58 @@ static void test_resolve_mode_single_signals(void) { sul_result_t r; - r = sul_default_state(); - r.fireman = true; + r = sul_default_state(); + r.fireman = true; TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.fire_alarm = true; TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.overload = true; TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.seismic = true; TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.maintenance = true; TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.lading = true; TEST_ASSERT_EQUAL(SUL_MODE_LADING, sul_resolve_mode(&r)); } -/* Согласованный порядок: fireman > пожар > перегруз > сейсмо > сервис > - * погрузка. Проверяем каждую соседнюю пару при одновременной активности. */ static void test_resolve_mode_priority_ordering(void) { sul_result_t r; - r = sul_default_state(); + r = sul_default_state(); r.fireman = true; - r.fire_alarm = true; /* fireman выигрывает у пожара */ + r.fire_alarm = true; TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.fire_alarm = true; - r.overload = true; /* пожар выигрывает у перегруза */ + r.overload = true; TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.overload = true; - r.seismic = true; /* перегруз выигрывает у сейсмо */ + r.seismic = true; TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.seismic = true; - r.maintenance = true; /* сейсмо выигрывает у сервиса */ + r.maintenance = true; TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r)); - r = sul_default_state(); + r = sul_default_state(); r.maintenance = true; - r.lading = true; /* сервис выигрывает у погрузки */ + r.lading = true; TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r)); } @@ -190,17 +184,15 @@ static void test_mode_pending_on_mode_change(void) (void) snprintf(normal.pos, SUL_POS_BUF_LEN, "5"); (void) controller_process(&ctx, &normal); - sul_result_t fire = normal; - fire.fire_alarm = true; + sul_result_t fire = normal; + fire.fire_alarm = true; const indication_task_t task = controller_process(&ctx, &fire); TEST_ASSERT_TRUE(task.mode_pending); TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, task.mode); - TEST_ASSERT_FALSE(task.pos_pending); /* позиция не менялась */ + TEST_ASSERT_FALSE(task.pos_pending); } -/* Смена сырого сигнала, не меняющая РАЗРЕШЁННЫЙ режим (перегруз при активном - * пожаре), не должна поднимать mode_pending. */ static void test_mode_pending_stable_when_resolved_mode_unchanged(void) { controller_ctx_t ctx; @@ -211,8 +203,8 @@ static void test_mode_pending_stable_when_resolved_mode_unchanged(void) (void) controller_process(&ctx, &fire); sul_result_t fire_plus_overload = fire; - fire_plus_overload.overload = true; /* режим остаётся FIRE_ALARM */ - const indication_task_t task = controller_process(&ctx, &fire_plus_overload); + fire_plus_overload.overload = true; + const indication_task_t task = controller_process(&ctx, &fire_plus_overload); TEST_ASSERT_FALSE(task.mode_pending); TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, task.mode); @@ -226,14 +218,13 @@ static void test_arrival_and_movement_pending_on_rising_edge(void) sul_result_t quiet = sul_default_state(); (void) controller_process(&ctx, &quiet); - sul_result_t event = sul_default_state(); - event.arrival = true; - event.movement = true; + sul_result_t event = sul_default_state(); + event.arrival = true; + event.movement = true; indication_task_t task = controller_process(&ctx, &event); TEST_ASSERT_TRUE(task.arrival_pending); TEST_ASSERT_TRUE(task.movement_pending); - /* Уровень держится — фронта нет, pending не выставляется повторно. */ task = controller_process(&ctx, &event); TEST_ASSERT_FALSE(task.arrival_pending); TEST_ASSERT_FALSE(task.movement_pending); diff --git a/tests/host/tft_app_menu/README.md b/tests/host/tft_app_menu/README.md new file mode 100644 index 0000000..958b774 --- /dev/null +++ b/tests/host/tft_app_menu/README.md @@ -0,0 +1,38 @@ +# test_tft_app_menu + +## Модуль под тестом + +`firmware/tft_app/src/menu/src/menu.c` — **чистая** модель меню (навигация, +редактирование, дерево-данные). Рендер и wiring кнопок — отдельно (не здесь). + +## Категория + +A — платформонезависимый. Движок оперирует переданным `settings_t*` (мутирует +RAM), QSPI не трогает — моки не нужны. Деревья меню строятся прямо в тесте. + +## Что проверяется + +**Плоское дерево** `[Протокол(SELECT), Адрес(BYTE), Логи(BOOL), Выход(BACK)]`: +- `open` ставит курсор на первый пункт верхнего уровня. +- `menu_next` идёт по уровню с **заворотом** (последний → первый). +- Инкремент `BYTE` (адрес 0..15) с заворотом `max→min`; `BOOL` — тумблер. +- Выход-с-сохранением: `save_requested` = `dirty` (true после правки, false без). +- `menu_next`/`menu_action` — no-op при закрытом меню. + +**Дерево с подменю:** +- Вход в `SUBMENU` → первый ребёнок. +- `BACK` в подменю → возврат к пункту-подменю (не закрытие). +- Правка в подменю сохраняется в `settings_t`; выход из корня → `save_requested`. + +## Гарантии + +- Модель **сама не сохраняет** — только выставляет `save_requested`; фактический + `settings_store_save()` вызывает app-слой (разделение логики и side-effect). +- Правки применяются к переданному `settings_t` немедленно (RAM); на флеш — + единожды при выходе-с-сохранением. + +## Запуск + +```bash +ctest --preset host-debug-test -R test_tft_app_menu -V +``` diff --git a/tests/host/tft_app_menu/test_menu.c b/tests/host/tft_app_menu/test_menu.c new file mode 100644 index 0000000..9af85a0 --- /dev/null +++ b/tests/host/tft_app_menu/test_menu.c @@ -0,0 +1,249 @@ +/** + * @file test_menu.c + * @brief Host-тесты чистой модели меню (menu.c). См. README.md. + */ + +#include "menu/menu.h" +#include "unity.h" + +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +/* ── Плоское дерево: Протокол / Адрес / Логи / Выход ─────────────────────── */ + +enum +{ + A_ROOT = 0, + A_PROTO, + A_ADDR, + A_LOG, + A_EXIT, + A_COUNT, +}; + +static const menu_item_desc_t K_TREE_FLAT[A_COUNT] = { + [A_ROOT] = { "Настройки", MENU_SUBMENU, 0, 0, 0, MENU_ROOT_INDEX, A_PROTO, A_EXIT }, + [A_PROTO] = { "Протокол", MENU_SELECT, offsetof(settings_t, device.protocol_id), 0, 1, + MENU_ROOT_INDEX, 0, 0 }, + [A_ADDR] = { "Адрес", MENU_BYTE, offsetof(settings_t, user.proto_slice[0]), 0, 15, + MENU_ROOT_INDEX, 0, 0 }, + [A_LOG] = { "Логи", MENU_BOOL, offsetof(settings_t, device.log_enabled), 0, 1, + MENU_ROOT_INDEX, 0, 0 }, + [A_EXIT] = { "Выход", MENU_BACK, 0, 0, 0, MENU_ROOT_INDEX, 0, 0 }, +}; + +static void open_flat(menu_ctx_t *p_ctx, settings_t *p_s) +{ + memset(p_s, 0, sizeof(*p_s)); + menu_init(p_ctx, K_TREE_FLAT, A_COUNT, p_s); + menu_open(p_ctx); +} + +static void test_open_selects_first_toplevel(void) +{ + menu_ctx_t ctx; + settings_t s; + open_flat(&ctx, &s); + + TEST_ASSERT_TRUE(menu_is_open(&ctx)); + TEST_ASSERT_EQUAL_UINT8(A_PROTO, menu_current(&ctx)); +} + +static void test_next_wraps_around_level(void) +{ + menu_ctx_t ctx; + settings_t s; + open_flat(&ctx, &s); + + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(A_ADDR, menu_current(&ctx)); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(A_LOG, menu_current(&ctx)); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(A_EXIT, menu_current(&ctx)); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(A_PROTO, menu_current(&ctx)); +} + +static void test_byte_increment_wraps(void) +{ + menu_ctx_t ctx; + settings_t s; + open_flat(&ctx, &s); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(A_ADDR, menu_current(&ctx)); + + for (uint8_t i = 0U; i < 15U; ++i) + { + menu_action(&ctx); + } + TEST_ASSERT_EQUAL_UINT8(15U, s.user.proto_slice[0]); + TEST_ASSERT_EQUAL_UINT8(15U, menu_read_value(&ctx, A_ADDR)); + TEST_ASSERT_TRUE(ctx.dirty); + + menu_action(&ctx); + TEST_ASSERT_EQUAL_UINT8(0U, s.user.proto_slice[0]); +} + +static void test_bool_toggle(void) +{ + menu_ctx_t ctx; + settings_t s; + open_flat(&ctx, &s); + s.device.log_enabled = 1U; + menu_next(&ctx); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(A_LOG, menu_current(&ctx)); + + menu_action(&ctx); + TEST_ASSERT_EQUAL_UINT8(0U, s.device.log_enabled); + menu_action(&ctx); + TEST_ASSERT_EQUAL_UINT8(1U, s.device.log_enabled); +} + +static void test_exit_saves_when_dirty(void) +{ + menu_ctx_t ctx; + settings_t s; + open_flat(&ctx, &s); + + menu_next(&ctx); + menu_action(&ctx); + menu_next(&ctx); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(A_EXIT, menu_current(&ctx)); + + menu_action(&ctx); + TEST_ASSERT_FALSE(menu_is_open(&ctx)); + TEST_ASSERT_TRUE(ctx.save_requested); +} + +static void test_exit_no_save_when_clean(void) +{ + menu_ctx_t ctx; + settings_t s; + open_flat(&ctx, &s); + + menu_next(&ctx); + menu_next(&ctx); + menu_next(&ctx); + menu_action(&ctx); + + TEST_ASSERT_FALSE(menu_is_open(&ctx)); + TEST_ASSERT_FALSE(ctx.save_requested); +} + +static void test_ops_noop_when_closed(void) +{ + menu_ctx_t ctx; + settings_t s; + memset(&s, 0, sizeof(s)); + menu_init(&ctx, K_TREE_FLAT, A_COUNT, &s); + + menu_next(&ctx); + menu_action(&ctx); + TEST_ASSERT_FALSE(menu_is_open(&ctx)); + TEST_ASSERT_FALSE(ctx.dirty); +} + +/* ── Дерево с подменю ────────────────────────────────────────────────────── */ + +enum +{ + B_ROOT = 0, + B_A, + B_SUB, + B_EXIT, + B_B, + B_BACK, + B_COUNT, +}; + +static const menu_item_desc_t K_TREE_SUB[B_COUNT] = { + [B_ROOT] = { "Настройки", MENU_SUBMENU, 0, 0, 0, MENU_ROOT_INDEX, B_A, B_EXIT }, + [B_A] = { "A", MENU_BYTE, offsetof(settings_t, user.proto_slice[0]), 0, 9, MENU_ROOT_INDEX, 0, + 0 }, + [B_SUB] = { "Подменю", MENU_SUBMENU, 0, 0, 0, MENU_ROOT_INDEX, B_B, B_BACK }, + [B_EXIT] = { "Выход", MENU_BACK, 0, 0, 0, MENU_ROOT_INDEX, 0, 0 }, + [B_B] = { "B", MENU_BOOL, offsetof(settings_t, device.log_enabled), 0, 1, B_SUB, 0, 0 }, + [B_BACK] = { "Назад", MENU_BACK, 0, 0, 0, B_SUB, 0, 0 }, +}; + +static void open_sub(menu_ctx_t *p_ctx, settings_t *p_s) +{ + memset(p_s, 0, sizeof(*p_s)); + menu_init(p_ctx, K_TREE_SUB, B_COUNT, p_s); + menu_open(p_ctx); +} + +static void test_enter_submenu(void) +{ + menu_ctx_t ctx; + settings_t s; + open_sub(&ctx, &s); + + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(B_SUB, menu_current(&ctx)); + menu_action(&ctx); + TEST_ASSERT_EQUAL_UINT8(B_B, menu_current(&ctx)); +} + +static void test_back_from_submenu_returns_to_parent_item(void) +{ + menu_ctx_t ctx; + settings_t s; + open_sub(&ctx, &s); + + menu_next(&ctx); + menu_action(&ctx); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(B_BACK, menu_current(&ctx)); + + menu_action(&ctx); + TEST_ASSERT_TRUE(menu_is_open(&ctx)); + TEST_ASSERT_EQUAL_UINT8(B_SUB, menu_current(&ctx)); +} + +static void test_submenu_edit_persists_and_root_exit_saves(void) +{ + menu_ctx_t ctx; + settings_t s; + open_sub(&ctx, &s); + s.device.log_enabled = 0U; + + menu_next(&ctx); + menu_action(&ctx); + menu_action(&ctx); + TEST_ASSERT_EQUAL_UINT8(1U, s.device.log_enabled); + + menu_next(&ctx); + menu_action(&ctx); + menu_next(&ctx); + TEST_ASSERT_EQUAL_UINT8(B_EXIT, menu_current(&ctx)); + + menu_action(&ctx); + TEST_ASSERT_FALSE(menu_is_open(&ctx)); + TEST_ASSERT_TRUE(ctx.save_requested); +} + +int main(void) +{ + UNITY_BEGIN(); + + RUN_TEST(test_open_selects_first_toplevel); + RUN_TEST(test_next_wraps_around_level); + RUN_TEST(test_byte_increment_wraps); + RUN_TEST(test_bool_toggle); + RUN_TEST(test_exit_saves_when_dirty); + RUN_TEST(test_exit_no_save_when_clean); + RUN_TEST(test_ops_noop_when_closed); + + RUN_TEST(test_enter_submenu); + RUN_TEST(test_back_from_submenu_returns_to_parent_item); + RUN_TEST(test_submenu_edit_persists_and_root_exit_saves); + + return UNITY_END(); +} diff --git a/tests/host/tft_app_settings_store/README.md b/tests/host/tft_app_settings_store/README.md new file mode 100644 index 0000000..0be879b --- /dev/null +++ b/tests/host/tft_app_settings_store/README.md @@ -0,0 +1,36 @@ +# test_tft_app_settings_store + +## Модуль под тестом + +`firmware/tft_app/src/services/settings_store/src/settings_codec.c` — **чистая** +сериализация ядра настроек (страница `magic`/`version`/`CRC32`, дефолты). +Flash-адаптер `settings_store.c` (load/save через `bsp_qspi_flash`) — не здесь, +он проверяется на железе (HIL). + +## Категория + +A — платформонезависимый. `bsp/qspi_flash.h` подключается только ради +геометрии (`BSP_QSPI_SECTOR_SIZE`) — это чистый заголовок без SDK, моки не нужны. + +## Что проверяется + +- Размер `settings_page_t` — ровно один сектор QSPI (дублирует `_Static_assert`). +- Дефолты вменяемы (протокол NKU, логи вкл, громкости, адрес `proto_slice[0]`=0). +- `serialize` проставляет `magic` (`STFT`) и `version`. +- **Round-trip**: `serialize`→`deserialize` сохраняет поля (вес, вместимость, год, + адрес, тумблер логов, серийник). +- **Отбраковка**: битый `magic` / несовпадающая `version` / испорченный байт + данных (расходится CRC) → `deserialize` возвращает false. +- Свежестёртая страница (вся `0xFF`) → невалидна. + +## Гарантии + +- CRC32 считается по всем байтам страницы, кроме самого поля `crc32`. +- Любая невалидность (`magic`/`version`/`CRC`) → false; вызывающий (`settings_store.c`) + подставляет дефолты, устройство не «кирпичится». + +## Запуск + +```bash +ctest --preset host-debug-test -R test_tft_app_settings_store -V +``` diff --git a/tests/host/tft_app_settings_store/test_settings_codec.c b/tests/host/tft_app_settings_store/test_settings_codec.c index 733e72e..17cbdfb 100644 --- a/tests/host/tft_app_settings_store/test_settings_codec.c +++ b/tests/host/tft_app_settings_store/test_settings_codec.c @@ -1,9 +1,6 @@ /** * @file test_settings_codec.c - * @brief Host-тесты чистой сериализации настроек (settings_codec.c) — без QSPI. - * - * Формат страницы (magic/version/CRC32), дефолты, round-trip, отбраковка - * битых страниц. Flash-адаптер (settings_store.c) тестируется на железе. + * @brief Host-тесты settings_codec.c (сериализация ядра настроек). См. README.md. */ #include "settings_codec.h" @@ -12,14 +9,9 @@ #include #include -void setUp(void) -{ -} -void tearDown(void) -{ -} +void setUp(void) {} +void tearDown(void) {} -/* Страница обязана быть ровно одним сектором (дублирует _Static_assert в .c). */ static void test_page_is_one_sector(void) { TEST_ASSERT_EQUAL_UINT(BSP_QSPI_SECTOR_SIZE, sizeof(settings_page_t)); @@ -28,12 +20,12 @@ static void test_page_is_one_sector(void) static void test_defaults_sane(void) { const settings_t D = settings_defaults(); - TEST_ASSERT_EQUAL_UINT8(0U, D.device.protocol_id); /* NKU_CAN */ - TEST_ASSERT_EQUAL_UINT8(1U, D.device.log_enabled); /* логи вкл из коробки */ + TEST_ASSERT_EQUAL_UINT8(0U, D.device.protocol_id); + TEST_ASSERT_EQUAL_UINT8(1U, D.device.log_enabled); TEST_ASSERT_EQUAL_UINT8(2U, D.user.sound_volume_idx); TEST_ASSERT_EQUAL_UINT8(1U, D.user.music_volume_idx); TEST_ASSERT_EQUAL_UINT16(0U, D.user.max_load_kg); - TEST_ASSERT_EQUAL_UINT8(0U, D.user.proto_slice[0]); /* адрес НКУ = 0 */ + TEST_ASSERT_EQUAL_UINT8(0U, D.user.proto_slice[0]); } static void test_serialize_sets_magic_version(void) @@ -47,12 +39,12 @@ static void test_serialize_sets_magic_version(void) static void test_roundtrip_preserves_fields(void) { - settings_t in = settings_defaults(); - in.user.max_load_kg = 1000U; - in.user.max_cap_persons = 8U; + settings_t in = settings_defaults(); + in.user.max_load_kg = 1000U; + in.user.max_cap_persons = 8U; in.user.year_production = 25U; - in.user.proto_slice[0] = 7U; /* адрес станции 7 */ - in.device.log_enabled = 0U; + in.user.proto_slice[0] = 7U; + in.device.log_enabled = 0U; (void) snprintf(in.user.serial, SETTINGS_SERIAL_LEN, "AB1234"); settings_page_t page; @@ -90,7 +82,6 @@ static void test_bad_version_rejected(void) TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); } -/* Магик/версия валидны, но байт данных испорчен → расходится только CRC. */ static void test_bad_crc_rejected(void) { settings_t in = settings_defaults(); @@ -102,7 +93,6 @@ static void test_bad_crc_rejected(void) TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); } -/* Свежестёртый флеш (весь 0xFF) — невалиден (магик != 'STFT') → дефолты. */ static void test_erased_page_is_invalid(void) { settings_page_t page; diff --git a/tests/host/tft_app_sul_nku/README.md b/tests/host/tft_app_sul_nku/README.md new file mode 100644 index 0000000..ec2c0ef --- /dev/null +++ b/tests/host/tft_app_sul_nku/README.md @@ -0,0 +1,44 @@ +# test_sul_nku_can + +## Модуль под тестом + +`firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c` — чистый декодер +протокола НКУ-CAN (PACKET1..5 → `sul_result_t`). Плюс `elevator_model.c` +(`sul_default_state()`). + +## Категория + +A — платформонезависимый, без единого HAL-вызова. Golden-векторы CAN-кадров +строятся прямо в тесте, моки не нужны. + +## Что проверяется + +- **PACKET1** — направление (none/up/down/double), начало движения, коды режима + (пожар / сервис-МП1-МП2-ревизия / инструментальная погрузка / пожарный), + сброс устаревшего режима «нормальным» пакетом. +- **PACKET2 / PACKET4** — перегруз как **мультиисточник** (OR под-источников: + перегруз из PACKET4 переживает PACKET2-без-перегруза); сейсмоопасность (PACKET4). +- **PACKET3** — позиция кабины (двузначная, однозначная через пробел и через + байт 0, отрицательная, кириллица), `floor_num` (стандартный/отрицательный/ + подвальный), гонг (активен при сброшенном бите), временная погрузка + (`lading_secs`), мультиисточник `lading` (временная + инструментальная). +- **PACKET5** — следующий этаж с гейтом: показывается только пока кабина едет и + назначение ≠ текущий уровень; гаснет на прибытии. +- **Адрес станции** — сдвиг ID пакетов (`base | addr<<4/<<6`); базовый ID адреса 0 + становится чужим; клампинг адреса к 15. +- **Малформированные / чужие кадры** — неверный DLC → `SUL_STATUS_ERR`; код + символа вне таблицы → `ERR`; чужой ID → `SUL_STATUS_IGNORED` (выход не тронут). +- **Накопление состояния** между пакетами разных типов. + +## Гарантии + +- Декодер выдаёт **уровни, не события** (edge-детекция — забота презентации/аудио). +- Поля с несколькими источниками (overload, lading) — всегда OR под-источников: + пакет-без-сигнала не сбрасывает флаг, выставленный другим пакетом. +- `IGNORED`/`ERR` не трогают выходной `sul_result_t`. + +## Запуск + +```bash +ctest --preset host-debug-test -R test_sul_nku_can -V +``` diff --git a/tests/host/tft_app_sul_nku/test_sul_nku_can.c b/tests/host/tft_app_sul_nku/test_sul_nku_can.c index f02e4ef..5ee645e 100644 --- a/tests/host/tft_app_sul_nku/test_sul_nku_can.c +++ b/tests/host/tft_app_sul_nku/test_sul_nku_can.c @@ -1,9 +1,6 @@ /** * @file test_sul_nku_can.c - * @brief Host unit-тесты декодера НКУ-CAN (Фаза 1: PACKET1 + PACKET3). - * - * Чистый декодер, без единого HAL-вызова — никаких fff-моков не нужно, - * golden-векторы CAN-кадров строятся прямо в тесте. + * @brief Host unit-тесты декодера НКУ-CAN (PACKET1..5 + адрес). См. README.md. */ #include "domain/sul/nku_can.h" @@ -11,24 +8,23 @@ #include -void setUp(void) -{ -} -void tearDown(void) -{ -} +void setUp(void) {} +void tearDown(void) {} -/* ── Вспомогательные конструкторы кадров ─────────────────────────────── */ +/* ── Конструкторы кадров ─────────────────────────────────────────────────── */ #define PACKET1_ID 0x506U +#define PACKET2_ID 0x408U #define PACKET3_ID 0x508U +#define PACKET4_ID 0x50BU +#define PACKET5_ID 0x606U #define UNKNOWN_ID 0x123U static sul_frame_t make_packet1(uint8_t arrow_bits) { static uint8_t s_data[8]; memset(s_data, 0, sizeof(s_data)); - s_data[6] = arrow_bits; /* ARROW_MASK=0x03 на data[6] (DATA7) */ + s_data[6] = arrow_bits; return (sul_frame_t){ .id = PACKET1_ID, .bus = 0, .p_data = s_data, .len = 8U }; } @@ -36,17 +32,11 @@ static sul_frame_t make_packet3(uint8_t left, uint8_t right) { static uint8_t s_data[8]; memset(s_data, 0, sizeof(s_data)); - s_data[5] = left; /* FLOOR_MASK=0x3F на data[5] (left) */ - s_data[6] = right; /* FLOOR_MASK=0x3F на data[6] (right) */ + s_data[5] = left; + s_data[6] = right; return (sul_frame_t){ .id = PACKET3_ID, .bus = 0, .p_data = s_data, .len = 8U }; } -#define PACKET2_ID 0x408U -#define PACKET4_ID 0x50BU -#define PACKET5_ID 0x606U - -/* PACKET1 полный: стрелка [1:0], начало движения [3:2], код режима [7:4] в - * data[6]; числовой уровень остановки — data[3]&0x3F. */ static sul_frame_t make_packet1_full(uint8_t arrow, uint8_t movement, uint8_t icon_nibble, uint8_t level) { @@ -61,12 +51,10 @@ static sul_frame_t make_packet2(bool overload) { static uint8_t s_data[8]; memset(s_data, 0, sizeof(s_data)); - s_data[7] = overload ? 0x40U : 0x00U; /* WEIGHT_MASK */ + s_data[7] = overload ? 0x40U : 0x00U; return (sul_frame_t){ .id = PACKET2_ID, .bus = 0, .p_data = s_data, .len = 8U }; } -/* PACKET3 полный: символы этажа + гонг (активен при СБРОШЕННОМ бите 0x40 в - * data[3]) + временная погрузка (data[2] сек, data[3] низкий нибл — мин). */ static sul_frame_t make_packet3_full(uint8_t left, uint8_t right, bool gong, uint8_t secs, uint8_t mins) { @@ -83,8 +71,8 @@ static sul_frame_t make_packet4(bool overload, bool seismic) { static uint8_t s_data[8]; memset(s_data, 0, sizeof(s_data)); - s_data[5] = overload ? 0x40U : 0x00U; /* WEIGHT_MASK */ - s_data[0] = seismic ? 0x80U : 0x00U; /* SEISMIC_MASK */ + s_data[5] = overload ? 0x40U : 0x00U; + s_data[0] = seismic ? 0x80U : 0x00U; return (sul_frame_t){ .id = PACKET4_ID, .bus = 0, .p_data = s_data, .len = 8U }; } @@ -98,16 +86,16 @@ static sul_frame_t make_packet5(uint8_t next_left, uint8_t next_right, uint8_t d return (sul_frame_t){ .id = PACKET5_ID, .bus = 0, .p_data = s_data, .len = 8U }; } -/* ── PACKET1 — направление ───────────────────────────────────────────── */ +/* ── PACKET1 — направление ───────────────────────────────────────────────── */ static void test_packet1_none(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t FRAME = make_packet1(0U); + const sul_frame_t frame = make_packet1(0U); - TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction); } @@ -116,9 +104,9 @@ static void test_packet1_up(void) nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t FRAME = make_packet1(1U); + const sul_frame_t frame = make_packet1(1U); - TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); } @@ -127,9 +115,9 @@ static void test_packet1_down(void) nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t FRAME = make_packet1(2U); + const sul_frame_t frame = make_packet1(2U); - TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_DIR_DOWN, out.direction); } @@ -138,34 +126,33 @@ static void test_packet1_double(void) nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t FRAME = make_packet1(3U); + const sul_frame_t frame = make_packet1(3U); - TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_DIR_DOUBLE, out.direction); } -/* Верхние биты data[6] (выше ARROW_MASK) не должны влиять на результат. */ static void test_packet1_ignores_bits_outside_mask(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t FRAME = make_packet1(0xFCU | 1U); /* мусор в старших битах + arrow=1 */ + const sul_frame_t frame = make_packet1(0xFCU | 1U); - TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); } -/* ── PACKET3 — позиция ───────────────────────────────────────────────── */ +/* ── PACKET3 — позиция ───────────────────────────────────────────────────── */ static void test_packet3_two_digit_floor(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t FRAME = make_packet3(1U, 2U); /* "1","2" -> "12" */ + const sul_frame_t frame = make_packet3(1U, 2U); - TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL_STRING("12", out.pos); } @@ -174,13 +161,12 @@ static void test_packet3_single_digit_via_space(void) nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t FRAME = make_packet3(16U, 5U); /* left=SPACE, right="5" -> "5" */ + const sul_frame_t frame = make_packet3(16U, 5U); - TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL_STRING("5", out.pos); } -/* Легаси-квирк: left==0 трактуется как «пусто», как и SPACE — не «0» + right. */ static void test_packet3_single_digit_via_zero_byte(void) { nku_can_ctx_t ctx; @@ -197,7 +183,7 @@ static void test_packet3_negative_floor(void) nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t frame = make_packet3(22U, 1U); /* '-','1' -> "-1" */ + const sul_frame_t frame = make_packet3(22U, 1U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL_STRING("-1", out.pos); @@ -208,7 +194,7 @@ static void test_packet3_cyrillic_single_char(void) nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t frame = make_packet3(16U, 17U); /* SPACE, symbol_P -> "П" */ + const sul_frame_t frame = make_packet3(16U, 17U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL_STRING("П", out.pos); @@ -219,27 +205,27 @@ static void test_packet3_out_of_range_symbol_is_error(void) nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t frame = make_packet3(1U, 63U); /* 63 > SYMBOL_TOTAL-1(37) */ + const sul_frame_t frame = make_packet3(1U, 63U); TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out)); } -/* ── Кадры не по протоколу ────────────────────────────────────────────── */ +/* ── Кадры не по протоколу / малформированные ────────────────────────────── */ static void test_unknown_id_is_ignored_and_out_untouched(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - out.direction = SUL_DIR_UP; /* сентинел, decode() не должен его тронуть */ + out.direction = SUL_DIR_UP; (void) memset(out.pos, 'X', sizeof(out.pos)); uint8_t data[8] = { 0 }; const sul_frame_t frame = { .id = UNKNOWN_ID, .bus = 0, .p_data = data, .len = 8U }; TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &frame, &out)); - TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* не тронут */ - TEST_ASSERT_EQUAL_CHAR('X', out.pos[0]); /* не тронут */ + TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); + TEST_ASSERT_EQUAL_CHAR('X', out.pos[0]); } static void test_wrong_dlc_on_packet1_id_is_error(void) @@ -264,25 +250,37 @@ static void test_wrong_dlc_on_packet3_id_is_error(void) TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out)); } -/* ── Накопление состояния между разными пакетами ─────────────────────── */ +static void test_wrong_dlc_on_packet2_4_5_is_error(void) +{ + nku_can_ctx_t ctx; + nku_can_init(&ctx); + sul_result_t out; + uint8_t data[8] = { 0 }; + + const sul_frame_t f2 = { .id = PACKET2_ID, .bus = 0, .p_data = data, .len = 6U }; + TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f2, &out)); + const sul_frame_t f4 = { .id = PACKET4_ID, .bus = 0, .p_data = data, .len = 7U }; + TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f4, &out)); + const sul_frame_t f5 = { .id = PACKET5_ID, .bus = 0, .p_data = data, .len = 4U }; + TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f5, &out)); +} + +/* ── Накопление состояния ────────────────────────────────────────────────── */ -/* PACKET1 и PACKET3 несут разные поля — второй вызов обязан вернуть ПОЛНОЕ - * накопленное состояние (и pos, и direction), а не только то, что пришло в - * последнем кадре. Это ключевое свойство stateful-ctx декодера. */ static void test_state_accumulates_across_packet_types(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - const sul_frame_t f1 = make_packet1(1U); /* UP */ + const sul_frame_t f1 = make_packet1(1U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f1, &out)); - const sul_frame_t f3 = make_packet3(1U, 2U); /* "12" */ + const sul_frame_t f3 = make_packet3(1U, 2U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f3, &out)); TEST_ASSERT_EQUAL_STRING("12", out.pos); - TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* пережило кадр PACKET3 */ + TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); } static void test_init_resets_to_default(void) @@ -294,7 +292,7 @@ static void test_init_resets_to_default(void) TEST_ASSERT_EQUAL(SUL_DIR_NONE, ctx.state.direction); } -/* ── PACKET1 — режимы (код нибла data[6][7:4]) и начало движения ──────── */ +/* ── PACKET1 — режимы и начало движения ──────────────────────────────────── */ static void test_packet1_mode_fire(void) { @@ -309,7 +307,7 @@ static void test_packet1_mode_fire(void) static void test_packet1_mode_maintenance_variants(void) { - const uint8_t icons[] = { 0x30U, 0x50U, 0x40U }; /* МП1 / МП2 / ревизия */ + const uint8_t icons[] = { 0x30U, 0x50U, 0x40U }; for (size_t i = 0U; i < sizeof(icons); ++i) { nku_can_ctx_t ctx; @@ -343,7 +341,6 @@ static void test_packet1_mode_fireman(void) TEST_ASSERT_TRUE(out.fireman); } -/* Пакет без кода режима гасит устаревший режим (PACKET1-владелец). */ static void test_packet1_normal_clears_previous_mode(void) { nku_can_ctx_t ctx; @@ -374,7 +371,7 @@ static void test_packet1_movement_bits(void) TEST_ASSERT_FALSE(out.movement); } -/* ── PACKET2 / PACKET4 — перегруз (мультиисточник) и сейсмо ───────────── */ +/* ── PACKET2 / PACKET4 — перегруз (мультиисточник) и сейсмо ───────────────── */ static void test_packet2_overload_set_and_clear(void) { @@ -391,8 +388,6 @@ static void test_packet2_overload_set_and_clear(void) TEST_ASSERT_FALSE(out.overload); } -/* Ключевой мультиисточник: перегруз из PACKET4 не должен сбрасываться - * пакетом PACKET2 без перегруза (и наоборот) — выход = OR под-источников. */ static void test_overload_multisource_independence(void) { nku_can_ctx_t ctx; @@ -405,11 +400,11 @@ static void test_overload_multisource_independence(void) const sul_frame_t p2_off = make_packet2(false); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p2_off, &out)); - TEST_ASSERT_TRUE(out.overload); /* PACKET4-перегруз пережил PACKET2-без-перегруза */ + TEST_ASSERT_TRUE(out.overload); const sul_frame_t p4_off = make_packet4(false, false); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p4_off, &out)); - TEST_ASSERT_FALSE(out.overload); /* оба источника сняты */ + TEST_ASSERT_FALSE(out.overload); } static void test_packet4_seismic_set_and_clear(void) @@ -427,7 +422,7 @@ static void test_packet4_seismic_set_and_clear(void) TEST_ASSERT_FALSE(out.seismic); } -/* ── PACKET3 — гонг, временная погрузка, floor_num ───────────────────── */ +/* ── PACKET3 — гонг, временная погрузка, floor_num ───────────────────────── */ static void test_packet3_gong_active_when_bit_clear(void) { @@ -450,30 +445,26 @@ static void test_packet3_lading_time_seconds(void) nku_can_init(&ctx); sul_result_t out; - const sul_frame_t frame = make_packet3_full(1U, 2U, false, 30U, 1U); /* 1*60+30 */ + const sul_frame_t frame = make_packet3_full(1U, 2U, false, 30U, 1U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL_UINT16(90U, out.lading_secs); - TEST_ASSERT_TRUE(out.lading); /* временная погрузка тоже поднимает lading */ + TEST_ASSERT_TRUE(out.lading); } -/* Мультиисточник lading: временная (PACKET3) держит режим, даже когда PACKET1 - * не несёт инструментальной; и наоборот. */ static void test_lading_multisource_independence(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); sul_result_t out; - /* Временная погрузка идёт, PACKET1 без режима — lading остаётся. */ const sul_frame_t timed = make_packet3_full(1U, 2U, false, 10U, 0U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &timed, &out)); TEST_ASSERT_TRUE(out.lading); const sul_frame_t p1_normal = make_packet1_full(0U, 0U, 0x00U, 0U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p1_normal, &out)); - TEST_ASSERT_TRUE(out.lading); /* временная погрузка пережила «нормальный» PACKET1 */ + TEST_ASSERT_TRUE(out.lading); - /* Отсчёт истёк — lading гаснет. */ const sul_frame_t timed_zero = make_packet3_full(1U, 2U, false, 0U, 0U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &timed_zero, &out)); TEST_ASSERT_FALSE(out.lading); @@ -485,22 +476,22 @@ static void test_packet3_floor_num_derivation(void) sul_result_t out; nku_can_init(&ctx); - const sul_frame_t two_digit = make_packet3_full(1U, 2U, false, 0U, 0U); /* "12" */ + const sul_frame_t two_digit = make_packet3_full(1U, 2U, false, 0U, 0U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &two_digit, &out)); TEST_ASSERT_EQUAL_UINT8(12U, out.floor_num); nku_can_init(&ctx); - const sul_frame_t negative = make_packet3_full(22U, 1U, false, 0U, 0U); /* "-1" -> 41 */ + const sul_frame_t negative = make_packet3_full(22U, 1U, false, 0U, 0U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &negative, &out)); TEST_ASSERT_EQUAL_UINT8(41U, out.floor_num); nku_can_init(&ctx); - const sul_frame_t basement = make_packet3_full(17U, 1U, false, 0U, 0U); /* "П1" -> 51 */ + const sul_frame_t basement = make_packet3_full(17U, 1U, false, 0U, 0U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &basement, &out)); TEST_ASSERT_EQUAL_UINT8(51U, out.floor_num); } -/* ── PACKET5 — следующий этаж (гейт: только пока едет и назначение ≠ уровень) */ +/* ── PACKET5 — следующий этаж ────────────────────────────────────────────── */ static void test_packet5_next_shown_while_moving(void) { @@ -508,10 +499,10 @@ static void test_packet5_next_shown_while_moving(void) nku_can_init(&ctx); sul_result_t out; - const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U); /* UP, уровень 5 */ + const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &moving, &out)); - const sul_frame_t next = make_packet5(1U, 6U, 16U); /* "16", назначение 16 ≠ 5 */ + const sul_frame_t next = make_packet5(1U, 6U, 16U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out)); TEST_ASSERT_EQUAL_STRING("16", out.next); } @@ -522,7 +513,7 @@ static void test_packet5_next_suppressed_when_not_moving(void) nku_can_init(&ctx); sul_result_t out; - const sul_frame_t still = make_packet1_full(0U, 0U, 0x00U, 5U); /* нет движения */ + const sul_frame_t still = make_packet1_full(0U, 0U, 0x00U, 5U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &still, &out)); const sul_frame_t next = make_packet5(1U, 6U, 16U); @@ -539,12 +530,11 @@ static void test_packet5_next_suppressed_when_dest_equals_level(void) const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &moving, &out)); - const sul_frame_t next = make_packet5(1U, 6U, 5U); /* назначение == уровень */ + const sul_frame_t next = make_packet5(1U, 6U, 5U); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out)); TEST_ASSERT_EQUAL_STRING("", out.next); } -/* Следующий этаж гаснет на прибытии (PACKET1 сообщает «нет движения»). */ static void test_packet5_next_cleared_on_arrival(void) { nku_can_ctx_t ctx; @@ -562,54 +552,35 @@ static void test_packet5_next_cleared_on_arrival(void) TEST_ASSERT_EQUAL_STRING("", out.next); } -static void test_wrong_dlc_on_packet2_4_5_is_error(void) -{ - nku_can_ctx_t ctx; - nku_can_init(&ctx); - sul_result_t out; - uint8_t data[8] = { 0 }; +/* ── Адрес станции: сдвиг ID пакетов ─────────────────────────────────────── */ - const sul_frame_t f2 = { .id = PACKET2_ID, .bus = 0, .p_data = data, .len = 6U }; - TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f2, &out)); - const sul_frame_t f4 = { .id = PACKET4_ID, .bus = 0, .p_data = data, .len = 7U }; - TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f4, &out)); - const sul_frame_t f5 = { .id = PACKET5_ID, .bus = 0, .p_data = data, .len = 4U }; - TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f5, &out)); -} - -/* ── Адрес станции (Фаза 3.1): сдвиг ID пакетов ──────────────────────────── */ - -/* Адрес N сдвигает ID: PACKET1 = 0x506 | (N<<4). Базовый ID адреса 0 при этом - * становится чужим (IGNORED). */ static void test_address_shifts_packet_ids(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); - nku_can_set_address(&ctx, 1U); /* group4 = 0x10 */ + nku_can_set_address(&ctx, 1U); sul_result_t out; uint8_t data[8] = { 0 }; - data[6] = 1U; /* стрелка вверх */ + data[6] = 1U; const sul_frame_t shifted = { .id = 0x506U | 0x10U, .bus = 0, .p_data = data, .len = 8U }; TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &shifted, &out)); TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); - /* Базовый ID адреса 0 теперь не наш. */ const sul_frame_t base = { .id = 0x506U, .bus = 0, .p_data = data, .len = 8U }; TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &base, &out)); } -/* Адрес > 15 клампится к 15 (не выходит за 4-битный group4). */ static void test_address_clamped_to_max(void) { nku_can_ctx_t ctx; nku_can_init(&ctx); - nku_can_set_address(&ctx, 200U); /* клампится к 15 → group4 = 0xF0 */ + nku_can_set_address(&ctx, 200U); sul_result_t out; uint8_t data[8] = { 0 }; - data[6] = 2U; /* вниз */ + data[6] = 2U; const sul_frame_t f = { .id = 0x506U | 0xF0U, .bus = 0, .p_data = data, .len = 8U }; TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f, &out)); @@ -636,6 +607,7 @@ int main(void) RUN_TEST(test_unknown_id_is_ignored_and_out_untouched); RUN_TEST(test_wrong_dlc_on_packet1_id_is_error); RUN_TEST(test_wrong_dlc_on_packet3_id_is_error); + RUN_TEST(test_wrong_dlc_on_packet2_4_5_is_error); RUN_TEST(test_state_accumulates_across_packet_types); RUN_TEST(test_init_resets_to_default); @@ -661,8 +633,6 @@ int main(void) RUN_TEST(test_packet5_next_suppressed_when_dest_equals_level); RUN_TEST(test_packet5_next_cleared_on_arrival); - RUN_TEST(test_wrong_dlc_on_packet2_4_5_is_error); - RUN_TEST(test_address_shifts_packet_ids); RUN_TEST(test_address_clamped_to_max);