Добавить общую Qt-консоль с фильтрами и экспортом

This commit is contained in:
2026-10-17 14:43:12 +03:00
parent f613101124
commit 5af8a8e9c8
4 changed files with 347 additions and 0 deletions

View File

@@ -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 возвращается вызывающему процессу.

View File

@@ -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.

View File

@@ -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 ('<span style="color:%s;white-space:pre">%s</span>' % (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)) # Причина ошибки остаётся доступна оператору.

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() # Обе истории очищены одним действием.