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).
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// Тесты конверсий: шаблонные по умолчанию, 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); // насыщение или корректное значение — но не падение
|
||||
}
|
||||
Reference in New Issue
Block a user