Merge origin/ump-v4 into main

Combine Flash CAN updates, signal processing and HOPE pin fixes with current Modbus/GAS, STM32 ports and CCS release exports. Resolve protocol, documentation and Qt conflicts; deduplicate Android JNI sources and include gauge/IGBT sources in host builds.
This commit is contained in:
2026-11-07 01:32:27 +03:00
136 changed files with 8828 additions and 821 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)"
+33
View File
@@ -53,6 +53,39 @@ class PlotTests(unittest.TestCase):
crossed = markers.move(Marker.A, 1900).move(Marker.B, 1100)
self.assertEqual(-800, self.core.delta(crossed.a, crossed.b))
def test_deep_x_zoom_preserves_anchor_and_allows_pan_and_zoom_out(self):
view = Viewport()
for _ in range(65):
view = view.transform(self.core, zoom_x=1.25, focus_x=.3)
self.assertAlmostEqual(.3, view.x + .3 * view.width)
self.assertLess(view.width * 5_000_000, 3)
moved = view.transform(self.core, pan_x=.2)
self.assertLess(moved.x, view.x)
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=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))
+14
View File
@@ -21,6 +21,20 @@ from set_devices.signal_reconstruction import METHODS
class PlotProcessingTests(unittest.TestCase):
def test_million_output_points_for_all_methods(self):
snapshot = Snapshot([Series("a", "A", [(0, 1), (1, 3), (2, 5)])])
for method in METHODS:
with self.subTest(method=method):
curve = process(prepare(snapshot, "a", method, 1_000_000, 1))
self.assertEqual(1_000_000, len(curve.points))
self.assertEqual((0., 2.), (curve.points[0][0], curve.points[-1][0]))
for x, y in curve.points[::10000]:
self.assertAlmostEqual(1 + 2 * x, y)
self.assertAlmostEqual(5., curve.points[-1][1])
self.assertTrue(curve.pieces)
with self.assertRaises(ValueError):
process(prepare(snapshot, "a", "linear", 1_000_001))
def test_snapshot_copies_mutable_samples_and_excludes_hidden_and_digital(self):
points = [[0, 0], [1, 1]]
channel = Series("a", "Analog", points)
+7
View File
@@ -56,6 +56,7 @@ class ProcessingQtTests(unittest.TestCase):
self.panel.method.setCurrentIndex(self.panel.method.findData(method))
self.calculate()
self.assertEqual(method, self.panel.curve.request.method)
self.panel.count.setValue(self.panel.count.value() + 1)
self.assertEqual(index + 1, len(self.panel.curves))
self.assertTrue(self.panel.export_button.isEnabled())
@@ -67,6 +68,12 @@ class ProcessingQtTests(unittest.TestCase):
self.assertEqual((), self.panel.curves)
self.assertIsNone(self.panel.curve)
def test_large_output_count_reaches_worker(self):
self.assertEqual(10000, self.panel.count.value())
self.panel.count.setValue(1_000_000)
self.calculate()
self.assertEqual(1_000_000, len(self.panel.curve.points))
def test_repeated_request_does_not_duplicate_curve(self):
self.calculate()
self.calculate()
@@ -0,0 +1,141 @@
"""Контракт сравнения проверяется с чужим canvas без импорта приложения.""" # Фиксируем возможность повторного использования Qt-порта.
import json # Сохранённый отчёт проверяется как машиночитаемые данные.
from pathlib import Path # Временный CSV и отчёт используют явные пути.
import tempfile # Проверки не затрагивают пользовательские файлы.
import time # Монотонный срок ограничивает ожидание worker.
from types import SimpleNamespace # Источник и кривая не зависят от моделей приложения.
import unittest # Набор можно запустить вне тестового окружения потребителя.
from unittest.mock import patch # Подменяем выбор файла, сохраняя реальную запись JSON.
try: # Используем доступную в окружении версию Qt.
from PySide6.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Qt6 предоставляет базовые элементы тестового canvas.
except ImportError: # Qt5 проверяется непосредственно, без слоя совместимости приложения.
from PySide2.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Публичный порт обязан поддерживать оба binding.
from protocan.plot import Bounds, Viewport # Общие контейнеры координат не требуют конкретного рендерера.
from set_devices.qt_ports.reference_comparison import ReferenceComparisonDialog, ReferenceComparisonPanel # Импортируем общий порт напрямую.
from set_devices.qt_ports.reference_overlay import ReferenceOverlayPanel # Прямое наложение предоставляет только параметры и данные, не создавая canvas.
class Canvas(QWidget): # Минимальный сторонний график реализует документированный контракт.
def __init__(self): # В тесте отсутствует зависимость от графика приложения.
super().__init__() # QSplitter принимает обычный QWidget.
self._series = [] # Панель заполняет кривые через внедрённую фабрику.
self.axis_lock_x, self.axis_lock_y = QCheckBox(self), QCheckBox(self) # Флажки поддерживают снятие фиксации осей.
self.axis_limits = None # Ручные границы сбрасываются при новом результате.
self.reset_interaction() # Начинаем с нейтрального масштаба.
self._sync_bounds() # Задаём известные границы тестовой записи.
def reset_interaction(self): # Общая панель сбрасывает навигацию перед показом кривых.
self.viewport = Viewport() # Нормированная область начинается с полного масштаба.
def _sync_bounds(self): # Тестовым трём отсчётам достаточно постоянной общей области.
self.visible_bounds = Bounds(0., 2., 0., 10.) # Интервал 0–2 мс и амплитуда 0–10 охватывают контрольные данные.
def make_series(key, name, **data): # Формат контейнера кривой определяет потребитель.
return SimpleNamespace(key=key, name=name, **data) # Принимаем полные массивы и цвет без дополнительных преобразований.
class ReferencePortTests(unittest.TestCase): # Проверяем интеграционный контракт независимо от GUI-потребителя.
@classmethod # Один экземпляр QApplication обслуживает весь процесс проверок.
def setUpClass(cls): # Доставка сигналов worker требует реального приложения Qt.
cls.app = QApplication.instance() or QApplication([]) # Повторно используем существующий цикл событий при совместном запуске тестов.
def setUp(self): # Данные каждого сценария изолированы от остальных.
self.temp = tempfile.TemporaryDirectory() # Каталог удаляется после завершения всех операций.
self.root = Path(self.temp.name) # Потребитель явно задаёт начальную папку.
self.path = self.root / 'reference.csv' # Файл хранит известный линейный эталон.
self.path.write_text('time_ms,U (V)\n0,1\n1,3\n2,5\n', encoding='utf-8') # Три амплитуды позволяют проверить полный остаток и число точек.
self.sources = [(SimpleNamespace(name='U (V)', display_value=lambda value: value * 2), [(0., 1.), (1., 2.), (2., 3.)])] # После внешнего множителя измерение отличается от эталона на 1 В.
self.panels = [] # Созданные виджеты закрываются до удаления файлов.
def tearDown(self): # Очистка обязательна даже при ошибке утверждения.
for panel in self.panels: # Завершаем все экземпляры панели и диалога.
panel.close() # Закрытие останавливает worker или запрашивает его отмену.
self.wait_idle(panel) # Ожидаем завершение до удаления QObject.
panel.deleteLater() # Дочерние элементы освобождаются очередью Qt.
self.app.processEvents() # Доставляем отложенные сигналы завершённых заданий.
self.temp.cleanup() # Фоновые операции больше не используют файлы.
def create(self, cls=ReferenceComparisonPanel, sources=None): # Один контракт подключает встроенную панель и отдельное окно.
panel = cls(self.sources if sources is None else sources, source_path='capture.csv', plot_factory=Canvas, series_factory=make_series, path_provider=lambda name: str(self.root / name)) # Все зависимости передаются извне без импорта прикладного GUI.
self.panels.append(panel) # Регистрируем объект для гарантированного завершения.
return panel # Сценарий использует обычные методы общего порта.
def wait_idle(self, panel): # Короткие задания не должны зависеть от скорости машины.
deadline = time.monotonic() + 5 # Пять секунд ограничивают время ожидания трёхточечного файла.
while (panel._worker is not None or getattr(panel, '_pending', False) or (hasattr(panel, '_timer') and panel._timer.isActive())) and time.monotonic() < deadline: # Для слоя эталона ждём также автоматически запланированный расчёт.
self.app.processEvents() # Доставляем result и finished без зависимости от QtTest.
time.sleep(.001) # Одна миллисекунда освобождает процессор для worker.
self.assertIsNone(panel._worker) # Дальнейшие проверки выполняются только после остановки задания.
self.assertFalse(getattr(panel, '_pending', False)) # Новый снимок параметров также должен быть обработан.
def test_custom_canvas_transform_and_paths(self): # Проверяем пользовательский цикл на независимом canvas.
panel = self.create() # Фабрики графика и кривой принадлежат тестовому потребителю.
panel.load_reference(self.path) # Чтение CSV выполняется настоящим фоновым потоком.
self.wait_idle(panel) # Сравнение запускается после принятия эталона.
panel._calculate() # Применяем внешний множитель к полному измерению.
self.wait_idle(panel) # Ждём формирования таблицы и кривых.
self.assertIsInstance(panel.plot, Canvas) # Библиотека не навязывает собственный canvas.
self.assertEqual(1., panel.comparison.metrics['rmse']) # Ошибка 1 В подтверждает корректность преобразования источника.
self.assertEqual([2., 4., 6.], list(panel.plot._series[0].values)) # Фабрика получает все исходные амплитуды после преобразования.
panel.mode.setCurrentIndex(1) # Переходим от наложения к отклонению без нового расчёта.
self.assertEqual([1., 1., 1.], list(panel.plot._series[0].values)) # Остаток сохраняет ожидаемый знак на каждой точке.
with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getOpenFileName', return_value=('', '')) as choose: # Отмена выбора позволяет проверить только начальную папку.
panel._open_reference() # Вызываем штатный обработчик открытия эталона.
self.assertEqual(str(self.root), choose.call_args[0][2]) # Путь предоставляется потребителем библиотеки.
output = self.root / 'report.json' # Экспорт направляется в изолированный каталог теста.
with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getSaveFileName', return_value=(str(output), '')) as choose: # Подменяем только выбор имени файла.
panel._save_report() # Реальная запись проверяет UTF-8 и сериализацию показателей.
self.assertEqual(str(self.root / 'reference_comparison.json'), choose.call_args[0][2]) # Предложенное имя отчёта также проходит через provider.
report = json.loads(output.read_text(encoding='utf-8')) # Читаем результат с диска, не подменяя алгоритм экспорта.
self.assertEqual(('capture.csv', 3), (report['measured_file'], report['metrics']['count'])) # Отчёт сохраняет происхождение и полное число сравнённых точек.
def test_dialog_and_empty_source_contract(self): # Отдельное окно сохраняет совместимость без возможности расчёта пустого источника.
dialog = self.create(ReferenceComparisonDialog, sources=[]) # Источник может временно отсутствовать у другого потребителя.
self.assertIsInstance(dialog, QDialog) # Сохраняется настоящий API QDialog, а не имитация через QWidget.
dialog.load_reference(self.path) # Одного эталона недостаточно для численного сравнения.
self.wait_idle(dialog) # После чтения проверяем окончательное состояние элементов.
self.assertFalse(dialog.calculate_button.isEnabled()) # Не допускаем обращения к индексу -1 пустого списка.
dialog.reject() # Прежний способ закрытия отдельного окна поддерживается напрямую.
def test_overlay_loads_automatically_without_canvas(self): # Пользовательский потребитель получает кривую сразу после чтения CSV.
panel = ReferenceOverlayPanel(self.sources, source_path='capture.csv', path_provider=lambda name: str(self.root / name)) # Фабрики canvas и новой таблицы не нужны.
self.panels.append(panel) # Гарантируем остановку worker до удаления временных файлов.
events = [] # Сигнал сообщает существующему графику о готовом дополнительном слое.
panel.changed.connect(lambda: events.append(True)) # Не создаём даже тестовый canvas ради получения кривой.
panel.load_reference(self.path) # После чтения статистика и временное совмещение запускаются автоматически.
self.wait_idle(panel) # Ожидаем обе стадии фонового задания.
specs, data = panel.overlay() # Потребитель добавляет эти данные к своим исходным кривым.
self.assertFalse(hasattr(panel, 'plot')) # Слой принципиально не может подменить исходный canvas.
self.assertEqual(1, len(events)) # Один файл публикует одно готовое наложение.
self.assertEqual('Эталон: U (V)', specs[0][1]) # Легенда различает оригинал и эталон с одинаковым именем канала.
self.assertEqual([(0., 1.), (1., 3.), (2., 5.)], list(data[panel.RESULT_KEY])) # Сохраняем полную сетку и исходные амплитуды эталона.
self.assertEqual(1., panel.comparison.metrics['rmse']) # Внешнее преобразование измерения учитывается в статистике.
self.assertTrue(panel.report_button.isEnabled()) # Автоматический расчёт делает доступным актуальный отчёт.
self.assertEqual(14, panel.results.table.rowCount()) # Полные расчёты доступны переносимому потребителю вместе с данными наложения.
self.assertEqual('1', panel.results.table.item(6, 1).text()) # Восстановленная таблица содержит фактический RMSE, а не только подписи.
self.assertTrue(panel.results.export_button.isEnabled()) # Экспорт доступен непосредственно рядом с подробной таблицей.
output = self.root / 'detailed-report.json' # Подробная область сохраняет актуальный расчёт в изолированный файл.
with patch('set_devices.qt_ports.reference_overlay.QFileDialog.getSaveFileName', return_value=(str(output), '')): # Подменяем только выбор места, сохраняя обработчик экспорта.
panel.results.export_button.click() # Проверяем кнопку восстановленной подробной таблицы.
self.assertEqual(1., json.loads(output.read_text(encoding='utf-8'))['metrics']['rmse']) # JSON содержит фактическое отклонение этого результата.
panel.results.delta_curve.setChecked(True) # Добавление остатка не требует собственной фабрики графика.
specs, data = panel.overlay() # Потребитель получает эталон и отдельный слой отклонения вместе.
self.assertEqual(2, len(specs)) # Обе дополнительные кривые можно добавить к сохранённому оригиналу.
self.assertEqual([1., 1., 1.], list(data[panel.DIFFERENCE_KEY].values)) # Численные остатки совпадают с преобразованными амплитудами измерения.
panel.set_sources([(self.sources[0][0], [(10., 1.), (11., 2.), (12., 3.)])]) # Новый оригинальный кадр не требует повторного выбора файла эталона.
self.wait_idle(panel) # Совмещение времени обновляется после смены источника.
self.assertEqual([10., 11., 12.], panel.trace.times) # Эталон переходит на исходную временную шкалу нового измерения.
self.assertEqual([10., 11., 12.], panel.difference.times) # Остаток следует той же исходной шкале времени без скрытого обнуления.
panel.shift.setValue(10000.) # Сдвиг 10000 мкс выводит эталон за пределы трёхточечного измерения.
self.wait_idle(panel) # Параметр применяется без дополнительной кнопки расчёта.
self.assertIsNone(panel.comparison) # Отсутствие перекрытия исключает только статистику.
self.assertEqual([20., 21., 22.], panel.trace.times) # Кривая остаётся доступной исходному графику даже без общей части.
self.assertEqual(0, panel.results.table.rowCount()) # Старые расчёты не остаются видимыми после потери перекрытия.
self.assertFalse(panel.results.delta_curve.isEnabled() or panel.results.export_button.isEnabled()) # Без корректного результата отключаются остаток и экспорт, но не исходный график.
if __name__ == '__main__': # Набор можно выполнить стандартным Python без pytest.
unittest.main() # Общий порт проверяется отдельно от репозитория потребителя.