Files
templates/python/tests/test_plot.py
Andrey a5e46d4627 Расширить обмен по шинам, CAN-адаптеры и обработку графиков
Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
2026-10-17 14:36:14 +03:00

108 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Проверки plot: фиксируют ожидаемое поведение на воспроизводимых входных данных.
# Сценарии для навигации по файлу:
# test_shared_numeric_fixtures
# test_markers_stay_in_data_coordinates_and_cross
# test_invalid_numeric_inputs_do_not_escape_to_painter
# test_locked_viewport_and_decibel_delta
# Тестовые значения и подмены окружения относятся к проверке контракта; они не являются
# настройками рабочего устройства.
"""Cross-port contract: Python/ctypes and Kotlin/JNI consume the same fixtures."""
import ctypes
import json
import math
import os
from pathlib import Path
import unittest
from dataclasses import replace
from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport
@unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Set SETPROTOCOL_LIBRARY to the built C library")
class PlotTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.core = PlotMath(ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"]))
def test_shared_numeric_fixtures(self):
path = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/plot-v1.json"
for case in json.loads(path.read_text())["cases"]:
with self.subTest(case=case["name"]):
if case["output"] is None:
with self.assertRaises(ValueError):
self.core.call(case["op"], *case["input"])
else:
actual = self.core.call(case["op"], *case["input"])
self.assertEqual(len(actual), len(case["output"]))
for a, b in zip(actual, case["output"]):
self.assertAlmostEqual(a, b, places=10)
def test_markers_stay_in_data_coordinates_and_cross(self):
bounds = Bounds(1000, 2000, -10, 10)
markers = Markers().positioned(self.core, bounds)
self.assertTrue(markers.enabled(Marker.A))
self.assertFalse(markers.enabled(Marker.C))
self.assertTrue(replace(markers, x_pairs=2).enabled(Marker.C))
zoomed = bounds.visible(self.core, Viewport(.25, .25, .5, .5))
self.assertEqual(markers, markers.positioned(self.core, zoomed))
moved = markers.drag(self.core, Marker.A, 50, 500, zoomed)
self.assertAlmostEqual(moved.a, markers.a + 50)
self.assertEqual(markers.b, moved.b)
self.assertEqual(Marker.A, moved.hit(self.core, 0, 50, 500, 100, 20, zoomed))
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))
with self.assertRaises(ValueError):
self.core.pinch_axis(value, 10, 8)
def test_locked_viewport_and_decibel_delta(self):
locked = Viewport(locked=True)
self.assertEqual(locked, locked.transform(self.core, zoom_x=2, pan_y=.2))
self.assertAlmostEqual(20, self.core.db_delta(1, 10))
self.assertAlmostEqual(-20, self.core.db_delta(10, 1))
self.assertIsNone(self.core.db_delta(0, 1))
self.assertEqual(Bounds(0, 500, -2, 2), self.core.limits(0, 500, -2, 2))
with self.assertRaises(ValueError):
self.core.limits(1, 1, -2, 2)
if __name__ == "__main__":
unittest.main()