From 5af8a8e9c8806d4dea612ea4b4857174a9e233eb Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 17 Oct 2026 14:43:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D1=83=D1=8E=20Qt-=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=81=D0=BE=D0=BB=D1=8C=20=D1=81=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/examples/console_examples.py | 46 ++++++++ python/set_devices/qt_ports/CONSOLE.md | 68 +++++++++++ python/set_devices/qt_ports/console.py | 156 +++++++++++++++++++++++++ python/tests/test_console.py | 77 ++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 python/examples/console_examples.py create mode 100644 python/set_devices/qt_ports/CONSOLE.md create mode 100644 python/set_devices/qt_ports/console.py create mode 100644 python/tests/test_console.py diff --git a/python/examples/console_examples.py b/python/examples/console_examples.py new file mode 100644 index 0000000..1453266 --- /dev/null +++ b/python/examples/console_examples.py @@ -0,0 +1,46 @@ +"""Три автономных примера общей консоли templates без подключения к аппаратуре.""" # Примеры показывают служебный текст, CAN и двоичный RS485 на одном компоненте. +import argparse # Ключ --kind выбирает один пример для отдельного запуска. +import time # Время в модели кадра соответствует обычному источнику пакетов приложения. +from set_devices.can485_board import BoardFrame # Штатная модель строит RS485-пакет с настоящим CRC. +from set_devices.can_monitor import decode_protocol # CAN расшифровывается действующим декодером templates. +from set_devices.can_transport import build_frame # Транспортный кадр не собирается вручную в UI. +from set_devices.qt_ports.console import ConsoleView # Все примеры используют ту же консоль, что SETGUI. +try: # Выбираем доступную версию Qt без зависимости от приложения SETGUI. + from PySide6.QtWidgets import QApplication, QTabWidget # Вкладки позволяют сравнить три независимых журнала. +except ImportError: # Сохраняем совместимость примера с портом Qt 5. + from PySide2.QtWidgets import QApplication, QTabWidget # Интерфейс запуска одинаков для обеих версий Qt. + + +def create_examples(): # Фабрика доступна также тесту и генератору снимков интерфейса. + service = ConsoleView(limit=4000) # Первый журнал показывает служебные ответы и команды платы. + service.append_message(">>> V", direction="TX", color="#8fd18f") # Метка отправки демонстрирует независимый фильтр TX. + service.append_message("#OK driver started", direction="RX", color="#6fa8dc") # Ответ устройства показывает цвет и фильтр RX. + service.append_message("#ERR transmit failed (no ack / bus busy)", color="#ff7b7b") # Ошибка остаётся видна независимо от фильтров направления. + can = ConsoleView(limit=4000, payloads=True) # Второй пример добавляет переключение представления настоящих байтов CAN. + frame = build_frame(0x701, b"\x05", ide=False, rtr=False) # ID 0x701 и байт 05 задают heartbeat CANopen узла 1 в Operational. + decoded = decode_protocol(frame, "deif") # Подпись события берётся из штатного протокольного декодера. + can.append_message("RX CAN 0x701 · " + decoded.summary, direction="RX", payload=b"\x05") # Исходный payload позволяет сравнить режимы HEX и ASCII. + rs485 = ConsoleView(limit=4000, payloads=True) # Третий пример показывает транспортный пакет платы отдельно от прикладного CAN. + packet = BoardFrame(time.time(), False, 0x701, False, 1, b"\x05", "RS485").rs485_packet() # Пакет AA55 и CRC8 формируются кодеком templates. + rs485.append_message("RX RS485 · пакет CAN485", direction="RX", payload=packet) # Консоль отображает байты, не повторяя алгоритм разбора пакета. + for console in (can, rs485): # Двоичные примеры сразу демонстрируют одновременно байты и печатное представление. + console.display.setCurrentText("HEX + ASCII") # Пользователь может вернуться к исходной подписи или выбрать один формат. + return (("Служебная консоль", service), ("CAN", can), ("RS485", rs485)) # Каждый журнал владеет своей историей и настройками просмотра. + + +def main(): # Точка входа запускается обычным Python с установленным Qt-портом templates. + parser = argparse.ArgumentParser(description=__doc__) # Справка объясняет назначение примеров без отдельного приложения. + parser.add_argument("--kind", choices=("all", "service", "can", "rs485"), default="all") # Один пример каждого источника доступен отдельной командой. + args = parser.parse_args() # Пользователь выбирает источник до создания окна. + app = QApplication.instance() or QApplication([]) # Одно приложение Qt обслуживает все примеры. + window = QTabWidget() # Общий просмотр не связывает состояния независимых консолей. + for key, (title, console) in zip(("service", "can", "rs485"), create_examples()): # Стабильные ключи соответствуют значениям --kind. + if args.kind in ("all", key): window.addTab(console.panel, title) # Выбранные примеры добавляются с общей панелью инструментов. + window.setWindowTitle("Примеры консоли templates") # Заголовок явно отличает демонстрацию от подключения к плате. + window.resize(1100, 480) # Начальный размер в логических пикселях позволяет видеть все инструменты. + window.show() # Окно появляется только при прямом запуске примера. + return app.exec() # Цикл Qt обрабатывает поиск, паузу, экспорт и изменение масштаба. + + +if __name__ == "__main__": # Импорт фабрики в тестах не должен открывать окно или запускать цикл событий. + raise SystemExit(main()) # Код завершения Qt возвращается вызывающему процессу. diff --git a/python/set_devices/qt_ports/CONSOLE.md b/python/set_devices/qt_ports/CONSOLE.md new file mode 100644 index 0000000..7c32bbf --- /dev/null +++ b/python/set_devices/qt_ports/CONSOLE.md @@ -0,0 +1,68 @@ +# Общая консоль Qt + +`set_devices.qt_ports.console.ConsoleView` — общий порт представления для +журналов SETGUI. Он не управляет портами, опросом и отправкой команд. + +Приложение → `ConsoleView` → Qt 6 / Qt 5. Протокольные декодеры передают +готовые подписи и необязательные исходные байты; консоль не вычисляет CRC. + +| Файл | Назначение и зависимости | +|---|---| +| `set_devices/qt_ports/console.py` | Виджет и файловый экспорт; stdlib и PySide6 либо PySide2 | +| `examples/console_examples.py` | По одному примеру служебной консоли, CAN и RS485; действующие кодеки templates | +| `tests/test_console.py` | Проверки истории, паузы, фильтров, очистки и UTF-8; pytest и Qt | + +## Возможности, собранные из журналов SETGUI + +| Возможность | Источник / поведение общего компонента | +|---|---| +| Очистка | Канальные консоли платы, TX/RX, Modbus; удаляет также скрытые строки и очередь обновления | +| Пауза | Эмуляторы CAN; экран заморожен, история продолжает принимать данные до заданного предела | +| TX/RX | Диагностика и эмуляторы; служебные ошибки без направления остаются видны | +| Автопрокрутка | Мониторы CAN и RS; её можно отключить для чтения истории | +| Цвета | Консоль платы и монитор шины; сохраняются при переразборе фильтра | +| HEX/ASCII | Диагностика RS; формат применяется к явно переданному payload | +| Сохранение | Мониторы CAN и RS; экспорт фактически видимого текста в UTF-8 | +| Поиск, копирование, перенос, масштаб | Общие инструменты всех подключённых текстовых журналов | + +## Подключение + +```python +from set_devices.qt_ports.console import ConsoleView # Подключаем общий порт представления templates. +console = ConsoleView(limit=4000, payloads=True) # Храним не более 4000 строк, включая скрытые и поступившие на паузе. +layout.addWidget(console.panel, 1) # В layout вставляется контейнер с инструментами и редактором. +console.append_message("RX ответ", direction="RX", payload=b"OK") # Передаём готовую подпись и байты без повторного разбора протокола. +console.append_message("Ошибка связи", color="#ff7b7b") # Служебная ошибка остаётся видна при выключенных TX и RX. +console.userCleared.connect(clear_frame_history) # Отдельный буфер кадров очищается тем же пользовательским действием. +``` + +Старые вызовы `appendPlainText`, `appendHtml`, `setPlainText`, `clear`, +`toPlainText` и `setMaximumBlockCount` поддерживаются. `setPlainText` заменяет +снимок истории, учитывая текущую паузу. Для внешней истории кадров подключите +`userCleared`: программный `clear()` повторно этот сигнал не испускает. + +`directions=False` скрывает встроенные флажки, если приложение уже имеет свои +фильтры TX/RX. `payloads=True` показывает форматы HEX/ASCII. События должны +поступать в GUI-потоке Qt, как при работе с обычным `QPlainTextEdit`. + +При заполнении история обновляет экран пачками с интервалом 100 мс. +Количество строк на панели относится ко всей ограниченной истории. +На паузе сохранение и копирование используют замороженный видимый экран. + +## Три запускаемых примера + +Из каталога `python`, с установленными зависимостями `.[qt]`: + +```powershell +python examples/console_examples.py --kind service +python examples/console_examples.py --kind can +python examples/console_examples.py --kind rs485 +``` + +Без `--kind` открываются все три примера. Аппаратура не требуется. +В каждом примере можно проверить очистку, паузу, поиск, TX/RX, экспорт, +копирование, автопрокрутку, перенос и масштаб; в CAN и RS485 — HEX/ASCII. + +Используется в SETGUI: CAN/RS485 и служебная консоль ESP, журналы соединения +и TX/RX, эмуляторы CAN, CAN FD, Modbus, диагностика ПМ35 и ТМС2812, +спектр и логический анализатор Altera. diff --git a/python/set_devices/qt_ports/console.py b/python/set_devices/qt_ports/console.py new file mode 100644 index 0000000..a2efe2b --- /dev/null +++ b/python/set_devices/qt_ports/console.py @@ -0,0 +1,156 @@ +"""Общая консоль Qt: ограниченная история и инструменты просмотра без управления транспортом.""" # Модуль относится к порту представления templates и не реализует протоколы устройств. +from collections import deque # Кольцевая история ограничивает память при непрерывном обмене. +from html import escape # Текст устройства не должен становиться HTML-разметкой журнала. +from pathlib import Path # Экспорт сохраняет явно выбранный пользователем файл в UTF-8. +import re # Метки направления извлекаются только из начала строки после необязательного времени. +try: # Поддерживаем актуальный Qt и существующие установки на Python для Windows 7. + from PySide6.QtCore import QTimer, Signal # Таймер объединяет обновления заполненного журнала. + from PySide6.QtGui import QFont, QTextDocument # Документ извлекает обычный текст из уже оформленных сообщений приложения. + from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Панель использует только стандартные элементы Qt. +except ImportError: # Старый порт сохраняет тот же публичный интерфейс консоли. + from PySide2.QtCore import QTimer, Signal # В Qt 5 доступны те же сигналы и таймеры. + from PySide2.QtGui import QFont, QTextDocument # Цвета и извлечение текста не зависят от версии Qt. + from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Состав инструментов одинаков в обоих портах. + + +class ConsoleView(QPlainTextEdit): # Наследование сохраняет привычные appendPlainText, appendHtml и toPlainText у потребителей. + userCleared = Signal() # Владелец отдельной истории может удалить её при явном нажатии «Очистить». + + def __init__(self, parent=None, limit=4000, directions=True, payloads=False): # Предел задан в строках, включая скрытые и поступившие на паузе. + super().__init__(parent) # Владельца редактора впоследствии принимает контейнер panel. + self.records = deque(maxlen=max(1, int(limit))) # Каждая запись хранит текст, оформление, направление и необязательные байты. + self.setReadOnly(True) # Журнал показывает полученные события и не допускает ручной подмены текста. + self.setFont(QFont("Cascadia Mono", 9)) # Моноширинный шрифт выравнивает HEX, адреса и метки времени. + super().setMaximumBlockCount(self.records.maxlen) # Документ ограничен тем же количеством строк, что история. + self.timer = QTimer(self) # Таймер уничтожается вместе с редактором и не переживает закрытие вкладки. + self.timer.setSingleShot(True) # За одну пачку входящих строк достаточно одного полного обновления. + self.timer.setInterval(100) # Интервал 100 мс ограничивает перестроения десятью в секунду при полном буфере. + self.timer.timeout.connect(self.refresh) # Перестроение всегда читает последние записи и текущие фильтры. + self.panel = QWidget(parent) # Контейнер можно вставить в layout или вкладку вместо самого редактора. + layout = QVBoxLayout(self.panel) # Две компактные строки инструментов остаются над областью текста. + layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт приложение-потребитель. + actions = QHBoxLayout() # Основные операции сгруппированы отдельно от фильтров просмотра. + self.clear_button = QPushButton("Очистить") # Очистка удаляет также скрытую историю и накопленное на паузе. + self.clear_button.clicked.connect(self._user_clear) # Сигнал владельцу позволяет очистить дополнительный буфер кадров. + actions.addWidget(self.clear_button) # Очистка постоянно доступна у левого края консоли. + self.pause = QCheckBox("Пауза") # Пауза замораживает экран, продолжая сохранять входящие сообщения. + actions.addWidget(self.pause) # Пауза относится только к этому экземпляру журнала. + self.follow = QCheckBox("Автопрокрутка") # Прокрутку можно отключить для чтения старых записей. + self.follow.setChecked(True) # По умолчанию новые события видны сразу. + actions.addWidget(self.follow) # Управление расположено рядом с паузой просмотра. + for title, callback in (("Копировать", self.copy_visible), ("Сохранить…", self.save_dialog), ("−", lambda: self.zoomOut(1)), ("+", lambda: self.zoomIn(1))): # Масштаб меняется на один шаг Qt без изменения данных. + button = QPushButton(title) # Кнопки используют тему и шрифт приложения. + button.clicked.connect(callback) # Обработчик вызывается только по явному действию пользователя. + actions.addWidget(button) # Одинаковый порядок действий используется всеми консолями. + actions.addStretch(1) # Свободное место не растягивает подписи инструментов. + layout.addLayout(actions) # Основная строка отделена от параметров отображения. + filters = QHBoxLayout() # Поиск и направления занимают вторую строку для умеренной минимальной ширины. + self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк. + self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории. + filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли. + self.show_tx, self.show_rx = QCheckBox("TX"), QCheckBox("RX") # Служебные сообщения не относятся ни к одному направлению. + for control in (self.show_tx, self.show_rx): # Оба фильтра действуют независимо и применяются к истории. + control.setChecked(True) # Первое открытие показывает весь обмен. + control.setVisible(directions) # Потребители со своими фильтрами могут скрыть дублирующие элементы. + filters.addWidget(control) # Направления расположены рядом с текстовым фильтром. + self.display = QComboBox() # Формат байтов относится только к сообщениям с явно переданным payload. + self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются. + self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки. + self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload. + filters.addWidget(self.display) # Представление можно менять без повторной передачи данных. + self.wrap = QCheckBox("Перенос") # Перенос длинных строк удобен на узких панелях. + filters.addWidget(self.wrap) # Переключатель не изменяет исходный текст при экспорте. + self.count = QLabel() # Счётчик показывает объём ограниченной истории, включая скрытые строки. + filters.addWidget(self.count) # Количество остаётся видно при включённом фильтре. + layout.addLayout(filters) # Параметры находятся непосредственно над текстом. + layout.addWidget(self, 1) # Редактор занимает оставшуюся высоту контейнера. + for control in (self.pause, self.show_tx, self.show_rx, self.follow): # Изменения параметров применяются к уже накопленным данным. + control.toggled.connect(self.refresh) # Снятие паузы показывает сохранённые во время неё события. + self.search.textChanged.connect(self.refresh) # Поиск немедленно обновляет видимую часть истории. + self.display.currentIndexChanged.connect(self.refresh) # Выбор HEX/ASCII заново форматирует сохранённый payload. + self.wrap.toggled.connect(lambda checked: self.setLineWrapMode(QPlainTextEdit.WidgetWidth if checked else QPlainTextEdit.NoWrap)) # Отключённый перенос сохраняет горизонтальную прокрутку. + self.setLineWrapMode(QPlainTextEdit.NoWrap) # Начальный режим совпадает с выключенным флажком переноса. + self.refresh() # Пустой журнал сразу получает корректный счётчик. + + def setMaximumBlockCount(self, limit): # Сохраняем совместимость с существующими настройками журналов SETGUI. + self.records = deque(self.records, maxlen=max(1, int(limit))) # Предел ограничивает и скрытую историю, а не только документ. + super().setMaximumBlockCount(self.records.maxlen) # Согласуем вместимость редактора с кольцевым буфером. + self.refresh() # Уменьшение лимита сразу удаляет вытесненные строки с экрана. + + def append_message(self, text, direction="", color=None, payload=None, _html=None, _defer=False): # Принимаем готовый текст и данные, не выполняя разбор протокола. + for line in str(text).split("\n"): # Многострочные расшифровки также подчиняются общему лимиту строк. + match = re.match(r"^(?:\[[^\]]+\]\s*|\d\d:\d\d:\d\d(?:\.\d+)?\s*)?(TX|RX|>>>)\s", line) # Направление распознаётся только у начальной метки, а не по словам внутри ошибки. + role = direction or (("TX" if match[1] == ">>>" else match[1]) if match else "") # Явная роль имеет приоритет над текстовой эвристикой. + html = _html or ('%s' % (escape(color, quote=True), escape(line)) if color else "") # Безопасное оформление сохраняет исходный текст устройства. + record = (line, html, role, bytes(payload) if payload is not None else None) # Снимок payload не зависит от последующих изменений входного bytearray. + expired = len(self.records) == self.records.maxlen # Вытеснение требует убрать старую строку даже при скрытых новых событиях. + self.records.append(record) # На паузе и под фильтрами данные продолжают сохраняться в пределах лимита. + if not self.pause.isChecked() and not _defer: # Пауза и пакетная замена замораживают документ, но не историю. + if expired or self.timer.isActive(): # Заполненный буфер обновляется пачками для устойчивого интенсивного приёма. + if not self.timer.isActive(): self.timer.start() # Следующие события не отодвигают уже запланированное обновление. + elif self._visible(record): self._append_record(record) # Обычный поток дописывает одну строку без переразбора всей истории. + self.count.setText("%d строк" % len(self.records)) # Счётчик истории обновляется даже при замороженном экране. + + def appendPlainText(self, text): # Старые потребители могут продолжать писать обычный текст. + self.append_message(text) # Ограничения, пауза и фильтры применяются через единый путь записи. + + def appendHtml(self, html): # Прежнее цветное оформление приложения остаётся доступно через API QPlainTextEdit. + document = QTextDocument() # Временный документ преобразует форматированный фрагмент в строки для поиска. + document.setHtml(html) # Разметка поступает от приложения; внешние данные оно должно экранировать. + text = document.toPlainText() # Обычный текст используется для поиска и определения направления. + self.append_message(text, _html=html if "\n" not in text else None) # Однострочное оформление сохраняется без полного обновления истории. + + def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления. + self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок. + self.records.clear() # Сохраняется только актуальный результат форматирования потребителя. + if not self.pause.isChecked(): super().clear() # При паузе прежний экран сохраняется до возобновления. + if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись. + self.refresh() # Поиск и фильтры применяются к новому снимку целиком. + + def clear(self): # Программная очистка удаляет все данные и отменяет ожидающее обновление. + self.timer.stop() # Запланированное обновление больше не требуется. + self.records.clear() # Скрытые записи и накопленное на паузе не должны вернуться. + super().clear() # Явная очистка действует сразу даже при замороженном экране. + self.count.setText("0 строк") # Счётчик согласован с удалённой историей. + + def _user_clear(self): # Только действие оператора уведомляет владельца внешней истории. + self.clear() # Сначала очищаем собственный буфер и видимый документ. + self.userCleared.emit() # Потребитель может безопасно вызвать clear повторно без рекурсивного сигнала. + + def _visible(self, record): # Единое условие применяется при добавлении и полном обновлении. + text, _html, role, _payload = record # Поиск использует исходный текст сообщения независимо от выбранного формата байтов. + return (role != "TX" or self.show_tx.isChecked()) and (role != "RX" or self.show_rx.isChecked()) and self.search.text().casefold() in text.casefold() # Служебные сообщения сохраняются при отключённых направлениях. + + def _append_record(self, record): # Добавляем одну подготовленную запись, учитывая формат и положение прокрутки. + text, html, _role, payload = record # Байты присутствуют только при явной передаче payload приложением. + bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Запоминаем позицию до изменения размера документа. + mode = self.display.currentIndex() # Нулевой режим сохраняет текст и цвета, заданные потребителем. + if payload is not None and mode: # Формат меняет только подписанную приложением полезную нагрузку. + hexadecimal = payload.hex(" ").upper() # HEX показывает каждый исходный байт двумя цифрами. + ascii_text = "".join(chr(value) if 32 <= value <= 126 else "." for value in payload) # Диапазон печатного ASCII задан стандартом; управляющие байты заменяются точками. + super().appendPlainText(text + " · " + (hexadecimal if mode == 1 else ascii_text if mode == 2 else hexadecimal + " | " + ascii_text)) # Исходная подпись не теряется при смене представления байтов. + elif html: super().appendHtml(html) # Сохраняем безопасное оформление или прежний фрагмент приложения. + else: super().appendPlainText(text) # Обычный текст не интерпретируется как HTML. + bar.setValue(bar.maximum() if self.follow.isChecked() else position) # Отключённая автопрокрутка сохраняет место чтения. + + def refresh(self, *_args): # Сигналы Qt могут передавать индекс, флаг или текст. + self.timer.stop() # Немедленное обновление заменяет ожидающее пакетное перестроение. + if self.pause.isChecked(): return # Изменения фильтров во время паузы применятся после её снятия. + bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Сохраняем место чтения до очистки документа. + super().clear() # Очищаем только документ, сохраняя ограниченную историю. + for record in self.records: # Показываем оставшиеся записи в исходном порядке. + if self._visible(record): self._append_record(record) # Скрытые строки остаются доступными для следующего выбора фильтра. + bar.setValue(bar.maximum() if self.follow.isChecked() else min(position, bar.maximum())) # При сокращении документа ограничиваем прежнюю позицию новым диапазоном. + self.count.setText("%d строк" % len(self.records)) # Счётчик включает временно скрытые записи. + + def copy_visible(self): # Экспортируется фактически показанный текст, включая состояние паузы и фильтра. + QApplication.clipboard().setText(self.toPlainText()) # Копирование всей видимой консоли не требует ручного выделения. + + def save_to(self, path): # Отдельный метод позволяет проверить экспорт без системного диалога. + Path(path).write_text(self.toPlainText(), encoding="utf-8") # UTF-8 сохраняет русские сообщения и расшифровки протоколов. + + def save_dialog(self): # Пользователь самостоятельно выбирает назначение журнала. + path, _filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console.txt", "Текст UTF-8 (*.txt);;Все файлы (*)") # Диалог не выполняет автоматической записи при отмене. + if path: # Записываем только явно выбранный файл. + try: self.save_to(path) # Обрабатываем отказ файловой системы без прерывания приёма данных. + except OSError as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору. diff --git a/python/tests/test_console.py b/python/tests/test_console.py new file mode 100644 index 0000000..1b50ff1 --- /dev/null +++ b/python/tests/test_console.py @@ -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('<ошибка>') # Проверяем совместимость прежнего цветного 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() # Обе истории очищены одним действием.