"""Контракт сравнения проверяется с чужим 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() # Общий порт проверяется отдельно от репозитория потребителя.