Files
meshcore-stats/tests/charts/test_chart_render.py
Jorijn Schrijvershof ca13e31aae test: stabilize suite and broaden integration coverage (#32)
* tests: cache integration/report fixtures to speed up tests

* fix: speed up yearly aggregation and refresh timings report

* chore: remove the report

* fix: unrecognized named-value: 'runner'. Located at position 1 within expression: runner.temp

* fix: ruff linting error

* test: strengthen assertions and stabilize tests

* test(integration): expand rendered chart metrics
2026-01-08 21:20:34 +01:00

434 lines
15 KiB
Python

"""Tests for SVG chart rendering."""
import os
from datetime import datetime, timedelta
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
from meshmon.charts import (
CHART_THEMES,
DataPoint,
TimeSeries,
render_chart_svg,
)
from .conftest import extract_svg_data_attributes, normalize_svg_for_snapshot
def _svg_viewbox_dims(svg: str) -> tuple[float, float]:
root = ET.fromstring(svg)
viewbox = root.attrib.get("viewBox")
assert viewbox is not None
_, _, width, height = viewbox.split()
return float(width), float(height)
class TestRenderChartSvg:
"""Tests for render_chart_svg function."""
def test_returns_svg_string(self, sample_timeseries, light_theme):
"""Returns valid SVG string."""
svg = render_chart_svg(sample_timeseries, light_theme)
assert svg.startswith("<?xml") or svg.startswith("<svg")
assert "</svg>" in svg
def test_includes_svg_namespace(self, sample_timeseries, light_theme):
"""SVG includes xmlns namespace."""
svg = render_chart_svg(sample_timeseries, light_theme)
assert 'xmlns="http://www.w3.org/2000/svg"' in svg
def test_respects_width_height(self, sample_timeseries, light_theme):
"""SVG respects specified dimensions."""
svg_default = render_chart_svg(sample_timeseries, light_theme)
svg_small = render_chart_svg(sample_timeseries, light_theme, width=600, height=200)
default_w, default_h = _svg_viewbox_dims(svg_default)
small_w, small_h = _svg_viewbox_dims(svg_small)
assert small_w < default_w
assert small_h < default_h
def test_uses_theme_colors(self, sample_timeseries, light_theme, dark_theme):
"""Different themes produce different colors."""
light_svg = render_chart_svg(sample_timeseries, light_theme)
dark_svg = render_chart_svg(sample_timeseries, dark_theme)
# Check theme colors are present
assert light_theme.line in light_svg or f"#{light_theme.line}" in light_svg
assert dark_theme.line in dark_svg or f"#{dark_theme.line}" in dark_svg
class TestEmptyChartRendering:
"""Tests for rendering empty charts."""
def test_empty_chart_renders(self, empty_timeseries, light_theme):
"""Empty time series renders without error."""
svg = render_chart_svg(empty_timeseries, light_theme)
assert "</svg>" in svg
def test_empty_chart_shows_message(self, empty_timeseries, light_theme):
"""Empty chart shows 'No data available' message."""
svg = render_chart_svg(empty_timeseries, light_theme)
assert "No data available" in svg
class TestDataPointsInjection:
"""Tests for data-points attribute injection."""
def test_includes_data_points(self, sample_timeseries, light_theme):
"""SVG includes data-points attribute."""
svg = render_chart_svg(sample_timeseries, light_theme)
assert "data-points=" in svg
def test_data_points_valid_json(self, sample_timeseries, light_theme):
"""data-points contains valid JSON array."""
svg = render_chart_svg(sample_timeseries, light_theme)
data = extract_svg_data_attributes(svg)
assert "points" in data
assert isinstance(data["points"], list)
def test_data_points_count_matches(self, sample_timeseries, light_theme):
"""data-points count matches time series points."""
svg = render_chart_svg(sample_timeseries, light_theme)
data = extract_svg_data_attributes(svg)
assert len(data["points"]) == len(sample_timeseries.points)
def test_data_points_structure(self, sample_timeseries, light_theme):
"""Each data point has ts and v keys."""
svg = render_chart_svg(sample_timeseries, light_theme)
data = extract_svg_data_attributes(svg)
for point in data["points"]:
assert "ts" in point
assert "v" in point
assert isinstance(point["ts"], int)
assert isinstance(point["v"], (int, float))
def test_includes_metadata_attributes(self, sample_timeseries, light_theme):
"""SVG includes metric, period, theme attributes."""
svg = render_chart_svg(sample_timeseries, light_theme)
data = extract_svg_data_attributes(svg)
assert data.get("metric") == "bat"
assert data.get("period") == "day"
assert data.get("theme") == "light"
def test_includes_axis_range_attributes(self, sample_timeseries, light_theme):
"""SVG includes x and y axis range attributes."""
svg = render_chart_svg(sample_timeseries, light_theme)
data = extract_svg_data_attributes(svg)
assert "x_start" in data
assert "x_end" in data
assert "y_min" in data
assert "y_max" in data
class TestYAxisLimits:
"""Tests for Y-axis limit handling."""
def test_fixed_y_limits(self, sample_timeseries, light_theme):
"""Fixed Y limits are applied."""
svg = render_chart_svg(
sample_timeseries, light_theme,
y_min=3.0, y_max=4.5
)
data = extract_svg_data_attributes(svg)
assert float(data["y_min"]) == 3.0
assert float(data["y_max"]) == 4.5
def test_auto_y_limits_with_padding(self, light_theme):
"""Auto Y limits add padding around data."""
now = datetime.now()
points = [
DataPoint(timestamp=now, value=10.0),
DataPoint(timestamp=now + timedelta(hours=1), value=20.0),
]
ts = TimeSeries(metric="test", role="repeater", period="day", points=points)
svg = render_chart_svg(ts, light_theme)
data = extract_svg_data_attributes(svg)
y_min = float(data["y_min"])
y_max = float(data["y_max"])
# Auto limits should extend beyond data range
assert y_min < 10.0
assert y_max > 20.0
class TestXAxisLimits:
"""Tests for X-axis limit handling."""
def test_fixed_x_limits(self, sample_timeseries, light_theme):
"""Fixed X limits are applied."""
x_start = datetime(2024, 1, 1, 0, 0, 0)
x_end = datetime(2024, 1, 2, 0, 0, 0)
svg = render_chart_svg(
sample_timeseries, light_theme,
x_start=x_start, x_end=x_end
)
data = extract_svg_data_attributes(svg)
assert int(data["x_start"]) == int(x_start.timestamp())
assert int(data["x_end"]) == int(x_end.timestamp())
class TestChartThemes:
"""Tests for chart theme constants."""
def test_light_theme_exists(self):
"""Light theme is defined."""
assert "light" in CHART_THEMES
def test_dark_theme_exists(self):
"""Dark theme is defined."""
assert "dark" in CHART_THEMES
def test_themes_have_required_colors(self):
"""Themes have all required color attributes."""
required = ["background", "canvas", "text", "axis", "grid", "line", "area"]
for theme in CHART_THEMES.values():
for attr in required:
assert hasattr(theme, attr), f"Theme missing {attr}"
assert getattr(theme, attr), f"Theme {attr} is empty"
def test_theme_colors_are_valid_hex(self):
"""Theme colors are valid hex strings."""
import re
hex_pattern = re.compile(r'^[0-9a-fA-F]{6,8}$')
for name, theme in CHART_THEMES.items():
for attr in ["background", "canvas", "text", "axis", "grid", "line", "area"]:
color = getattr(theme, attr)
assert hex_pattern.match(color), f"{name}.{attr} = {color} is not valid hex"
class TestSvgNormalization:
"""Tests for SVG snapshot normalization helper."""
def test_normalize_removes_matplotlib_ids(self, sample_timeseries, light_theme):
"""Normalization removes matplotlib-generated IDs."""
svg = render_chart_svg(sample_timeseries, light_theme)
normalized = normalize_svg_for_snapshot(svg)
# Should not have matplotlib's randomized IDs
import re
# Look for patterns like id="abc123-def456"
random_ids = re.findall(r'id="[a-z0-9]+-[0-9a-f]{8,}"', normalized)
assert len(random_ids) == 0
def test_normalize_preserves_data_attributes(self, sample_timeseries, light_theme):
"""Normalization preserves data-* attributes."""
svg = render_chart_svg(sample_timeseries, light_theme)
normalized = normalize_svg_for_snapshot(svg)
assert "data-metric=" in normalized
assert "data-points=" in normalized
def test_normalize_removes_matplotlib_comment(self, sample_timeseries, light_theme):
"""Normalization removes matplotlib version comment."""
svg = render_chart_svg(sample_timeseries, light_theme)
normalized = normalize_svg_for_snapshot(svg)
assert "Created with matplotlib" not in normalized
class TestSvgSnapshots:
"""Snapshot tests for SVG chart rendering.
These tests compare rendered SVG output against saved snapshots
to detect unintended changes in chart appearance.
To update snapshots, run: UPDATE_SNAPSHOTS=1 pytest tests/charts/test_chart_render.py
"""
@pytest.fixture
def update_snapshots(self):
"""Return True if snapshots should be updated."""
return os.environ.get("UPDATE_SNAPSHOTS", "").lower() in ("1", "true", "yes")
def _assert_snapshot_match(
self,
actual: str,
snapshot_path: Path,
update: bool,
) -> None:
"""Compare SVG against snapshot, with optional update mode."""
# Normalize for comparison
normalized = normalize_svg_for_snapshot(actual)
if update:
# Update mode: write normalized SVG to snapshot
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
snapshot_path.write_text(normalized, encoding="utf-8")
pytest.skip(f"Snapshot updated: {snapshot_path}")
else:
# Compare mode
if not snapshot_path.exists():
# Create new snapshot if it doesn't exist
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
snapshot_path.write_text(normalized, encoding="utf-8")
pytest.fail(
f"Snapshot created: {snapshot_path}\n"
f"Run tests again to verify, or set UPDATE_SNAPSHOTS=1 to regenerate."
)
expected = snapshot_path.read_text(encoding="utf-8")
if normalized != expected:
# Show first difference for debugging
norm_lines = normalized.splitlines()
exp_lines = expected.splitlines()
diff_info = []
for i, (n, e) in enumerate(zip(norm_lines, exp_lines, strict=False), 1):
if n != e:
diff_info.append(f"Line {i} differs:")
diff_info.append(f" Expected: {e[:100]}...")
diff_info.append(f" Actual: {n[:100]}...")
if len(diff_info) > 12:
diff_info.append(" (more differences omitted)")
break
if len(norm_lines) != len(exp_lines):
diff_info.append(
f"Line count: expected {len(exp_lines)}, got {len(norm_lines)}"
)
pytest.fail(
f"Snapshot mismatch: {snapshot_path}\n"
f"Set UPDATE_SNAPSHOTS=1 to regenerate.\n\n"
+ "\n".join(diff_info)
)
def test_gauge_chart_light_theme(
self,
snapshot_gauge_timeseries,
light_theme,
snapshots_dir,
update_snapshots,
):
"""Gauge metric chart with light theme matches snapshot."""
svg = render_chart_svg(
snapshot_gauge_timeseries,
light_theme,
y_min=3.0,
y_max=4.2,
)
snapshot_path = snapshots_dir / "bat_day_light.svg"
self._assert_snapshot_match(svg, snapshot_path, update_snapshots)
def test_gauge_chart_dark_theme(
self,
snapshot_gauge_timeseries,
dark_theme,
snapshots_dir,
update_snapshots,
):
"""Gauge metric chart with dark theme matches snapshot."""
svg = render_chart_svg(
snapshot_gauge_timeseries,
dark_theme,
y_min=3.0,
y_max=4.2,
)
snapshot_path = snapshots_dir / "bat_day_dark.svg"
self._assert_snapshot_match(svg, snapshot_path, update_snapshots)
def test_counter_chart_light_theme(
self,
snapshot_counter_timeseries,
light_theme,
snapshots_dir,
update_snapshots,
):
"""Counter metric (rate) chart with light theme matches snapshot."""
svg = render_chart_svg(
snapshot_counter_timeseries,
light_theme,
)
snapshot_path = snapshots_dir / "nb_recv_day_light.svg"
self._assert_snapshot_match(svg, snapshot_path, update_snapshots)
def test_counter_chart_dark_theme(
self,
snapshot_counter_timeseries,
dark_theme,
snapshots_dir,
update_snapshots,
):
"""Counter metric (rate) chart with dark theme matches snapshot."""
svg = render_chart_svg(
snapshot_counter_timeseries,
dark_theme,
)
snapshot_path = snapshots_dir / "nb_recv_day_dark.svg"
self._assert_snapshot_match(svg, snapshot_path, update_snapshots)
def test_empty_chart_light_theme(
self,
snapshot_empty_timeseries,
light_theme,
snapshots_dir,
update_snapshots,
):
"""Empty chart with 'No data available' matches snapshot."""
svg = render_chart_svg(
snapshot_empty_timeseries,
light_theme,
)
snapshot_path = snapshots_dir / "empty_day_light.svg"
self._assert_snapshot_match(svg, snapshot_path, update_snapshots)
def test_empty_chart_dark_theme(
self,
snapshot_empty_timeseries,
dark_theme,
snapshots_dir,
update_snapshots,
):
"""Empty chart with dark theme matches snapshot."""
svg = render_chart_svg(
snapshot_empty_timeseries,
dark_theme,
)
snapshot_path = snapshots_dir / "empty_day_dark.svg"
self._assert_snapshot_match(svg, snapshot_path, update_snapshots)
def test_single_point_chart(
self,
snapshot_single_point_timeseries,
light_theme,
snapshots_dir,
update_snapshots,
):
"""Chart with single data point matches snapshot."""
svg = render_chart_svg(
snapshot_single_point_timeseries,
light_theme,
y_min=3.0,
y_max=4.2,
)
snapshot_path = snapshots_dir / "single_point_day_light.svg"
self._assert_snapshot_match(svg, snapshot_path, update_snapshots)