Files
petr.polezhaev fa1718f405 core(M3): aircon-слой — шаблоны A/B/F, конверсии+override, публичный API + C-API
- include/fgl-aircon: types.hpp (Prop/State/Error/Value/Template/
  Conversion-linear-custom/PropOverride/Config с задокументированными
  lifetime-контрактами), templates.hpp (PropInfo-таблицы, интроспекция,
  конверсии c контрактом Linear num/den!=0), session.hpp (Session:
  set_int/bool/string, get_prop, batch, cached, set_log_sink с контрактом),
  c_api.h (extern "C" для cffi/pyfglair).
- src/aircon: tables.cpp (шаблоны A=33/B=20/F=34 по PROTOCOL §8.2,
  template_detect + template_is_known; конверсии: default тождественно,
  DisplayTemperature raw 0.01°C(+5000)→API 0.1°C; linear с инверсией
  raw=(api-offset)*den/num и насыщением __builtin_*_overflow; custom fn;
  af_*_swing base_type integer, kind bool), session.cpp (маппинг имя↔Prop,
  коэрсинг int-push к kBool по таблице, кэш со спинлоком, оптимистичный кэш,
  валидация overrides: терминатор/linear/custom_fn), c_api.cpp (шейм,
  static_assert'ы на все enum-значения, fgl_template_detect -1 для
  неизвестных).
- ayla: set_property_string (Command.str_value, coalescing копирует строку
  в т.ч. в batch-ветке); kMaxQueue 48 + static_assert (полный батч A=33);
  commit_batch всегда ставит notify; oversized-строка дропает событие
  (кэш не затирается); max_queue default 40.
- tests/aircon: test_tables (составы/атрибуты/дубли/template_detect/
  swings-integer), test_convert (default/linear/custom/диапазоны/насыщение
  в обе стороны/UB-экстремумы), aircon_runner + test_aircon_mock
  (маппинг+конверсии+batch=1-notify с ожиданием async REG; SET+кэш+
  RO/не-шаблон отказы+клэмпинг raw 450 на модуле; C-API smoke).
- Прибор AP-WC1E: полный батч шаблона A одним notify — 28 int/bool свойств
  (DisplayTemperature 7000→200=20.0°C, DeviceCapabilities=5119), boolean SET
  (JSON true) принят, RO/клэмп/NOTMPL отказы корректны.
- CI: 10/10 ×3 стабильно (3 полных прогона); ESP-IDF esp32 build complete.
Ревью независимым агентом: 3 круга — B1 kMaxQueue<батча, B2 linear div/0,
B3 c_api контракт -1, B4 batch-строки, BL1 INT64_MIN negation UB,
BL2 DisplayTemperature экстремумы, flake aircon_mock — всё закрыто;
APPROVED (условие круга 3: фикс + зелёный CI ×3).
2026-09-28 20:16:54 +03:00

