Insight_UX_1.0 / tests /test_analysis.py
Aryaman25's picture
Update Insight_UX_1.0 with latest changes: add auth, participants, tests, and dev requirements
f9609df
Raw
History Blame Contribute Delete
8.82 kB
"""
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