# Проверки 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()