190 lines
8.4 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Тесты конверсий: шаблонные по умолчанию, linear/custom override, диапазоны.
#include "doctest/doctest.h"
#include <cstring>
#include "fgl-aircon/templates.hpp"
using fgl::aircon::ConvKind;
using fgl::aircon::Conversion;
using fgl::aircon::Linear;
using fgl::aircon::Prop;
using fgl::aircon::PropOverride;
using fgl::aircon::convert_from_input;
using fgl::aircon::convert_to_display;
using fgl::aircon::prop_api_range;
using fgl::aircon::Template;
namespace {
const PropOverride kTerm{Prop::kCount};
}
TEST_CASE("Конверсия по умолчанию: тождество и DisplayTemperature") {
// Большинство свойств тождественны (API == raw).
CHECK(convert_to_display(Template::kA, Prop::OperationMode, 6, &kTerm) == 6);
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 200, &kTerm) == 200);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 250, &kTerm) == 250);
// DisplayTemperature: raw 0.01°C(+5000) -> API 0.1°C.
// Прибор: 7000 → 20.0°C → 200 (проверено на приборе).
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7000, &kTerm) == 200);
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7300, &kTerm) == 230);
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 4975, &kTerm) == -2);
// Округление к нулю.
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7004, &kTerm) == 200);
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7005, &kTerm) == 200);
// Обратная (симметричная, для тестов; свойство RO).
CHECK(convert_from_input(Template::kA, Prop::DisplayTemperature, 230, &kTerm) == 7300);
}
TEST_CASE("Linear override: to_display и from_input") {
// ТЕ ЖЕ коэффициенты в оба направления (from_input = инверсия):
// api = raw*1/2 (250 -> 125); raw = api*2 (125 -> 250).
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.to_display.kind = ConvKind::kLinear;
ov.to_display.linear = Linear{1, 2, 0};
ov.from_input.kind = ConvKind::kLinear;
ov.from_input.linear = Linear{1, 2, 0};
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 250, list) == 125);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 125, list) == 250);
// Смещение: Фаренгейт из сырых 0.1°C: F = raw*9/5 + 320 (те же коэфф.).
PropOverride ovF{};
ovF.prop = Prop::AdjustTemperature;
ovF.to_display.kind = ConvKind::kLinear;
ovF.to_display.linear = Linear{9, 5, 320};
ovF.from_input.kind = ConvKind::kLinear;
ovF.from_input.linear = Linear{9, 5, 320};
const PropOverride listF[] = {ovF, kTerm};
// 25.0°C = 77°F: 250*9/5+320 = 770 (0.1°F).
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 250, listF) == 770);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 770, listF) == 250);
// Отрицательные: -100 (−10°C) → 140 (14.0°F).
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, -100, listF) == 140);
// Override не задет другие свойства.
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 3, listF) == 3);
}
namespace {
int64_t double_it(int64_t raw, void*) { return raw * 2; }
int64_t half_it(int64_t v, void*) { return v / 2; }
}
TEST_CASE("Custom fn override") {
PropOverride ov{};
ov.prop = Prop::FanSpeed;
ov.to_display.kind = ConvKind::kCustomFn;
ov.to_display.fn = double_it;
ov.from_input.kind = ConvKind::kCustomFn;
ov.from_input.fn = half_it;
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 2, list) == 4);
CHECK(convert_from_input(Template::kA, Prop::FanSpeed, 4, list) == 2);
}
TEST_CASE("Диапазоны API-значений: шаблонные и override") {
int64_t lo = 0, hi = 0;
// adjust_temperature: raw -100..450 → тождественно.
REQUIRE(prop_api_range(Template::kA, Prop::AdjustTemperature, &kTerm, &lo, &hi));
CHECK(lo == -100);
CHECK(hi == 450);
// display_temperature: raw 4000..9500 (таблица приложения −10..45°C)
// → api -100..450.
REQUIRE(prop_api_range(Template::kA, Prop::DisplayTemperature, &kTerm, &lo, &hi));
CHECK(lo == -100);
CHECK(hi == 450);
// error_code 0..4095.
REQUIRE(prop_api_range(Template::kA, Prop::ErrorCode, &kTerm, &lo, &hi));
CHECK(lo == 0);
CHECK(hi == 4095);
// Без диапазона (0..0 в таблице).
CHECK_FALSE(prop_api_range(Template::kA, Prop::DeviceName, &kTerm, &lo, &hi));
CHECK_FALSE(prop_api_range(Template::kA, Prop::OpStatus, &kTerm, &lo, &hi));
// Override-диапазон перекрывает шаблонный.
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.has_range = true;
ov.min = 160;
ov.max = 300; // 16..30°C — фактические границы прибора
const PropOverride list[] = {ov, kTerm};
REQUIRE(prop_api_range(Template::kA, Prop::AdjustTemperature, list, &lo, &hi));
CHECK(lo == 160);
CHECK(hi == 300);
// Свойство не из шаблона.
CHECK_FALSE(prop_api_range(Template::kB, Prop::AfVerticalDirection, &kTerm,
&lo, &hi));
}
TEST_CASE("Насыщение linear при переполнении int64") {
// to_display: api = raw*num/den; raw=4 * num=INT64_MAX/2 -> переполнение.
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.to_display.kind = ConvKind::kLinear;
ov.to_display.linear = Linear{INT64_MAX / 2, 1, 0};
// from_input: raw = (api-offset)*den/num; api=4 * den=INT64_MAX/2.
ov.from_input.kind = ConvKind::kLinear;
ov.from_input.linear = Linear{1, INT64_MAX / 2, 0};
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 4, list) ==
INT64_MAX);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 4, list) ==
INT64_MAX);
// Смещение с переполнением сложения.
PropOverride ov2{};
ov2.prop = Prop::FanSpeed;
ov2.to_display.kind = ConvKind::kLinear;
ov2.to_display.linear = Linear{1, 1, INT64_MAX};
const PropOverride list2[] = {ov2, kTerm};
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 2, list2) ==
INT64_MAX);
}
TEST_CASE("template_is_known") {
using fgl::aircon::template_is_known;
CHECK(template_is_known("AP-WC1E"));
CHECK_FALSE(template_is_known("UNKNOWN"));
CHECK_FALSE(template_is_known(nullptr));
}
TEST_CASE("Насыщение linear: негативные экстремумы (UBSan)") {
// raw=-4 * num=INT64_MAX/2 -> INT64_MIN-переполнение -> насыщение MIN.
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.to_display.kind = ConvKind::kLinear;
ov.to_display.linear = Linear{INT64_MAX / 2, 1, 0};
ov.from_input.kind = ConvKind::kLinear;
ov.from_input.linear = Linear{1, INT64_MAX / 2, 0};
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, -4, list) ==
INT64_MIN); // (-4)*(MAX/2) -> переполнение в минус
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, -4, list) ==
INT64_MIN);
// add-переполнение в минус.
PropOverride ov2{};
ov2.prop = Prop::FanSpeed;
ov2.to_display.kind = ConvKind::kLinear;
ov2.to_display.linear = Linear{1, 1, INT64_MIN};
const PropOverride list2[] = {ov2, kTerm};
// q=2 + offset=INT64_MIN = INT64_MIN+2 — корректно (в диапазоне int64),
// насыщение не требуется; убеждаемся что нет падения/UB.
int64_t v = convert_to_display(Template::kA, Prop::FanSpeed, 2, list2);
CHECK(v == INT64_MIN + 2);
}
TEST_CASE("DisplayTemperature: экстремумы без UB") {
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature,
INT64_MIN + 1, &kTerm) != 0);
// Значение определяется насыщением, точный результат не важен — нет UB.
int64_t r = convert_from_input(Template::kA, Prop::DisplayTemperature,
INT64_MIN / 10, &kTerm);
CHECK(r != 0); // насыщение или корректное значение — но не падение
}