"""Fixtures for chart tests.""" import json import re from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from meshmon.charts import ( CHART_THEMES, DataPoint, TimeSeries, ) @pytest.fixture def light_theme(): """Light chart theme.""" return CHART_THEMES["light"] @pytest.fixture def dark_theme(): """Dark chart theme.""" return CHART_THEMES["dark"] @pytest.fixture def sample_timeseries(): """Sample time series with 24 hours of data.""" now = datetime.now() points = [] for i in range(24): ts = now - timedelta(hours=23 - i) # Simulate battery voltage pattern (higher during day, lower at night) value = 3.7 + 0.3 * abs(12 - i) / 12 points.append(DataPoint(timestamp=ts, value=value)) return TimeSeries( metric="bat", role="repeater", period="day", points=points, ) @pytest.fixture def empty_timeseries(): """Empty time series (no data).""" return TimeSeries( metric="bat", role="repeater", period="day", points=[], ) @pytest.fixture def single_point_timeseries(): """Time series with single data point.""" now = datetime.now() return TimeSeries( metric="bat", role="repeater", period="day", points=[DataPoint(timestamp=now, value=3.85)], ) @pytest.fixture def counter_timeseries(): """Sample counter time series (for rate calculation testing).""" now = datetime.now() points = [] for i in range(24): ts = now - timedelta(hours=23 - i) # Simulate increasing counter value = float(i * 100) points.append(DataPoint(timestamp=ts, value=value)) return TimeSeries( metric="nb_recv", role="repeater", period="day", points=points, ) @pytest.fixture def week_timeseries(): """Sample week time series for binning tests.""" now = datetime.now() points = [] # One point per hour for 7 days = 168 points for i in range(168): ts = now - timedelta(hours=167 - i) value = 3.7 + 0.2 * (i % 24) / 24 points.append(DataPoint(timestamp=ts, value=value)) return TimeSeries( metric="bat", role="repeater", period="week", points=points, ) def normalize_svg_for_snapshot(svg: str) -> str: """Normalize SVG for deterministic snapshot comparison. Handles matplotlib's dynamic ID generation while preserving semantic content that affects chart appearance. Uses sequential normalized IDs to preserve relationships between definitions and references. IMPORTANT: Each ID type gets its own prefix to maintain uniqueness: - tick_N: matplotlib tick marks (m[0-9a-f]{8,}) - clip_N: clipPath definitions (p[0-9a-f]{8,}) - glyph_N: font glyph definitions (DejaVuSans-XX) This ensures that: 1. All IDs remain unique (no duplicates) 2. References (xlink:href, url(#...)) correctly resolve 3. SVG renders identically to the original """ # Patterns for matplotlib's random IDs, each with its own prefix # to maintain uniqueness across different ID types id_type_patterns = [ (r'm[0-9a-f]{8,}', 'tick'), # matplotlib tick marks (r'p[0-9a-f]{8,}', 'clip'), # matplotlib clipPaths (r'DejaVuSans-[0-9a-f]+', 'glyph'), # font glyphs (hex-named) ] # Find all IDs in the document all_ids = re.findall(r'id="([^"]+)"', svg) # Create mapping for IDs that match random patterns # Use separate counters per type to ensure predictable naming id_mapping = {} type_counters = {prefix: 0 for _, prefix in id_type_patterns} for id_val in all_ids: if id_val in id_mapping: continue for pattern, prefix in id_type_patterns: if re.fullmatch(pattern, id_val): new_id = f"{prefix}_{type_counters[prefix]}" id_mapping[id_val] = new_id type_counters[prefix] += 1 break # Replace all occurrences of mapped IDs (definitions and references) # Process in a deterministic order (sorted by original ID) for consistency for old_id, new_id in sorted(id_mapping.items()): # Replace id definitions svg = svg.replace(f'id="{old_id}"', f'id="{new_id}"') # Replace url(#...) references svg = svg.replace(f'url(#{old_id})', f'url(#{new_id})') # Replace xlink:href references svg = svg.replace(f'xlink:href="#{old_id}"', f'xlink:href="#{new_id}"') # Replace href references (SVG 2.0 style without xlink prefix) svg = svg.replace(f'href="#{old_id}"', f'href="#{new_id}"') # Remove matplotlib version comment (changes between versions) svg = re.sub(r'', '', svg) # Normalize dc:date timestamp (changes on each render) svg = re.sub(r'[^<]+', 'NORMALIZED', svg) # Normalize whitespace (but preserve newlines for readability) svg = re.sub(r'[ \t]+', ' ', svg) svg = re.sub(r' ?\n ?', '\n', svg) return svg.strip() def extract_svg_data_attributes(svg: str) -> dict: """Extract data-* attributes from SVG for validation. Args: svg: SVG string Returns: Dict with extracted data attributes """ data = {} # Extract data-points JSON points_match = re.search(r'data-points="([^"]+)"', svg) if points_match: points_str = points_match.group(1).replace('"', '"') try: data["points"] = json.loads(points_str) except json.JSONDecodeError: data["points_raw"] = points_str # Extract other data attributes for attr in ["data-metric", "data-period", "data-theme", "data-x-start", "data-x-end", "data-y-min", "data-y-max"]: match = re.search(rf'{attr}="([^"]+)"', svg) if match: key = attr.replace("data-", "").replace("-", "_") data[key] = match.group(1) return data @pytest.fixture def snapshots_dir(): """Path to snapshots directory.""" return Path(__file__).parent.parent / "snapshots" / "svg" @pytest.fixture def sample_raw_points(): """Raw points for aggregation testing.""" now = datetime.now() return [ (now - timedelta(hours=2), 3.7), (now - timedelta(hours=1, minutes=45), 3.72), (now - timedelta(hours=1, minutes=30), 3.75), (now - timedelta(hours=1), 3.8), (now - timedelta(minutes=30), 3.82), (now, 3.85), ] # --- Deterministic fixtures for snapshot testing --- # These use fixed timestamps to produce consistent SVG output @pytest.fixture def snapshot_base_time(): """Fixed base time for deterministic snapshot tests. Uses 2024-01-15 12:00:00 UTC as a stable reference point. Explicitly set to UTC to ensure consistent behavior across all machines. """ return datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) @pytest.fixture def snapshot_gauge_timeseries(snapshot_base_time): """Deterministic gauge time series for snapshot testing. Creates a battery voltage pattern over 24 hours with fixed timestamps. """ points = [] for i in range(24): ts = snapshot_base_time - timedelta(hours=23 - i) # Simulate battery voltage pattern (higher during day, lower at night) value = 3.7 + 0.3 * abs(12 - i) / 12 points.append(DataPoint(timestamp=ts, value=value)) return TimeSeries( metric="bat", role="repeater", period="day", points=points, ) @pytest.fixture def snapshot_counter_timeseries(snapshot_base_time): """Deterministic counter time series for snapshot testing. Creates a packet rate pattern over 24 hours with fixed timestamps. This represents rate values (already converted from counter deltas). """ points = [] for i in range(24): ts = snapshot_base_time - timedelta(hours=23 - i) # Simulate packet rate - higher during day hours (6-18) hour = (i + 12) % 24 # Convert to actual hour of day value = ( 2.0 + (hour - 6) * 0.3 # 2.0 to 5.6 packets/min if 6 <= hour <= 18 else 0.5 + (hour % 6) * 0.1 # 0.5 to 1.1 packets/min (night) ) points.append(DataPoint(timestamp=ts, value=value)) return TimeSeries( metric="nb_recv", role="repeater", period="day", points=points, ) @pytest.fixture def snapshot_empty_timeseries(): """Empty time series for snapshot testing.""" return TimeSeries( metric="bat", role="repeater", period="day", points=[], ) @pytest.fixture def snapshot_single_point_timeseries(snapshot_base_time): """Time series with single data point for snapshot testing.""" return TimeSeries( metric="bat", role="repeater", period="day", points=[DataPoint(timestamp=snapshot_base_time, value=3.85)], ) def normalize_svg_for_snapshot_full(svg: str) -> str: """Extended SVG normalization for full snapshot comparison. In addition to standard normalization, this also: - Removes timestamps from data-points to allow content-only comparison - Normalizes floating point precision Used when you want to compare the visual structure but not exact data values. """ # Apply standard normalization first svg = normalize_svg_for_snapshot(svg) # Normalize data-points timestamps (keep structure, normalize values) # This allows charts with different base times to still match structure svg = re.sub(r'"ts":\s*\d+', '"ts":0', svg) # Normalize floating point values to 2 decimal places in attributes def normalize_float(match): try: val = float(match.group(1)) return f'{val:.2f}' except ValueError: return match.group(0) svg = re.sub(r'(\d+\.\d{3,})', normalize_float, svg) return svg