refactor(tests): split into tests/unit and tests/tools
This commit is contained in:
336
tests/tools/test_balabit_adapter.py
Normal file
336
tests/tools/test_balabit_adapter.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Tests for Balabit Mouse Dynamics Challenge data adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_module_exports():
|
||||
"""The adapter module must export the public functions used by CLI."""
|
||||
from tools.data_adapters import balabit
|
||||
assert hasattr(balabit, "parse_session_csv")
|
||||
assert hasattr(balabit, "segment_by_clicks")
|
||||
assert hasattr(balabit, "filter_segments")
|
||||
assert hasattr(balabit, "process_session")
|
||||
assert hasattr(balabit, "MouseEvent")
|
||||
assert hasattr(balabit, "Segment")
|
||||
|
||||
|
||||
def test_mouse_event_dataclass():
|
||||
"""MouseEvent has expected fields."""
|
||||
from tools.data_adapters.balabit import MouseEvent
|
||||
e = MouseEvent(t_ms=100, button="NoButton", state="Move", x=300, y=400)
|
||||
assert e.t_ms == 100
|
||||
assert e.state == "Move"
|
||||
assert e.x == 300
|
||||
|
||||
|
||||
def test_segment_dataclass():
|
||||
"""Segment has expected fields."""
|
||||
from tools.data_adapters.balabit import MouseEvent, Segment
|
||||
events = [MouseEvent(t_ms=0, button="NoButton", state="Move", x=10, y=20)]
|
||||
s = Segment(events=events, click_x=100, click_y=200, click_t_ms=500, session_id="user1_s1")
|
||||
assert s.events == events
|
||||
assert s.click_x == 100
|
||||
assert s.session_id == "user1_s1"
|
||||
|
||||
|
||||
def _write_csv(path: Path, rows: list[str]) -> None:
|
||||
"""Helper to write a Balabit-format CSV with header."""
|
||||
header = "record timestamp,client timestamp,button,state,x,y"
|
||||
path.write_text(header + "\n" + "\n".join(rows) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
class TestParseSessionCsv:
|
||||
def test_parses_basic_rows(self, tmp_path):
|
||||
from tools.data_adapters.balabit import parse_session_csv
|
||||
csv = tmp_path / "session_1"
|
||||
_write_csv(csv, [
|
||||
"1500000000.000,0.000,NoButton,Move,100,200",
|
||||
"1500000000.050,0.050,NoButton,Move,110,210",
|
||||
"1500000000.100,0.100,Left,Pressed,120,220",
|
||||
])
|
||||
events = parse_session_csv(csv)
|
||||
assert len(events) == 3
|
||||
assert events[0].t_ms == 0
|
||||
assert events[0].state == "Move"
|
||||
assert events[0].x == 100
|
||||
assert events[2].t_ms == 100
|
||||
assert events[2].state == "Pressed"
|
||||
assert events[2].button == "Left"
|
||||
|
||||
def test_handles_float_timestamps(self, tmp_path):
|
||||
"""Client timestamps are floats in seconds; we convert to int ms."""
|
||||
from tools.data_adapters.balabit import parse_session_csv
|
||||
csv = tmp_path / "session_2"
|
||||
_write_csv(csv, [
|
||||
"0,1.234,NoButton,Move,50,60",
|
||||
"0,1.250,NoButton,Move,55,65",
|
||||
])
|
||||
events = parse_session_csv(csv)
|
||||
assert events[0].t_ms == 1234
|
||||
assert events[1].t_ms == 1250
|
||||
|
||||
def test_skips_malformed_rows(self, tmp_path):
|
||||
"""Rows with bad data are logged and skipped, not raised."""
|
||||
from tools.data_adapters.balabit import parse_session_csv
|
||||
csv = tmp_path / "session_3"
|
||||
_write_csv(csv, [
|
||||
"0,0.000,NoButton,Move,100,200",
|
||||
"BROKEN_ROW",
|
||||
"0,abc,NoButton,Move,100,200", # bad timestamp
|
||||
"0,0.100,NoButton,Move,150,250",
|
||||
])
|
||||
events = parse_session_csv(csv)
|
||||
assert len(events) == 2
|
||||
assert events[0].x == 100
|
||||
assert events[1].x == 150
|
||||
|
||||
def test_returns_empty_list_for_empty_file(self, tmp_path):
|
||||
from tools.data_adapters.balabit import parse_session_csv
|
||||
csv = tmp_path / "session_4"
|
||||
csv.write_text("record timestamp,client timestamp,button,state,x,y\n", encoding="utf-8")
|
||||
events = parse_session_csv(csv)
|
||||
assert events == []
|
||||
|
||||
|
||||
class TestSegmentByClicks:
|
||||
def _make_event(self, t_ms: int, state: str, x: int, y: int, button: str = "NoButton"):
|
||||
from tools.data_adapters.balabit import MouseEvent
|
||||
return MouseEvent(t_ms=t_ms, button=button, state=state, x=x, y=y)
|
||||
|
||||
def test_one_click_one_segment(self):
|
||||
from tools.data_adapters.balabit import segment_by_clicks
|
||||
events = [
|
||||
self._make_event(0, "Move", 10, 20),
|
||||
self._make_event(100, "Move", 50, 60),
|
||||
self._make_event(500, "Move", 100, 100),
|
||||
self._make_event(600, "Pressed", 110, 110, button="Left"),
|
||||
]
|
||||
segments = segment_by_clicks(events, window_ms=1200, session_id="test_s1")
|
||||
assert len(segments) == 1
|
||||
seg = segments[0]
|
||||
assert seg.click_x == 110
|
||||
assert seg.click_y == 110
|
||||
assert seg.click_t_ms == 600
|
||||
assert len(seg.events) == 3
|
||||
assert seg.session_id == "test_s1"
|
||||
|
||||
def test_window_excludes_old_events(self):
|
||||
"""Move events earlier than (click_t - window_ms) are dropped."""
|
||||
from tools.data_adapters.balabit import segment_by_clicks
|
||||
events = [
|
||||
self._make_event(0, "Move", 10, 20), # too old
|
||||
self._make_event(100, "Move", 20, 30), # too old
|
||||
self._make_event(900, "Move", 30, 40), # in window
|
||||
self._make_event(1000, "Pressed", 40, 50, button="Left"),
|
||||
]
|
||||
segments = segment_by_clicks(events, window_ms=200, session_id="s")
|
||||
assert len(segments) == 1
|
||||
assert len(segments[0].events) == 1
|
||||
assert segments[0].events[0].t_ms == 900
|
||||
|
||||
def test_multiple_clicks_multiple_segments(self):
|
||||
from tools.data_adapters.balabit import segment_by_clicks
|
||||
events = [
|
||||
self._make_event(0, "Move", 10, 20),
|
||||
self._make_event(100, "Pressed", 50, 50, button="Left"),
|
||||
self._make_event(200, "Released", 50, 50, button="Left"),
|
||||
self._make_event(300, "Move", 60, 60),
|
||||
self._make_event(400, "Move", 70, 70),
|
||||
self._make_event(500, "Pressed", 80, 80, button="Left"),
|
||||
]
|
||||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||||
assert len(segments) == 2
|
||||
assert segments[0].click_x == 50
|
||||
assert segments[1].click_x == 80
|
||||
# Second segment's events must not include the first Pressed
|
||||
for e in segments[1].events:
|
||||
assert e.state == "Move"
|
||||
|
||||
def test_skips_pressed_with_non_left_button(self):
|
||||
"""Right-clicks and wheel-clicks don't anchor segments (only Left)."""
|
||||
from tools.data_adapters.balabit import segment_by_clicks
|
||||
events = [
|
||||
self._make_event(0, "Move", 10, 20),
|
||||
self._make_event(100, "Pressed", 50, 50, button="Right"), # ignored
|
||||
self._make_event(200, "Move", 60, 60),
|
||||
self._make_event(300, "Pressed", 70, 70, button="Left"), # anchor
|
||||
]
|
||||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||||
assert len(segments) == 1
|
||||
assert segments[0].click_x == 70
|
||||
|
||||
def test_no_clicks_returns_empty(self):
|
||||
from tools.data_adapters.balabit import segment_by_clicks
|
||||
events = [
|
||||
self._make_event(0, "Move", 10, 20),
|
||||
self._make_event(100, "Move", 20, 30),
|
||||
]
|
||||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||||
assert segments == []
|
||||
|
||||
def test_excludes_drag_events(self):
|
||||
"""Drag events are not Move; segment should only include Move."""
|
||||
from tools.data_adapters.balabit import segment_by_clicks
|
||||
events = [
|
||||
self._make_event(0, "Move", 10, 20),
|
||||
self._make_event(100, "Drag", 30, 40), # not Move
|
||||
self._make_event(200, "Move", 50, 60),
|
||||
self._make_event(300, "Pressed", 70, 80, button="Left"),
|
||||
]
|
||||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||||
assert len(segments) == 1
|
||||
# Drag event should not appear in seg.events
|
||||
assert all(e.state == "Move" for e in segments[0].events)
|
||||
assert len(segments[0].events) == 2
|
||||
|
||||
|
||||
class TestFilterSegments:
|
||||
def _seg(self, events_data: list[tuple[int, int, int]], click=(500, 500), session="s"):
|
||||
"""events_data: list of (t_ms, x, y) tuples."""
|
||||
from tools.data_adapters.balabit import MouseEvent, Segment
|
||||
events = [MouseEvent(t_ms=t, button="NoButton", state="Move", x=x, y=y)
|
||||
for (t, x, y) in events_data]
|
||||
click_t = events[-1].t_ms + 50 if events else 100
|
||||
return Segment(events=events, click_x=click[0], click_y=click[1],
|
||||
click_t_ms=click_t, session_id=session)
|
||||
|
||||
def test_drops_segment_with_too_few_events(self):
|
||||
from tools.data_adapters.balabit import filter_segments
|
||||
seg = self._seg([(0, 100, 100), (50, 105, 105)]) # 2 events, min=5
|
||||
result = filter_segments([seg], min_events=5, min_dist=10,
|
||||
max_span_ms=5000, max_gap_ms=200)
|
||||
assert result == []
|
||||
|
||||
def test_drops_segment_with_short_distance(self):
|
||||
from tools.data_adapters.balabit import filter_segments
|
||||
# Start (100,100), end click=(105,100) → dist=5, min_dist=50
|
||||
events = [(i*10, 100+i, 100) for i in range(10)]
|
||||
seg = self._seg(events, click=(105, 100))
|
||||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||
max_span_ms=5000, max_gap_ms=200)
|
||||
assert result == []
|
||||
|
||||
def test_drops_segment_with_too_long_span(self):
|
||||
from tools.data_adapters.balabit import filter_segments
|
||||
# Span 6000ms, max_span=5000
|
||||
events = [(0, 100, 100), (2000, 200, 200), (4000, 300, 300), (6000, 400, 400),
|
||||
(6010, 410, 400), (6020, 420, 400)]
|
||||
seg = self._seg(events, click=(500, 500))
|
||||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||
max_span_ms=5000, max_gap_ms=10000)
|
||||
assert result == []
|
||||
|
||||
def test_drops_segment_with_gap(self):
|
||||
from tools.data_adapters.balabit import filter_segments
|
||||
# Gap of 500ms between events 2 and 3, max_gap=200
|
||||
events = [(0, 100, 100), (50, 110, 110), (100, 120, 120),
|
||||
(600, 200, 200), (650, 210, 210), (700, 220, 220)]
|
||||
seg = self._seg(events, click=(300, 300))
|
||||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||
max_span_ms=5000, max_gap_ms=200)
|
||||
assert result == []
|
||||
|
||||
def test_drops_segment_with_out_of_range_coords(self):
|
||||
from tools.data_adapters.balabit import filter_segments
|
||||
# x=10000 out of range
|
||||
events = [(i*10, 10000, 100) for i in range(6)]
|
||||
seg = self._seg(events, click=(10100, 100))
|
||||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||
max_span_ms=5000, max_gap_ms=200)
|
||||
assert result == []
|
||||
|
||||
def test_drops_segment_with_short_arc_length(self):
|
||||
"""Total arc < 50 even though endpoints are far → high-frequency jitter only."""
|
||||
from tools.data_adapters.balabit import filter_segments
|
||||
# Tiny back-and-forth with click 100px away — unrealistic, drop it
|
||||
events = [(i*10, 100 + (i % 2), 100) for i in range(10)]
|
||||
seg = self._seg(events, click=(200, 100))
|
||||
# arc length = sum of |Δp| ≈ 9 (alternating ±1) which is < 50
|
||||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||
max_span_ms=5000, max_gap_ms=200)
|
||||
assert result == []
|
||||
|
||||
def test_keeps_valid_segment(self):
|
||||
from tools.data_adapters.balabit import filter_segments
|
||||
# Smooth 100→500 px straight line, 10 events, span 500ms
|
||||
events = [(i*50, 100 + i*40, 100) for i in range(10)]
|
||||
seg = self._seg(events, click=(500, 100))
|
||||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||
max_span_ms=5000, max_gap_ms=200)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestProcessSession:
|
||||
def test_writes_jsonl_in_expected_format(self, tmp_path):
|
||||
from tools.config import BalabitAdapterConfig
|
||||
from tools.data_adapters.balabit import process_session
|
||||
|
||||
# Construct a Balabit-format CSV with one valid segment
|
||||
csv_path = tmp_path / "user_session_42"
|
||||
rows = []
|
||||
# 10 Move events going from (100,100) to (500,200) over 500ms
|
||||
for i in range(10):
|
||||
t = i * 0.05
|
||||
x = 100 + i * 40
|
||||
y = 100 + i * 10
|
||||
rows.append(f"0,{t:.3f},NoButton,Move,{x},{y}")
|
||||
rows.append(f"0,0.550,Left,Pressed,510,210")
|
||||
_write_csv(csv_path, rows)
|
||||
|
||||
out = tmp_path / "out.jsonl"
|
||||
config = BalabitAdapterConfig(
|
||||
window_ms=1200, min_dist=50, min_events=5,
|
||||
max_span_ms=5000, max_gap_ms=200,
|
||||
)
|
||||
n = process_session(csv_path, out, config)
|
||||
assert n == 1
|
||||
assert out.exists()
|
||||
|
||||
line = out.read_text(encoding="utf-8").strip()
|
||||
record = json.loads(line)
|
||||
assert record["meta"]["start"] == [100, 100]
|
||||
assert record["meta"]["end"] == [510, 210]
|
||||
assert record["meta"]["dist"] > 0
|
||||
assert record["meta"]["source"] == "balabit"
|
||||
assert record["meta"]["session_id"] == "user_session_42"
|
||||
# Events: only move events, no down/up
|
||||
assert all(e["type"] == "move" for e in record["events"])
|
||||
# Timestamps relative (start at 0)
|
||||
assert record["events"][0]["t"] == 0
|
||||
|
||||
def test_returns_zero_for_session_with_no_valid_segments(self, tmp_path):
|
||||
from tools.config import BalabitAdapterConfig
|
||||
from tools.data_adapters.balabit import process_session
|
||||
|
||||
csv_path = tmp_path / "empty_session"
|
||||
_write_csv(csv_path, ["0,0.000,NoButton,Move,100,100"]) # no clicks
|
||||
|
||||
out = tmp_path / "out.jsonl"
|
||||
config = BalabitAdapterConfig()
|
||||
n = process_session(csv_path, out, config)
|
||||
assert n == 0
|
||||
# File may not exist or may be empty — both acceptable
|
||||
if out.exists():
|
||||
assert out.read_text() == ""
|
||||
|
||||
def test_appends_to_existing_jsonl(self, tmp_path):
|
||||
from tools.config import BalabitAdapterConfig
|
||||
from tools.data_adapters.balabit import process_session
|
||||
|
||||
out = tmp_path / "out.jsonl"
|
||||
out.write_text('{"meta":{"start":[0,0],"end":[1,1]},"events":[]}\n', encoding="utf-8")
|
||||
|
||||
csv_path = tmp_path / "session_x"
|
||||
rows = [f"0,{i*0.05:.3f},NoButton,Move,{100+i*40},100" for i in range(10)]
|
||||
rows.append("0,0.550,Left,Pressed,510,100")
|
||||
_write_csv(csv_path, rows)
|
||||
|
||||
config = BalabitAdapterConfig()
|
||||
process_session(csv_path, out, config)
|
||||
|
||||
lines = out.read_text(encoding="utf-8").strip().split("\n")
|
||||
assert len(lines) == 2 # original + appended
|
||||
Reference in New Issue
Block a user