Files
templates/python/tests/test_gas_watch.py
T
Andrey 876b58517c feat(set-protocol): Раздели GAS и добавь пакетный сбор регистров
Сохрани адресацию отдельных регистров GAS 0x3 и выдели слотам тип 0x4 с маской Word0. Добавь единое C-ядро GAS_WATCH2, тонкие Python/JNI-порты и проверки общих эталонных байтов.

Совместимость: тип 0x4 больше не обслуживает Modbus Coil. Потребителям PCAN_MSG_MODBUS_COIL и ProtoCanMsgToModbusCoil нужно перейти на GAS_SLOT и отдельный API слотов; регистровый GAS остаётся на 0x3.
2026-10-23 14:14:48 +03:00

170 lines
26 KiB
Python

# Проверяем неизменяемые модели и настоящий C ABI GAS_WATCH2 на общих эталонных байтах.
# Python не подменяет C-кодек: проверки wire-пакетов выполняются только с указанной SETPROTOCOL_LIBRARY.
import ctypes # Загружаем настоящую общую библиотеку для проверки границы Python/C.
import json # Читаем общий JSON-fixture, используемый также C- и JVM-проверками.
import os # Путь к проверяемой сборке получает тестовый процесс через SETPROTOCOL_LIBRARY.
import unittest # Запускаем воспроизводимые проверки модели, подписок и ошибочных данных.
from dataclasses import FrozenInstanceError # Проверяем запрет изменения уже созданных моделей.
from pathlib import Path # Находим эталон относительно исходников независимо от текущего каталога.
from protocan.gas_watch import GasWatchConfig, GasWatchSample, NativeGasWatch # Проверяем публичный тонкий порт без доступа к другому Python-кодеку.
from protocan.protocol import MessageType # Проверяем выделение новых типов сообщений без изменения старых значений.
FIXTURE_PATH = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/gas-watch-v2.json" # Общие байты находятся рядом с C-тестами как единый контракт портов.
class GasWatchModelTests(unittest.TestCase): # Эти проверки не требуют DLL, поскольку относятся к представлению моделей Python.
def test_models_copy_sequences_and_are_frozen(self): # Изменение исходных списков не должно менять ожидаемый ACK и принятую выборку.
addresses = [0, 1, 0xFF01] # Повторяем три разрозненных адреса из пользовательского примера.
config = GasWatchConfig(1, 100, addresses) # Переданный список должен быть независимо сохранён как кортеж.
addresses[0] = 99 # Моделируем последующее редактирование таблицы сигналов GUI.
self.assertEqual((0, 1, 0xFF01), config.addresses) # Проверяем сохранение исходного порядка подписки.
with self.assertRaises(FrozenInstanceError): # Идентификатор уже сформированной модели нельзя менять на месте.
config.config_id = 2 # Присваивание проверяет защиту frozen dataclass, а не изменение состояния устройства.
values = [0, None, 65535] # Корректный ноль и недостоверное значение должны оставаться различимыми.
sample = GasWatchSample(1, 2, 3, values) # Публичная модель также должна копировать изменяемый контейнер.
values[0] = 5 # Моделируем повторное использование входного списка вызывающим приложением.
self.assertEqual((0, None, 65535), sample.values) # Ранее созданная выборка не должна изменяться вслед за списком.
with self.assertRaises(FrozenInstanceError): # Метка времени проверенной выборки остаётся неизменяемой.
sample.timestamp_ms = 4 # Присваивание должно быть отклонено механизмом dataclass.
def test_integer_ranges_prevent_ctypes_narrowing(self): # Python обязан отвергать значения, которые ctypes незаметно усекло бы до C-типа.
for config_id in (0, -1, 0x100000000, True, 1.5): # Проверяем нулевой, отрицательный, переполненный и нецелые идентификаторы.
with self.subTest(config_id=config_id), self.assertRaises(ValueError): # Каждый ошибочный id должен отказать до вызова C.
GasWatchConfig(config_id, 100, (0,)) # Остальные поля допустимы и не маскируют проверяемую ошибку.
for period in (-1, 65536, True, 1.5): # Период представляет uint16_t в миллисекундах без преобразования bool и float.
with self.subTest(period=period), self.assertRaises(ValueError): # Проверяем все источники возможного сужения периода.
GasWatchConfig(1, period, (0,)) # Валидный идентификатор и адрес изолируют неверный период.
for address in (-1, 65536, True, 1.5): # GAS-регистр задаётся uint16_t, а не произвольным Python-числом.
with self.subTest(address=address), self.assertRaises(ValueError): # Любой недопустимый элемент массива должен отказать.
GasWatchConfig(1, 100, (address,)) # Один адрес позволяет проверить границу независимо от числа каналов.
with self.assertRaises(ValueError): # Подписка не должна превышать общий предел 64 каналов.
GasWatchConfig(1, 100, tuple(range(65))) # 65 уникальных допустимых адресов изолируют ограничение размера.
with self.assertRaises(ValueError): # Значения публичной модели также не должны выходить за 16 бит.
GasWatchSample(1, 2, 3, (65536,)) # Отдельная ошибочная величина проверяет защиту модели результата.
def test_old_library_has_explicit_rebuild_error(self): # Устаревшая библиотека должна дать понятную ошибку без Python fallback.
with self.assertRaisesRegex(RuntimeError, "set_gas_watch.c"): # Сообщение должно назвать недостающую реализацию для пересборки.
NativeGasWatch(object()) # Объект без новых ABI-символов воспроизводит отсутствие функции в старой DLL.
def test_message_types_preserve_legacy_values(self): # Новые wire-типы не должны переопределять существующий GAS_WATCH.
self.assertEqual((0x12, 0x13), (MessageType.GAS_WATCH_SET, MessageType.GAS_WATCH_DATA)) # Сохраняем совместимость старого профиля v1.
self.assertEqual((0x14, 0x15), (MessageType.GAS_WATCH2_SET, MessageType.GAS_WATCH2_DATA)) # Выделенные типы однозначно выбирают новый формат.
@unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Укажите SETPROTOCOL_LIBRARY для проверки настоящего C-ядра") # Без явного пути не подменяем C тестовым Python-кодеком.
class NativeGasWatchTests(unittest.TestCase): # Проверяем реальные builder, ACK-валидатор и DATA-декодер общей библиотеки.
@classmethod # Одну DLL и общий fixture безопасно переиспользуют отдельные экземпляры адаптера каждого теста.
def setUpClass(cls): # Загружаем внешний контракт один раз до тестов C ABI.
cls.library = ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"]) # Указанный путь должен вести к сборке с set_gas_watch.c.
cls.fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) # Эталонные данные читаются в обязательной кодировке UTF-8.
cls.request_bytes = bytes.fromhex(cls.fixture["request_hex"]) # Фиксированные байты отделяют ожидаемый wire-вектор от реализации C.
cls.sample_bytes = bytes.fromhex(cls.fixture["sample_hex"]) # DATA проверяется теми же байтами, что и другие языковые порты.
def setUp(self): # Изолируем pending и active между независимыми сценариями.
self.core = NativeGasWatch(self.library) # Каждый тест начинает с отсутствующей подтверждённой конфигурацией.
self.config = GasWatchConfig(self.fixture["config_id"], self.fixture["period_ms"], self.fixture["addresses"]) # Сохраняем порядок общих трёх адресов.
def activate(self): # Повторяем нормальный цикл отправки конфигурации и её точного подтверждения.
payload = self.core.request(self.config) # Запрос формирует настоящий C-builder.
self.core.validate_ack(payload, self.config) # Полное эхо устройства проверяет настоящий C ACK-валидатор.
def test_shared_fixture_request_ack_and_sample(self): # Основной сценарий фиксирует совместимость байтов и семантику признаков достоверности.
self.assertEqual(self.request_bytes, self.core.request(self.config)) # Сопоставляем результат C-builder с общим независимым эталоном.
with self.assertRaises(RuntimeError): # DATA до ACK не имеет подтверждённого соответствия каналам.
self.core.sample(self.sample_bytes) # Даже корректный пакет должен быть отвергнут до активации конфигурации.
self.core.validate_ack(self.request_bytes, self.config) # Активируем подписку только по полному точному эху.
sample = self.core.sample(self.sample_bytes) # C проверяет config_id, количество, длину, padding и значения invalid-каналов.
expected_values = tuple(value if valid else None for value, valid in zip(self.fixture["values"], self.fixture["valid"])) # Преобразуем семантику fixture, не декодируя wire-пакет.
self.assertEqual(GasWatchSample(self.config.config_id, self.fixture["sequence"], self.fixture["timestamp_ms"], expected_values), sample) # Сверяем всю неизменяемую модель результата.
zero_sample = bytes.fromhex("0200030078563412040302014433221105000000003412") # Фиксированный вектор содержит достоверный ноль, invalid-ноль и слово 0x1234.
self.assertEqual((0, None, 0x1234), self.core.sample(zero_sample).values) # Недостоверное значение не должно превращаться в отображаемый ноль.
def test_native_request_rejects_duplicates_and_inconsistent_stop(self): # Логические ограничения протокола должны проверяться C, а не вторым валидатором Python.
self.activate() # Действующая подписка нужна для проверки сохранения состояния при отказе builder.
invalid = (GasWatchConfig(2, 100, (0, 0)), GasWatchConfig(2, 0, (0,)), GasWatchConfig(2, 100, ())) # Проверяем дубликаты и оба несовместимых сочетания периода с количеством.
for config in invalid: # Каждая ошибочная конфигурация должна быть отвергнута независимо от предыдущей попытки.
with self.subTest(config=config), self.assertRaises(ValueError): # Отказ C-builder преобразуется в явную ошибку Python.
self.core.request(config) # Никакой неправильный запрос не должен менять активную подписку.
self.assertEqual(self.config.config_id, self.core.sample(self.sample_bytes).config_id) # После отказа прежние подтверждённые DATA продолжают приниматься.
stop = GasWatchConfig(self.config.config_id + 1, 0, ()) # Пустой список с нулевым периодом представляет правильную команду отключения.
self.assertEqual(bytes.fromhex("020000007956341200000000"), self.core.request(stop)) # C-builder должен поддерживать отдельный идентификатор остановки.
self.core.validate_ack(bytes.fromhex("020000007956341200000000"), stop) # Полный ACK пустой конфигурации также должен проходить C-проверку.
def test_ack_checks_full_configuration_and_preserves_active(self): # Равного количества каналов недостаточно для принятия подтверждения.
self.activate() # Создаём активную подписку, которая должна пережить ошибочный ACK следующей команды.
changed = GasWatchConfig(self.config.config_id + 1, 200, (0xFF01, 1, 0)) # Новый порядок того же числа адресов получает новый идентификатор.
changed_request = self.core.request(changed) # Pending меняется, а действующий профиль остаётся активным до ACK.
self.assertEqual(self.config.config_id, self.core.sample(self.sample_bytes).config_id) # Старые DATA разрешены, пока устройство не подтвердило переключение.
for supplied in (self.config, changed): # Отдельно проверяем поздний ACK и пакет, не соответствующий новой конфигурации.
with self.subTest(supplied=supplied), self.assertRaises(ValueError): # Ни один вариант не должен активировать неподтверждённую подписку.
self.core.validate_ack(self.request_bytes, supplied) # Вариант old отвергает pending-проверка, вариант new — C-сравнение эха.
altered = bytearray(changed_request) # Моделируем ACK с тем же id, периодом и count, но другим порядком адресов.
altered[-2:] = b"\x02\x00" # Меняем один адрес в конце вектора, не затрагивая геометрию ответа.
with self.assertRaises(ValueError): # Полное сравнение адресов должно обнаружить подмену при прежнем количестве каналов.
self.core.validate_ack(bytes(altered), changed) # C проверяет весь список и не разрешает частичную подписку.
self.core.validate_ack(changed_request, changed) # Корректное полное эхо атомарно переводит pending в active.
with self.assertRaises(ValueError): # Пакет прежнего id после успешного переключения становится устаревшим.
self.core.sample(self.sample_bytes) # Количество осталось тем же, поэтому проверка должна опираться на идентификатор.
new_sample = bytes.fromhex("0200030079563412fffffffff0ffffff037b00feff0000") # Фиксированный DATA-вектор относится к новому config_id 0x12345679.
self.assertEqual(changed.config_id, self.core.sample(new_sample).config_id) # Новый подтверждённый id разрешает данные именно новой конфигурации.
def test_malformed_ack_and_sample_do_not_poison_subscription(self): # Ошибки отдельных пакетов не должны разрушать рабочую подписку.
self.core.request(self.config) # Pending необходим для проверки точного формата первого подтверждения.
for payload in (self.request_bytes[:-1], self.request_bytes + b"\x00"): # Отсечённые и дополненные ACK имеют неправильную длину.
with self.subTest(ack=payload), self.assertRaises(ValueError): # C должен отвергнуть и неполное, и нестрогое эхо.
self.core.validate_ack(payload, self.config) # Pending сохраняется, позволяя принять следующий корректный ACK.
self.core.validate_ack(self.request_bytes, self.config) # Корректное подтверждение после ошибок всё ещё активирует исходную подписку.
malformed = ( # Список фиксированных искажений покрывает геометрию, достоверность и привязку к конфигурации.
self.sample_bytes[:-1], # Отбрасываем последнее слово частично: общая длина больше не соответствует count.
self.sample_bytes + b"\x00", # Лишний байт также нарушает точную геометрию пакета.
bytes.fromhex("0100030078563412fffffffff0ffffff037b00feff0000"), # Версия 1 не должна интерпретироваться как новый формат версии 2.
bytes.fromhex("0200030079563412fffffffff0ffffff037b00feff0000"), # Другой id с прежним count моделирует поздние DATA другой подписки.
bytes.fromhex("0200020078563412fffffffff0ffffff037b00feff"), # Корректная геометрия двух значений не совпадает с тремя ожидаемыми адресами.
bytes.fromhex("0200030078563412fffffffff0ffffff837b00feff0000"), # Старший padding-бит bitmap для трёх каналов обязан быть нулём.
bytes.fromhex("0200030078563412fffffffff0ffffff037b00feff0100"), # Значение invalid-канала обязано быть нулём, иначе данные неоднозначны.
) # Закрываем перечень независимых отрицательных векторов C-декодера.
for payload in malformed: # Проверяем каждый отказ с тем же неизменным состоянием подписки.
with self.subTest(data=payload), self.assertRaises(ValueError): # Ни один некорректный пакет не должен превращаться в модель.
self.core.sample(payload) # Все проверки структуры пакета выполняются общим C-декодером.
self.assertEqual(self.config.config_id, self.core.sample(self.sample_bytes).config_id) # Следующий корректный пакет доказывает сохранение active после ошибки.
def test_failed_decode_keeps_all_c_outputs_unchanged(self): # Проверяем обещание ABI не публиковать частичные результаты при ошибке пакета.
data = (ctypes.c_uint8 * len(self.sample_bytes)).from_buffer_copy(self.sample_bytes) # Передаём эталонные байты, изменяя только ожидаемый id C-вызова.
sequence = ctypes.c_uint32(11) # Отличимый sentinel обнаруживает запись счётчика до окончания проверки.
timestamp = ctypes.c_uint32(22) # Независимый sentinel защищает проверку метки времени.
values = (ctypes.c_uint16 * 3)(31, 32, 33) # Отличимые слова обнаруживают частичное заполнение выходного массива.
valid = (ctypes.c_uint8 * 3)(41, 42, 43) # Признаки также не должны перезаписываться при устаревшем id.
result = self.core.lib.set_gas_watch_decode(data, len(data), self.config.config_id + 1, 3, ctypes.byref(sequence), ctypes.byref(timestamp), values, valid, 3) # Проверяем настоящий C ABI без обёртки создания модели.
self.assertEqual(-1, result) # Несовпадающий expected_config_id обязан отвергнуть даже корректно сформированный пакет.
self.assertEqual((11, 22, (31, 32, 33), (41, 42, 43)), (sequence.value, timestamp.value, tuple(values), tuple(valid))) # Каждый выход должен сохранить исходный sentinel.
def test_pending_replacement_id_reuse_and_reset(self): # Проверяем гонки нескольких запросов и очистку состояния при переподключении.
self.activate() # Ранее подтверждённая конфигурация задаёт исходный active.
changed = GasWatchConfig(self.config.config_id + 1, 100, (1, 0, 0xFF01)) # Первый новый порядок начинает ожидать подтверждение.
self.core.request(changed) # Старый active остаётся доступным, пока новый pending не подтверждён.
newest = GasWatchConfig(self.config.config_id + 2, 100, (0xFF01, 0, 1)) # Следующая команда заменяет ожидаемый список ещё до первого ACK.
newest_request = self.core.request(newest) # Актуальным pending становится только последний запрос.
with self.assertRaises(ValueError): # ACK уже заменённого pending не должен возвращать старое ожидание.
self.core.validate_ack(self.request_bytes, changed) # Проверка pending срабатывает до анализа неподходящего payload.
for reused in (GasWatchConfig(self.config.config_id, 200, self.config.addresses), GasWatchConfig(newest.config_id, 200, newest.addresses)): # Active и pending id нельзя переиспользовать с другим содержимым.
with self.subTest(reused=reused), self.assertRaises(ValueError): # Такая подмена лишила бы DATA возможности определить порядок значений.
self.core.request(reused) # Ошибка не должна уничтожать последний ожидаемый запрос.
self.core.validate_ack(newest_request, newest) # Последний корректный pending всё ещё подтверждается после отвергнутых попыток.
self.core.reset() # Переподключение удаляет как ожидаемую, так и подтверждённую подписку.
with self.assertRaises(RuntimeError): # Старая выборка после reset требует нового цикла подтверждения.
self.core.sample(self.sample_bytes) # Данные прошлого соединения не должны отображаться автоматически.
with self.assertRaises(ValueError): # Ни один ACK прошлого соединения больше не ожидается.
self.core.validate_ack(newest_request, newest) # Reset запрещает позднее восстановление прежнего active.
def test_maximum_subscription_uses_real_c_builder(self): # Проверяем безопасную передачу полного массива из 64 каналов через ctypes.
config = GasWatchConfig(0xFFFFFFFF, 65535, tuple(range(64))) # Используем максимальные id и период при допустимом числе уникальных адресов.
payload = self.core.request(config) # Размер payload определяет настоящий C-builder в общем транспортном буфере.
self.assertEqual(140, len(payload)) # Версия 2 задаёт 12 байт заголовка и 64 двухбайтных адреса, всего 140 байт.
self.core.validate_ack(payload, config) # Полное эхо максимального массива проверяется без усечения и частичного принятия.
if __name__ == "__main__": # Разрешаем запуск этого набора проверок напрямую наряду с unittest discovery.
unittest.main() # Стандартный runner сообщает результаты, ошибки DLL и пропущенные C-проверки.