Расширить обмен по шинам, CAN-адаптеры и обработку графиков

Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
This commit is contained in:
2026-10-17 14:36:14 +03:00
parent 95c8b8ac7d
commit a5e46d4627
69 changed files with 4413 additions and 682 deletions
+30 -7
View File
@@ -120,9 +120,11 @@ def test_read_accepts_address_formats_and_displays_both_values(page, address):
widget, protocol = page
widget.byte_read_address.setText(address)
widget.byte_read_button.click()
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert protocol.requests == [(0x1000, None, 7)]
assert not widget.byte_read_button.isEnabled()
assert widget.byte_read_button.isEnabled() # Ручная кнопка принимает новые запросы, пока текущее чтение ожидает ответ.
protocol.reply(True, {"value": 0xABCD})
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert widget.byte_read_address.text() == "$1000"
assert widget.byte_read_value.text() == "ABCD"
assert widget.byte_read_binary.text() == "1010 1011 1100 1101"
@@ -144,6 +146,7 @@ def test_invalid_inputs_do_not_send(page, address, value, binary):
widget.byte_write_value.setText(value)
widget.byte_write_binary.setText(binary)
widget.byte_write_button.click()
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert protocol.requests == []
assert "Проверьте" in widget.status.text()
@@ -156,11 +159,14 @@ def test_write_is_verified_and_controller_is_captured_for_whole_operation(page,
widget.byte_write_value.setText("0301")
widget.sync_byte_value("0301", 16)
widget.byte_write_button.click()
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert protocol.requests == [(0x1000, 0x0301, 13)]
controller[0] = 21
protocol.reply(True, {"raw": b"ack"})
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert protocol.requests[-1] == (0x1000, None, 13)
protocol.reply(True, {"value": readback})
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert widget.byte_read_value.text() == f"{readback:04X}"
assert ("Запись проверена" in widget.status.text()) == confirmed
if not confirmed:
@@ -171,18 +177,23 @@ def test_write_is_verified_and_controller_is_captured_for_whole_operation(page,
def test_failure_and_cancellation_restore_controls_and_ignore_late_reply(page):
widget, protocol = page
widget.byte_read_button.click()
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
protocol.reply(False, "CRC ответа не совпадает")
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert widget.status.text() == "CRC ответа не совпадает"
assert widget.byte_read_button.isEnabled()
widget.byte_read_button.click()
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
late_reply = protocol.callback
widget.stop()
assert protocol.cancelled == 1
widget.byte_read_button.click()
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
late_reply(True, {"value": 0x9999})
assert widget.byte_read_value.text() == ""
assert not widget.byte_read_button.isEnabled()
assert widget.byte_read_button.isEnabled() # Ручная кнопка принимает новые запросы, пока текущее чтение ожидает ответ.
protocol.reply(True, {"value": 0x1234})
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
assert widget.byte_read_value.text() == "1234"
assert widget.byte_read_button.isEnabled()
@@ -193,19 +204,31 @@ def test_controller_provider_is_used_by_bus_raw_memory_and_macros(app, tmp_path)
widget = ByteWorkPanel(protocol, device_address=lambda: controller[0],
macros_file=tmp_path / "address.json")
try:
widget.raw_widget.memory_write()
widget.raw_widget.memory_read()
assert protocol.requests == [(0x2000, 0, 21), (0x2000, None, 21)]
widget.bus_widget.read_from_bus()
assert protocol.requests[-2:] == [(0x200B, 0, 21), (0x200F, None, 21)]
widget.raw_widget.memory_write() # Первая операция сохраняет контроллер до ожидания общей очереди.
widget.raw_widget.memory_read() # Второе нажатие принимается до завершения первой записи.
QTest.qWait(15) # Запись получает владение общим соединением.
protocol.reply(True, {"raw": b"ack"}) # Подтверждаем текущую запись перед следующим чтением.
QTest.qWait(15) # Следующая ручная операция начинает PEEK.
protocol.reply(True, {"value": 0x1234}) # Возвращаем слово ручного чтения памяти.
QTest.qWait(15) # Освобождаем очередь после завершения второго действия.
assert protocol.requests == [(0x2000, 0, 21), (0x2000, None, 21)] # Оба нажатия используют контроллер своего исходного снимка.
widget.bus_widget.read_from_bus() # Последовательное чтение также выполняется асинхронно через общую очередь.
QTest.qWait(15) # Отправляем подготовку SBUS.
protocol.reply(True, {"raw": b"ack"}) # Подтверждение позволяет перейти к буферу SBUS.
QTest.qWait(15) # Отправляем чтение существующего регистра результата.
protocol.reply(True, {"value": 0x1234, "raw_response": {"raw": b"reply"}}) # Возвращаем слово последовательной шины.
QTest.qWait(15) # Завершаем логическую операцию перед явным макросом.
assert protocol.requests[-2:] == [(0x200B, 0, 21), (0x200F, None, 21)] # Адреса и формат существующей SBUS остаются прежними.
widget.bus_widget.execute_macro_commands([{"type": "read", "tetrad": 3, "register": 5}])
controller[0] = 22
QTest.qWait(20)
assert protocol.requests[-1] == (0x200B, 0x35, 21)
protocol.reply(True, {"raw": b"ack"})
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
QTest.qWait(20)
assert protocol.requests[-1] == (0x200F, None, 21)
protocol.reply(True, {"value": 0x4321, "raw_response": {"raw": b"reply"}})
QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI.
QTest.qWait(20)
assert not widget.bus_widget._macro_running
assert widget.bus_widget.bus_read_value_label.text() == "0x4321 (17185)"
+77
View File
@@ -0,0 +1,77 @@
"""Проверки общей консоли на настоящем Qt без физического устройства.""" # Проверяем сохранность истории, фильтры и очистку при асинхронном обновлении.
import os # Виртуальная платформа выбирается до импорта Qt.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # Тесты не открывают окна на рабочем столе.
import pytest # Фикстура даёт каждому сценарию независимую консоль.
from set_devices.qt_ports.console import ConsoleView, QApplication # Используем реальный порт templates, а не имитацию обработчиков.
@pytest.fixture # Создаём отдельный журнал для проверки независимости состояний.
def console(): # Ссылка на приложение удерживается до завершения сценария.
app = QApplication.instance() or QApplication([]) # Qt требует приложение перед созданием виджетов.
view = ConsoleView(limit=3, payloads=True) # Малый предел проверяет вытеснение без большого массива данных.
yield view # Сценарий управляет настоящими сигналами флажков и кнопок.
view.panel.deleteLater() # Удаляется контейнер вместе с редактором и таймером.
app.processEvents() # Отложенное удаление доставляется до следующего сценария.
def test_pause_keeps_bounded_history_and_clear_cannot_resurrect(console): # Очистка на паузе должна удалять также невидимые данные.
console.appendPlainText("первая") # Первая запись сразу видна на экране.
console.pause.setChecked(True) # Замораживаем только представление.
for index in range(5): console.appendPlainText("строка %d" % index) # На паузе поступает больше данных, чем помещается в истории.
assert console.toPlainText() == "первая" and len(console.records) == 3 # Экран заморожен, память ограничена.
console.pause.setChecked(False) # Возобновление показывает последние сохранённые строки.
assert console.toPlainText().splitlines() == ["строка 2", "строка 3", "строка 4"] # Вытеснённые записи больше не появляются.
console.appendPlainText("ожидает таймер") # Полный буфер ставит пакетное обновление в очередь.
assert console.timer.isActive() # Проверяем именно очистку при ожидающем обновлении.
console.pause.setChecked(True) # Очистка должна работать и при замороженном экране.
console.clear_button.click() # Пользователь удаляет весь журнал через общую панель.
console.pause.setChecked(False) # Возобновление не должно вернуть старые данные.
assert not console.records and not console.toPlainText() and not console.timer.isActive() # Документ, история и очередь согласованы.
def test_filters_restore_hidden_rows_and_preserve_service_errors(console): # Фильтры направлений не должны скрывать ошибки без метки TX/RX.
console.appendPlainText("[12:00:00] TX команда") # Начальная метка после времени определяет направление.
console.appendPlainText("12:00:01.123 RX ответ") # Поддерживаем существующий формат временных меток SETGUI.
console.appendPlainText("#ERR RX timeout") # Слово RX внутри ошибки не делает её принятым пакетом.
console.show_rx.setChecked(False) # Скрываем только входящие пакеты.
console.show_tx.setChecked(False) # Оба направления можно выключить одновременно.
assert console.toPlainText() == "#ERR RX timeout" # Служебная ошибка остаётся доступной оператору.
console.search.setText("ОТВЕТ") # Регистр запроса не должен влиять на поиск.
console.show_rx.setChecked(True) # Входящие данные восстанавливаются из сохранённой истории.
assert console.toPlainText() == "12:00:01.123 RX ответ" # Поиск применяется к той же записи без повторного приёма.
console.search.clear() # Снимаем текстовый фильтр.
assert len(console.records) == 3 # Фильтры не удаляют события.
def test_payload_and_utf8_export_use_visible_snapshot(console, tmp_path): # Сохранение и копирование соответствуют выбранному представлению.
console.append_message("RX пакет <текст>", direction="RX", payload=b"A\x00\xff") # Payload содержит печатный, управляющий и не-ASCII байты.
console.display.setCurrentText("HEX + ASCII") # Выбираем совместное представление байтов.
assert console.toPlainText() == "RX пакет <текст> · 41 00 FF | A.." # Непечатные байты заменяются точками без потери HEX.
path = tmp_path / "журнал.txt" # Проверяем кириллицу в имени и содержимом файла.
console.save_to(path) # Экспорт выполняется без диалога, сохраняя тот же код записи.
assert path.read_text(encoding="utf-8") == console.toPlainText() # UTF-8 сохраняет фактически видимый текст.
console.copy_visible() # Кнопка копирует весь показанный журнал.
assert QApplication.clipboard().text() == console.toPlainText() # Буфер обмена совпадает с экспортом.
def test_multiline_html_and_snapshot_obey_limit(console): # Предел относится к строкам, а не к числу вызовов записи.
console.appendPlainText("0\n1\n2\n3\n4") # Один пакет расшифровки превышает лимит документа и истории.
console.refresh() # Применяем запланированное пакетное обновление.
assert console.toPlainText() == "2\n3\n4" # Старые строки не возвращаются при смене фильтра.
console.setPlainText("снимок") # Потребитель может передать результат повторного разбора кадров целиком.
console.appendHtml('<span style="color:#ff0000">&lt;ошибка&gt;</span>') # Проверяем совместимость прежнего цветного API SETGUI.
console.search.setText("ошибка") # Поиск должен работать по тексту без тегов разметки.
assert console.toPlainText() == "<ошибка>" # Спецсимволы устройства остаются обычным текстом.
console.search.clear() # Возвращаем обе записи с исходным оформлением.
assert console.toPlainText() == "снимок\n<ошибка>" # Фильтр не теряет предыдущий снимок истории.
def test_clear_notifies_external_owner_once(console): # Владелец может очистить свой буфер и повторно вызвать clear без рекурсии.
external = ["старый кадр"] # Имитируем отдельную историю протокольных кадров приложения.
def clear_owner(): # Обработчик очищает внешний источник повторного форматирования.
external.clear() # Старые кадры не вернутся после смены парсера.
console.clear() # Повторный программный вызов не должен снова эмитировать пользовательский сигнал.
console.userCleared.connect(clear_owner) # Подключаем тот же контракт, что консоли платы и TX/RX SETGUI.
console.appendPlainText("строка") # До очистки журнал содержит данные.
console.clear_button.click() # Нажимаем настоящую кнопку Qt.
assert not external and not console.toPlainText() # Обе истории очищены одним действием.
+20 -2
View File
@@ -64,10 +64,28 @@ class PlotTests(unittest.TestCase):
self.assertEqual(view.width, moved.width)
expanded = moved.transform(self.core, zoom_x=.8)
self.assertGreater(expanded.width, moved.width)
limited = view.transform(self.core, zoom_x=1e10)
self.assertEqual(1e-7, limited.width)
limited = view.transform(self.core, zoom_x=1e15) # Заведомо превышаем новый предел 10^12 раз, чтобы проверить ограничение без обнуления ширины.
self.assertEqual(1e-12, limited.width) # Минимальная доля полного диапазона X должна позволять приближение в 100 000 раз сильнее прежнего.
self.assertEqual(limited, limited.transform(self.core, zoom_x=2))
def test_deep_zoom_keeps_both_axes_navigable(self): # Проверяем совместное приближение X и Y, независимое перемещение, границы и возвращение к полному диапазону.
view = Viewport() # Начинаем с полного диапазона обеих осей без фиксации.
for _ in range(160): # 160 шагов с коэффициентом 1,25 проходят оба прежних предела и достигают нового ограничения 10^12 раз.
view = view.transform(self.core, zoom_x=1.25, zoom_y=1.25, focus_x=.3, focus_y=.7) # Увеличиваем вокруг разных опорных точек, чтобы обнаружить смешение осей.
self.assertAlmostEqual(.3, view.x + .3 * view.width, places=14) # Координата опоры X должна оставаться на месте даже при минимальной ширине.
self.assertAlmostEqual(.7, view.y + .7 * view.height, places=14) # Независимая опора Y не должна дрейфовать при многократном увеличении.
self.assertEqual((1e-12, 1e-12), (view.width, view.height)) # Обе оси достигают триллионной доли полного диапазона и не превращаются в нулевой интервал.
self.assertEqual(view, view.transform(self.core, zoom_x=2, zoom_y=2)) # Дополнительное увеличение на пределе сохраняет область без накопления смещения.
moved = view.transform(self.core, pan_x=.2, pan_y=-.2) # Сдвигаем X и Y в противоположные стороны на пятую часть текущего окна.
self.assertLess(moved.x, view.x) # Положительное экранное перемещение X уменьшает смещение области.
self.assertGreater(moved.y, view.y) # Отрицательное перемещение Y увеличивает её нормированное смещение.
self.assertEqual((view.width, view.height), (moved.width, moved.height)) # Перемещение не должно самопроизвольно менять достигнутое увеличение.
for direction in (-1e15, 1e15): # Большое перемещение в обе стороны проверяет ограничение краями полного диапазона.
edge = moved.transform(self.core, pan_x=direction, pan_y=direction) # Заведомо выходим за исходные границы по каждой оси.
self.assertGreaterEqual(min(edge.x, edge.y), 0) # Начало области остаётся внутри диапазона, а не уходит в отрицательные координаты.
self.assertLessEqual(max(edge.x + edge.width, edge.y + edge.height), 1) # Конец области не выходит за исходные данные.
self.assertEqual(Viewport(), moved.transform(self.core, zoom_x=1e-15, zoom_y=1e-15)) # Уменьшение с большим запасом возвращает полный диапазон и нулевые смещения.
def test_invalid_numeric_inputs_do_not_escape_to_painter(self):
for value in (math.nan, math.inf, -math.inf):
self.assertEqual(Viewport(), Viewport().transform(self.core, zoom_x=value))