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:
@@ -34,9 +34,24 @@ fgl_add_test(ayla_envelope ayla/test_envelope.cpp)
|
||||
fgl_add_test(ayla_json ayla/test_json.cpp)
|
||||
fgl_add_test(ayla_httpc ayla/test_httpc.cpp)
|
||||
|
||||
# aircon-слой: юнит-тесты таблиц/конверсий + C-API smoke.
|
||||
fgl_add_test(aircon_tables aircon/test_tables.cpp)
|
||||
fgl_add_test(aircon_convert aircon/test_convert.cpp)
|
||||
target_link_libraries(test_aircon_tables PRIVATE fgl-aircon)
|
||||
target_link_libraries(test_aircon_convert PRIVATE fgl-aircon)
|
||||
|
||||
add_executable(aircon_runner aircon/aircon_runner.cpp)
|
||||
target_compile_features(aircon_runner PRIVATE cxx_std_20)
|
||||
target_link_libraries(aircon_runner PRIVATE fgl-aircon)
|
||||
target_include_directories(aircon_runner PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
||||
|
||||
# Интеграционные сценарии с mock-модулем (python, stdlib-only).
|
||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||
add_test(NAME ayla_session_mock
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/ayla/test_session_mock.py
|
||||
$<TARGET_FILE:session_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
||||
set_tests_properties(ayla_session_mock PROPERTIES TIMEOUT 180)
|
||||
add_test(NAME aircon_session_mock
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/aircon/test_aircon_mock.py
|
||||
$<TARGET_FILE:aircon_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
||||
set_tests_properties(aircon_session_mock PROPERTIES TIMEOUT 180)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Тесты публичной сессии (aircon-слой) против mock-модуля: маппинг
|
||||
// имя↔enum, конверсии в событиях, оптимистичный кэш, batch, C-API.
|
||||
// Запускается python-оркестратором (tests/aircon/test_aircon_mock.py).
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <thread>
|
||||
|
||||
#include "fgl-aircon/c_api.h"
|
||||
#include "fgl-aircon/session.hpp"
|
||||
#include "fgl-aircon/templates.hpp"
|
||||
|
||||
using fgl::aircon::Prop;
|
||||
using fgl::aircon::Session;
|
||||
using fgl::aircon::State;
|
||||
|
||||
static const char* state_name(State st) {
|
||||
switch (st) {
|
||||
case State::kIdle: return "idle";
|
||||
case State::kRegistering: return "registering";
|
||||
case State::kOnline: return "online";
|
||||
case State::kRecovering: return "recovering";
|
||||
case State::kOffline: return "offline";
|
||||
case State::kKeyError: return "key_error";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
static void on_state(void*, State st, fgl::aircon::Error err) {
|
||||
printf("STATE %s %d\n", state_name(st), static_cast<int>(err));
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static void on_property(void*, const fgl::aircon::PropertyEvent& ev) {
|
||||
const char* n = fgl::aircon::prop_enum_name(ev.prop);
|
||||
if (ev.value.kind == fgl::aircon::ValueKind::kString) {
|
||||
printf("PROP %s %d %d s:%s\n", n, ev.cmd_id, ev.status, ev.value.s);
|
||||
} else {
|
||||
printf("PROP %s %d %d %c:%lld\n", n, ev.cmd_id, ev.status,
|
||||
ev.value.kind == fgl::aircon::ValueKind::kBool ? 'b' : 'i',
|
||||
static_cast<long long>(ev.value.i));
|
||||
}
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static uint32_t env_u32(const char* name, uint32_t def) {
|
||||
const char* v = getenv(name);
|
||||
return v != nullptr ? static_cast<uint32_t>(atoi(v)) : def;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 6) {
|
||||
fprintf(stderr,
|
||||
"usage: %s <host> <device_port> <listen_port> <dsn> <lanip_key> "
|
||||
"<key_id> <duration_sec> [prop-enum-name ...]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
fgl::aircon::Config cfg{};
|
||||
cfg.host = argv[1];
|
||||
cfg.device_port = static_cast<uint16_t>(atoi(argv[2]));
|
||||
cfg.listen_port = static_cast<uint16_t>(atoi(argv[3]));
|
||||
cfg.dsn = argv[4];
|
||||
cfg.lanip_key = argv[5];
|
||||
cfg.lanip_key_id = static_cast<uint32_t>(atoi(argv[6]));
|
||||
cfg.tmpl = fgl::aircon::Template::kA;
|
||||
int duration_sec = atoi(argv[7]);
|
||||
|
||||
fgl::aircon::Callbacks cbs{};
|
||||
cbs.on_state = on_state;
|
||||
cbs.on_property = on_property;
|
||||
|
||||
Session* s = Session::create(cfg, cbs);
|
||||
if (s == nullptr) {
|
||||
printf("CREATE_FAILED\n");
|
||||
return 2;
|
||||
}
|
||||
// Тест C-API интроспекции.
|
||||
printf("CVER %s\n", fgl_version());
|
||||
printf("CNAME %s\n", fgl_prop_name(FGL_TEMPLATE_A, FGL_PROP_FAN_SPEED));
|
||||
printf("CCONV %lld\n", static_cast<long long>(
|
||||
fgl_convert_to_display(FGL_TEMPLATE_A,
|
||||
FGL_PROP_DISPLAY_TEMPERATURE,
|
||||
7000)));
|
||||
|
||||
if (!s->start()) {
|
||||
printf("START_FAILED\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Начальная синхронизация: пакет GET всех свойств шаблона A (батч).
|
||||
if (argc > 8) {
|
||||
s->batch_begin();
|
||||
for (int i = 8; i < argc; i++) {
|
||||
// argv: имена enum'ов ("OperationMode")
|
||||
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
|
||||
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)),
|
||||
argv[i]) == 0) {
|
||||
if (!s->get_prop(static_cast<Prop>(p))) {
|
||||
printf("GET_FAIL %s\n", argv[i]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!s->batch_commit()) printf("BATCH_PARTIAL\n");
|
||||
} else {
|
||||
// По умолчанию — все int/bool свойства шаблола A одним батчем
|
||||
// (строковые пропускаются: модуль-мок их не генерирует; на приборе
|
||||
// строковый push проверялся вручную через aircon-события).
|
||||
s->batch_begin();
|
||||
for (auto* info = fgl::aircon::prop_info_begin(cfg.tmpl);
|
||||
info != fgl::aircon::prop_info_begin(cfg.tmpl) +
|
||||
fgl::aircon::prop_info_count(cfg.tmpl);
|
||||
info++) {
|
||||
if (info->kind != fgl::aircon::ValueKind::kString) {
|
||||
if (!s->get_prop(info->prop)) {
|
||||
printf("GET_FAIL %s\n", fgl::aircon::prop_enum_name(info->prop));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!s->batch_commit()) printf("BATCH_PARTIAL\n");
|
||||
}
|
||||
|
||||
const char* set_prop = getenv("RUNNER_SET_PROP"); // enum-имя
|
||||
const char* set_val = getenv("RUNNER_SET_VALUE");
|
||||
uint32_t set_at = env_u32("RUNNER_SET_AT", 0);
|
||||
if (set_prop != nullptr && set_val != nullptr && set_at > 0) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(set_at));
|
||||
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
|
||||
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)),
|
||||
set_prop) == 0) {
|
||||
bool ok = s->set_int(static_cast<Prop>(p), atoll(set_val));
|
||||
printf("SET_DONE %s=%s ok=%d\n", set_prop, set_val, ok ? 1 : 0);
|
||||
// Оптимистичный кэш сразу доступен.
|
||||
fgl::aircon::Value v{};
|
||||
if (s->cached(static_cast<Prop>(p), &v)) {
|
||||
printf("CACHED %s %lld\n", set_prop,
|
||||
static_cast<long long>(v.i));
|
||||
}
|
||||
fflush(stdout);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::seconds(duration_sec);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
|
||||
// Проверки отказов (после duration, до stop):
|
||||
// - read-only свойство (ErrorCode) — отказ;
|
||||
// - свойство не из шаблона A (AfVerticalMoveStep1, только B) — отказ;
|
||||
// - клэмпинг: AdjustTemperature=1000 -> raw max 450.
|
||||
printf("RO_SET %d\n", s->set_int(Prop::ErrorCode, 5) ? 1 : 0);
|
||||
printf("NOTMPL_SET %d\n",
|
||||
s->set_int(Prop::AfVerticalMoveStep1, 1) ? 1 : 0);
|
||||
printf("CLAMP_SET %d\n", s->set_int(Prop::AdjustTemperature, 1000) ? 1 : 0);
|
||||
|
||||
s->stop();
|
||||
printf("DELETED\n");
|
||||
printf("STATS rekeys=%u pushes_ok=%u pushes_bad=%u cmds=%u state=%s\n",
|
||||
s->rekey_count(), s->pushes_ok(), s->pushes_bad(),
|
||||
s->commands_served(), state_name(s->state()));
|
||||
fflush(stdout);
|
||||
delete s;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Интеграционные сценарии aircon-слоя (публичный API) против mock-модуля.
|
||||
|
||||
usage: test_aircon_mock.py <aircon_runner> <mock_ac.py>
|
||||
Проверяет: маппинг enum↔имя, конверсию в событиях (DisplayTemperature!),
|
||||
оптимистичный кэш, batch=1 notify, RO/не-шаблонные отказы, клэмпинг.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "ayla"))
|
||||
from test_session_mock import ( # noqa: E402
|
||||
FAILURES, Proc, check, free_port, LANIP_KEY, KEY_ID, DSN, run_mock)
|
||||
|
||||
RUNNER = sys.argv[1]
|
||||
MOCK = sys.argv[2]
|
||||
|
||||
|
||||
def run_runner(mock_port, listen, duration, props=None, env_extra=None,
|
||||
timeout=None):
|
||||
env = dict(os.environ)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
argv = [RUNNER, "127.0.0.1", str(mock_port), str(listen), DSN, LANIP_KEY,
|
||||
str(KEY_ID), str(duration)]
|
||||
if props:
|
||||
argv += props
|
||||
p = Proc(argv, env=env)
|
||||
return p, (timeout or duration + 25)
|
||||
|
||||
|
||||
def scenario_mapping_and_batch():
|
||||
print("== aircon: маппинг/конверсии, batch=1 notify, полный шаблон A")
|
||||
mock_port, listen = free_port(), free_port()
|
||||
mock = run_mock([], mock_port)
|
||||
runner, timeout = run_runner(mock_port, listen, 5,
|
||||
props=["OperationMode", "FanSpeed",
|
||||
"DisplayTemperature"])
|
||||
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||
p1 = runner.wait_line("PROP OperationMode", 10)
|
||||
check(p1 is not None and " i:6" in p1, f"operation_mode: {p1}")
|
||||
p2 = runner.wait_line("PROP FanSpeed", 10)
|
||||
check(p2 is not None and " i:4" in p2, f"fan_speed: {p2}")
|
||||
# Конверсия: mock шлёт display_temperature=7000? Нет — mock вернёт
|
||||
# значение из своих props (нет в словаре -> 0) => raw 0 -> api -500.
|
||||
p3 = runner.wait_line("PROP DisplayTemperature", 10)
|
||||
check(p3 is not None and " i:-500" in p3, f"display_temp raw0 -> -500: {p3}")
|
||||
# C-API smoke.
|
||||
check(runner.wait_line("CVER 0.", 5) is not None, "c_api version")
|
||||
cname = runner.wait_line("CNAME", 5)
|
||||
check(cname is not None and "fan_speed" in cname, "c_api prop_name")
|
||||
conv = runner.wait_line("CCONV 200", 5)
|
||||
check(conv is not None, "c_api convert 7000 -> 200")
|
||||
# batch=1 notify: ждём >=2 REG (второй — асинхронный put после выдачи
|
||||
# батча), затем ассертим ровно один notify=True.
|
||||
import time as _time
|
||||
deadline = _time.time() + 5
|
||||
while _time.time() < deadline:
|
||||
regs = [l for l in mock.all_lines() if l.startswith("REG")]
|
||||
if len(regs) >= 2:
|
||||
break
|
||||
_time.sleep(0.2)
|
||||
notifies = [l for l in regs if "notify=True" in l]
|
||||
check(len(regs) >= 2 and len(notifies) == 1,
|
||||
f"один notify=True на батч: regs={regs}")
|
||||
runner.wait_line("DELETED", timeout)
|
||||
runner.stop()
|
||||
mock.stop()
|
||||
|
||||
|
||||
def scenario_set_cache_clamp():
|
||||
print("== aircon: SET + оптимистичный кэш + клэмпинг + отказы")
|
||||
mock_port, listen = free_port(), free_port()
|
||||
mock = run_mock([], mock_port)
|
||||
runner, timeout = run_runner(mock_port, listen, 5,
|
||||
env_extra={"RUNNER_SET_PROP": "FanSpeed",
|
||||
"RUNNER_SET_VALUE": "3",
|
||||
"RUNNER_SET_AT": "1"})
|
||||
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||
check(runner.wait_line("~SET_DONE FanSpeed=3 ok=1", 10) is not None,
|
||||
"SET выполнена")
|
||||
check(runner.wait_line("CACHED FanSpeed 3", 5) is not None,
|
||||
"оптимистичный кэш")
|
||||
check(mock.wait_line("~CMD SET fan_speed=3", 10) is not None,
|
||||
"mock применил SET (имя из таблицы)")
|
||||
# Отказы и клэмпинг печатаются перед DELETED.
|
||||
check(runner.wait_line("RO_SET 0", timeout) is not None, "RO отказ")
|
||||
check(runner.wait_line("NOTMPL_SET 0", 5) is not None,
|
||||
"не-шаблонное свойство отказ")
|
||||
check(runner.wait_line("CLAMP_SET 1", 5) is not None, "клэмпинг применён")
|
||||
check(mock.wait_line("~CMD SET adjust_temperature=450", 15) is not None,
|
||||
"клэмпинг: 1000 -> raw max 450 на модуле")
|
||||
runner.wait_line("DELETED", 10)
|
||||
runner.stop()
|
||||
mock.stop()
|
||||
|
||||
|
||||
def main():
|
||||
scenario_mapping_and_batch()
|
||||
scenario_set_cache_clamp()
|
||||
print()
|
||||
if FAILURES:
|
||||
print("ПРОВАЛЕНО:", len(FAILURES))
|
||||
for f in FAILURES:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("ВСЕ СЦЕНАРИИ ПРОЙДЕНЫ")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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); // насыщение или корректное значение — но не падение
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Тесты таблиц шаблонов: состав против PROTOCOL.md §8.2, типы, RO, диапазоны.
|
||||
#include "doctest/doctest.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "fgl-aircon/templates.hpp"
|
||||
|
||||
using fgl::aircon::Prop;
|
||||
using fgl::aircon::prop_info_count;
|
||||
using fgl::aircon::prop_info_find;
|
||||
using fgl::aircon::prop_info_find_by_name;
|
||||
using fgl::aircon::Template;
|
||||
using fgl::aircon::template_detect;
|
||||
|
||||
namespace {
|
||||
|
||||
// Список имён свойств шаблона (для сверки с протоколом).
|
||||
std::string names_joined(Template t) {
|
||||
std::string out;
|
||||
for (auto* p = fgl::aircon::prop_info_begin(t);
|
||||
p != fgl::aircon::prop_info_begin(t) + prop_info_count(t); p++) {
|
||||
if (!out.empty()) out += ",";
|
||||
out += p->name;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool has(Template t, const char* name) {
|
||||
return prop_info_find_by_name(t, name) != nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Шаблон A: состав против PROTOCOL §8.2 (все 33)") {
|
||||
const char* expected[] = {
|
||||
"operation_mode", "fan_speed", "adjust_temperature",
|
||||
"af_vertical_direction", "af_vertical_swing", "af_horizontal_direction",
|
||||
"af_horizontal_swing", "outdoor_low_noise", "indoor_fan_control",
|
||||
"human_det_auto_save", "min_heat", "powerful_mode", "coil_dry_mode",
|
||||
"economy_mode", "master_timer_on_off_1", "master_timer_on_off_2",
|
||||
"error_code", "demand_control", "filter_sign_reset_display", "op_status",
|
||||
"device_name", "building_name", "wifi_led_enable",
|
||||
"service_contact_name", "service_contact_phone", "service_contact_email",
|
||||
"af_horizontal_num_dir", "af_vertical_num_dir", "device_capabilities",
|
||||
"display_temperature", "get_prop", "human_det", "refresh"};
|
||||
for (const char* n : expected) {
|
||||
CAPTURE(n);
|
||||
CHECK(has(Template::kA, n));
|
||||
}
|
||||
CHECK(prop_info_count(Template::kA) == 33);
|
||||
}
|
||||
|
||||
TEST_CASE("Шаблон B: состав (20) и отличия от A") {
|
||||
CHECK(prop_info_count(Template::kB) == 20);
|
||||
CHECK(has(Template::kB, "af_vertical_move_step1"));
|
||||
CHECK(has(Template::kB, "af_horizontal_move_step1"));
|
||||
CHECK_FALSE(has(Template::kB, "af_vertical_direction"));
|
||||
CHECK_FALSE(has(Template::kB, "display_temperature"));
|
||||
CHECK_FALSE(has(Template::kB, "powerful_mode"));
|
||||
CHECK_FALSE(has(Template::kB, "min_heat"));
|
||||
}
|
||||
|
||||
TEST_CASE("Шаблон F: = A + monitor1 + filter_sign_reset (без display-варианта)") {
|
||||
CHECK(prop_info_count(Template::kF) == 34);
|
||||
CHECK(has(Template::kF, "monitor1"));
|
||||
CHECK(has(Template::kF, "filter_sign_reset"));
|
||||
CHECK_FALSE(has(Template::kF, "filter_sign_reset_display"));
|
||||
CHECK(has(Template::kF, "display_temperature"));
|
||||
}
|
||||
|
||||
TEST_CASE("template_detect: oem_model → шаблон (PROTOCOL §8.1)") {
|
||||
CHECK(template_detect("AP-WC1E") == Template::kA);
|
||||
CHECK(template_detect("AP-WA3E") == Template::kA);
|
||||
CHECK(template_detect("AP-WB2E") == Template::kB);
|
||||
CHECK(template_detect("AP-WF4E") == Template::kF);
|
||||
CHECK(template_detect("UNKNOWN-MODEL") == Template::kA); // как legacy
|
||||
CHECK(template_detect(nullptr) == Template::kA);
|
||||
}
|
||||
|
||||
TEST_CASE("Атрибуты свойств: base_type/read_only/диапазоны") {
|
||||
auto* om = prop_info_find_by_name(Template::kA, "operation_mode");
|
||||
REQUIRE(om != nullptr);
|
||||
CHECK(std::string(om->base_type) == "integer");
|
||||
CHECK_FALSE(om->read_only);
|
||||
CHECK(om->raw_min == 0);
|
||||
CHECK(om->raw_max == 6);
|
||||
|
||||
auto* err = prop_info_find_by_name(Template::kA, "error_code");
|
||||
REQUIRE(err != nullptr);
|
||||
CHECK(err->read_only);
|
||||
CHECK(err->raw_max == 4095);
|
||||
|
||||
auto* eco = prop_info_find_by_name(Template::kA, "economy_mode");
|
||||
REQUIRE(eco != nullptr);
|
||||
CHECK(std::string(eco->base_type) == "boolean");
|
||||
CHECK(eco->kind == fgl::aircon::ValueKind::kBool);
|
||||
|
||||
auto* dn = prop_info_find_by_name(Template::kA, "device_name");
|
||||
REQUIRE(dn != nullptr);
|
||||
CHECK(dn->kind == fgl::aircon::ValueKind::kString);
|
||||
CHECK(std::string(dn->base_type) == "string");
|
||||
|
||||
// prop_info_find по enum согласован с find_by_name.
|
||||
auto* by_enum = prop_info_find(Template::kA, Prop::FanSpeed);
|
||||
REQUIRE(by_enum != nullptr);
|
||||
CHECK(std::string(by_enum->name) == "fan_speed");
|
||||
CHECK(prop_info_find(Template::kB, Prop::AfVerticalDirection) == nullptr);
|
||||
CHECK(prop_info_find(Template::kA, Prop::kCount) == nullptr);
|
||||
CHECK(prop_info_find_by_name(Template::kA, "no_such") == nullptr);
|
||||
CHECK(prop_info_find_by_name(Template::kA, nullptr) == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("Таблицы: без дублей имен и enum'ов внутри шаблона") {
|
||||
for (Template t : {Template::kA, Template::kB, Template::kF}) {
|
||||
size_t n = prop_info_count(t);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
for (size_t j = i + 1; j < n; j++) {
|
||||
auto* a = fgl::aircon::prop_info_begin(t) + i;
|
||||
auto* b = fgl::aircon::prop_info_begin(t) + j;
|
||||
CHECK_FALSE(std::string(a->name) == std::string(b->name));
|
||||
CHECK_FALSE(a->prop == b->prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("af_*_swing: base_type integer (PROTOCOL §8.3), kind bool в API") {
|
||||
auto* vs = prop_info_find_by_name(Template::kA, "af_vertical_swing");
|
||||
REQUIRE(vs != nullptr);
|
||||
CHECK(std::string(vs->base_type) == "integer");
|
||||
CHECK(vs->kind == fgl::aircon::ValueKind::kBool);
|
||||
}
|
||||
Reference in New Issue
Block a user