File size: 8,821 Bytes
f9609df | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | """
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
|