""" Unit tests for analysis.py's pure computation — session loading, AOI attribution, dwell ranking, mouse summarization. No cv2/mediapipe needed (analysis.py itself only imports stdlib + theme). """ import os import json import analysis # --- _load_jsonl / load_session ------------------------------------------- def test_load_jsonl_missing_file_returns_empty(tmp_path): assert analysis._load_jsonl(str(tmp_path / "nope.jsonl")) == [] def test_load_jsonl_skips_malformed_lines(tmp_path): path = tmp_path / "log.jsonl" path.write_text('{"a": 1}\nnot json\n{"b": 2}\n\n', encoding="utf-8") records = analysis._load_jsonl(str(path)) assert records == [{"a": 1}, {"b": 2}] def _write_jsonl(path, records): with open(path, "w", encoding="utf-8") as f: for r in records: f.write(json.dumps(r) + "\n") def test_load_session_filters_by_type_and_sorts_by_time(tmp_path): session_dir = tmp_path / "20260101_120000" session_dir.mkdir() _write_jsonl(session_dir / "gaze_log.jsonl", [ {"type": "gaze", "t": 2.0, "sx": 10, "sy": 10}, {"type": "other", "t": 0.5}, {"type": "gaze", "t": 1.0, "sx": 5, "sy": 5}, ]) _write_jsonl(session_dir / "dom_log.jsonl", [ {"type": "dom", "t": 1.5, "url": "https://example.com", "aois": []}, ]) gaze, dom = analysis.load_session(str(session_dir)) assert [g["t"] for g in gaze] == [1.0, 2.0] # sorted, "other" filtered out assert len(dom) == 1 assert dom[0]["url"] == "https://example.com" def test_load_session_handles_missing_files(tmp_path): session_dir = tmp_path / "20260101_120000" session_dir.mkdir() gaze, dom = analysis.load_session(str(session_dir)) assert gaze == [] assert dom == [] # --- session_summary -------------------------------------------------------- def test_session_summary_empty_gaze(): summary = analysis.session_summary([], []) assert summary == {"duration": 0.0, "url": None, "samples": 0} def test_session_summary_computes_duration_and_url(): gaze = [{"t": 10.0}, {"t": 12.5}, {"t": 15.0}] dom = [{"t": 10.0, "url": "https://a.com"}, {"t": 14.0, "url": "https://b.com"}] summary = analysis.session_summary(gaze, dom) assert summary["duration"] == 5.0 assert summary["url"] == "https://b.com" # most recent dom snapshot assert summary["samples"] == 3 def test_session_summary_url_none_without_dom(): summary = analysis.session_summary([{"t": 0.0}, {"t": 1.0}], []) assert summary["url"] is None # --- friendly_label ----------------------------------------------------- def test_friendly_label_handles_none_and_empty(): assert analysis.friendly_label(None) == "Unlabeled area" assert analysis.friendly_label("") == "Unlabeled area" def test_friendly_label_known_landmarks(): assert analysis.friendly_label("navbar") == "Navigation bar" assert analysis.friendly_label("header") == "Page header" assert analysis.friendly_label("footer") == "Page footer" assert analysis.friendly_label("video") == "Video" def test_friendly_label_image(): assert analysis.friendly_label("img: logo.png") == "Image — logo.png" def test_friendly_label_headings(): assert analysis.friendly_label("h1: Welcome") == 'Main heading — “Welcome”' assert analysis.friendly_label("h2: About") == 'Heading — “About”' assert analysis.friendly_label("h3: Details") == 'Sub-heading — “Details”' def test_friendly_label_paragraph(): assert analysis.friendly_label("p (some text)") == 'Text — “some text…”' def test_friendly_label_id_and_class_selectors(): assert analysis.friendly_label("#hero") == "Section: hero" assert analysis.friendly_label(".card") == "Block: card" def test_friendly_label_falls_back_to_capitalized_raw(): assert analysis.friendly_label("button") == "Button" # --- _find_aoi / attribute_gaze ---------------------------------------- def _aoi(label, x, y, w, h): return {"label": label, "x": x, "y": y, "w": w, "h": h} def test_find_aoi_returns_none_when_no_match(): aois = [_aoi("header", 0, 0, 100, 50)] assert analysis._find_aoi(500, 500, aois) is None def test_find_aoi_matches_point_inside_box(): aois = [_aoi("header", 0, 0, 100, 50)] assert analysis._find_aoi(50, 25, aois) == "header" def test_find_aoi_respects_padding(): aois = [_aoi("header", 100, 100, 50, 50)] # Just outside the box but within PAD_PX (90) of it assert analysis._find_aoi(95, 125, aois) == "header" # Far outside the padded box entirely assert analysis._find_aoi(1000, 1000, aois) is None def test_find_aoi_picks_smallest_area_on_overlap(): aois = [ _aoi("big", 0, 0, 500, 500), _aoi("small", 100, 100, 20, 20), ] assert analysis._find_aoi(110, 110, aois) == "small" def test_find_aoi_skips_embed_label(): aois = [_aoi("embed", 0, 0, 1000, 1000)] assert analysis._find_aoi(500, 500, aois) is None def test_attribute_gaze_all_none_without_dom(): gaze = [{"t": 1.0, "sx": 5, "sy": 5}, {"t": 2.0, "sx": 6, "sy": 6}] result = analysis.attribute_gaze(gaze, []) assert result == [(1.0, None), (2.0, None)] def test_attribute_gaze_uses_most_recent_dom_snapshot(): gaze = [{"t": 5.0, "sx": 50, "sy": 25}] dom = [ {"t": 1.0, "aois": [_aoi("old", 0, 0, 10, 10)]}, {"t": 4.0, "aois": [_aoi("header", 0, 0, 100, 50)]}, ] result = analysis.attribute_gaze(gaze, dom) assert result == [(5.0, "header")] def test_attribute_gaze_before_first_dom_snapshot_is_none(): gaze = [{"t": 0.5, "sx": 50, "sy": 25}] dom = [{"t": 4.0, "aois": [_aoi("header", 0, 0, 100, 50)]}] result = analysis.attribute_gaze(gaze, dom) assert result == [(0.5, None)] # --- compute_dwell_ranking ----------------------------------------------- def test_compute_dwell_ranking_empty_for_fewer_than_two_points(): assert analysis.compute_dwell_ranking([]) == [] assert analysis.compute_dwell_ranking([(0.0, "header")]) == [] def test_compute_dwell_ranking_accumulates_time_per_label(): attributed = [ (0.0, "header"), (1.0, "header"), (2.0, "footer"), (3.0, "footer"), (4.0, None), ] ranking = analysis.compute_dwell_ranking(attributed) labels = {r["label"]: r for r in ranking} assert "Page header" in labels assert "Page footer" in labels assert labels["Page header"]["seconds"] == 2.0 # (1.0-0.0) + (2.0-1.0) assert labels["Page footer"]["seconds"] == 2.0 # (3.0-2.0) + (4.0-3.0) assert labels["Page header"]["hits"] == 2 def test_compute_dwell_ranking_sorted_descending_by_seconds(): attributed = [ (0.0, "footer"), (1.0, "footer"), (2.0, "header"), (2.5, None), ] ranking = analysis.compute_dwell_ranking(attributed) assert ranking[0]["label"] == "Page footer" assert ranking[0]["seconds"] >= ranking[1]["seconds"] def test_compute_dwell_ranking_pct_sums_to_roughly_100(): attributed = [(0.0, "header"), (1.0, "footer"), (2.0, None)] ranking = analysis.compute_dwell_ranking(attributed) assert abs(sum(r["pct"] for r in ranking) - 100.0) < 0.5 # --- summarize_mouse --------------------------------------------------- def test_summarize_mouse_no_file(tmp_path): summary = analysis.summarize_mouse(str(tmp_path)) assert summary["click_count"] == 0 assert summary["interests"] == [] assert summary["trail_points"] == 0 assert summary["heatmap_points"] == 0 def test_summarize_mouse_aggregates_dwell_and_clicks(tmp_path): _write_jsonl(tmp_path / "mouse_log.jsonl", [ {"type": "mouse_batch", "dwell": [{"element": "header", "duration": 1000}], "click": [{"timestamp": "2026-01-01T00:00:01"}], "trail": [1, 2], "heatmap": [1]}, {"type": "mouse_batch", "dwell": [{"element": "header", "duration": 500}], "click": [{"timestamp": "2026-01-01T00:00:00"}], "trail": [1], "heatmap": []}, ]) summary = analysis.summarize_mouse(str(tmp_path)) assert summary["click_count"] == 2 assert summary["trail_points"] == 3 assert summary["heatmap_points"] == 1 assert summary["interests"][0]["element"] == "header" assert summary["interests"][0]["seconds"] == 1.5 # (1000+500)ms -> 1.5s # clicks sorted ascending by timestamp assert summary["clicks"][0]["timestamp"] == "2026-01-01T00:00:00" def test_summarize_mouse_caps_clicks_at_fifty(tmp_path): clicks = [{"timestamp": f"2026-01-01T00:{i:02d}:00"} for i in range(60)] _write_jsonl(tmp_path / "mouse_log.jsonl", [ {"type": "mouse_batch", "dwell": [], "click": clicks, "trail": [], "heatmap": []}, ]) summary = analysis.summarize_mouse(str(tmp_path)) assert summary["click_count"] == 60 assert len(summary["clicks"]) == 50