feat: replay chart with zoomable viewpor
This commit is contained in:
@@ -59,3 +59,231 @@ def test_non_numeric_values_are_skipped_not_crashing():
|
||||
|
||||
assert item._min_y == pytest.approx(149.9)
|
||||
assert item._max_y == pytest.approx(151.1)
|
||||
|
||||
|
||||
# ---- viewport_stats (replay chart min/max/avg readouts) ----
|
||||
# Independently hand-computed against PopupChartForm.cs's recalc_stats
|
||||
# semantics: aggregate across every sensor's points in [start, end], plus
|
||||
# which sensor achieved each extreme.
|
||||
|
||||
|
||||
def test_viewport_stats_full_range_aggregate():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
names = ["Sensor 1", "Sensor 2"]
|
||||
|
||||
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||
|
||||
assert vmin == pytest.approx(147.0)
|
||||
assert vmax == pytest.approx(152.0)
|
||||
assert avg == pytest.approx(149.25)
|
||||
assert min_sensor == "Sensor 2"
|
||||
assert max_sensor == "Sensor 1"
|
||||
|
||||
|
||||
def test_viewport_stats_restricted_to_subrange():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
names = ["Sensor 1", "Sensor 2"]
|
||||
|
||||
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1)
|
||||
|
||||
assert vmin == pytest.approx(147.5)
|
||||
assert vmax == pytest.approx(151.0)
|
||||
assert avg == pytest.approx(149.125)
|
||||
assert min_sensor == "Sensor 2"
|
||||
assert max_sensor == "Sensor 1"
|
||||
|
||||
|
||||
def test_viewport_stats_empty_series_returns_zeros():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "")
|
||||
|
||||
|
||||
def test_viewport_stats_skips_non_numeric():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
series = [[150.0, "bad", 151.0]]
|
||||
names = ["Sensor 1"]
|
||||
|
||||
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||
|
||||
assert vmin == pytest.approx(150.0)
|
||||
assert vmax == pytest.approx(151.0)
|
||||
assert avg == pytest.approx(150.5)
|
||||
assert min_sensor == "Sensor 1"
|
||||
assert max_sensor == "Sensor 1"
|
||||
|
||||
|
||||
# ---- GraphQuickItem viewport properties (replay chart) ----
|
||||
# Default viewport (0, -1) means "whole series" so the already-shipped Graph
|
||||
# tab (which never sets these) is unaffected — see docs/adr/0004.
|
||||
|
||||
|
||||
def test_viewport_defaults_to_whole_range_and_markers_off():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
assert item.showMinMaxMarkers is False
|
||||
|
||||
|
||||
def test_view_stats_reflect_default_full_range_viewport():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
|
||||
assert item.viewMin == pytest.approx(147.0)
|
||||
assert item.viewMax == pytest.approx(152.0)
|
||||
assert item.viewAvg == pytest.approx(149.25)
|
||||
assert item.viewMinSensor == "Sensor 2"
|
||||
assert item.viewMaxSensor == "Sensor 1"
|
||||
|
||||
|
||||
def test_view_stats_restrict_to_narrowed_viewport():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
|
||||
item.viewStartIndex = 0
|
||||
item.viewEndIndex = 1
|
||||
|
||||
assert item.viewMin == pytest.approx(147.5)
|
||||
assert item.viewMax == pytest.approx(151.0)
|
||||
assert item.viewAvg == pytest.approx(149.125)
|
||||
|
||||
|
||||
def test_auto_range_y_restricted_to_narrowed_viewport():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||
# Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210]
|
||||
assert item._min_y == pytest.approx(90.0)
|
||||
assert item._max_y == pytest.approx(210.0)
|
||||
|
||||
# Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad
|
||||
item.viewStartIndex = 0
|
||||
item.viewEndIndex = 0
|
||||
|
||||
assert item._min_y == pytest.approx(145.0)
|
||||
assert item._max_y == pytest.approx(205.0)
|
||||
|
||||
|
||||
# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ----
|
||||
# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on
|
||||
# whole numbers -- avoids rounding ambiguity in the independent hand trace.
|
||||
|
||||
|
||||
def _eleven_point_item():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[float(i) for i in range(11)]]
|
||||
return item
|
||||
|
||||
|
||||
def test_zoom_in_centers_on_fraction():
|
||||
item = _eleven_point_item()
|
||||
|
||||
# Full range is (0, 10), width 10. Zooming to 40% width centered at the
|
||||
# midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7.
|
||||
item.zoomAtFraction(0.5, 0.4)
|
||||
|
||||
assert item.viewStartIndex == 3
|
||||
assert item.viewEndIndex == 7
|
||||
|
||||
|
||||
def test_zoom_out_clamps_to_data_bounds():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 3
|
||||
item.viewEndIndex = 7
|
||||
|
||||
# width=4, zoom out 3x centered at midpoint (frac=0.5): requested
|
||||
# new_width=12 exceeds the data's max width of 10, so it clamps to the
|
||||
# full range (0, 10).
|
||||
item.zoomAtFraction(0.5, 3.0)
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == 10
|
||||
|
||||
|
||||
def test_pan_shifts_viewport_by_fraction_of_width():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 4
|
||||
item.viewEndIndex = 8
|
||||
|
||||
# width=4, pan by 50% of width (2 indices): (4,8) -> (6,10)
|
||||
item.panByFraction(0.5)
|
||||
|
||||
assert item.viewStartIndex == 6
|
||||
assert item.viewEndIndex == 10
|
||||
|
||||
|
||||
def test_pan_clamps_at_right_edge_preserving_width():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 4
|
||||
item.viewEndIndex = 8
|
||||
|
||||
# width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is
|
||||
# out of bounds (max index 10), so the whole window shifts back by 2 to
|
||||
# stay in bounds while keeping width=4: (6,10).
|
||||
item.panByFraction(1.0)
|
||||
|
||||
assert item.viewStartIndex == 6
|
||||
assert item.viewEndIndex == 10
|
||||
|
||||
|
||||
def test_reset_zoom_restores_full_range_sentinel():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 3
|
||||
item.viewEndIndex = 7
|
||||
|
||||
item.resetZoom()
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
|
||||
|
||||
def test_zoom_noop_when_fewer_than_two_points():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[42.0]]
|
||||
|
||||
item.zoomAtFraction(0.5, 0.5)
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
|
||||
|
||||
def test_loading_new_series_while_zoomed_resets_viewport_to_full_range():
|
||||
"""Regression: zooming near the tail of a long run, then loading a new
|
||||
(shorter) file, left the stale viewport pointing past the new data --
|
||||
every per-series slice became empty and the chart went blank with a
|
||||
stale Y-range and zeroed-out min/max/avg readouts. New data must reset
|
||||
the viewport to the full range (PopupChartForm.cs's SetData() parity).
|
||||
"""
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[float(i) for i in range(20)]]
|
||||
item.viewStartIndex = 15
|
||||
item.viewEndIndex = 19
|
||||
|
||||
item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]]
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
assert item.viewMin == pytest.approx(100.0)
|
||||
assert item.viewMax == pytest.approx(104.0)
|
||||
assert item.viewAvg == pytest.approx(102.0)
|
||||
|
||||
Reference in New Issue
Block a user