Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt. Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor. Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов. Обновить справочники устройств, проекты CCS и регрессионные проверки.
142 lines
19 KiB
Python
142 lines
19 KiB
Python
"""Контракт сравнения проверяется с чужим 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() # Общий порт проверяется отдельно от репозитория потребителя.
|