diff --git a/ai_mouse/data_adapters/balabit.py b/ai_mouse/data_adapters/balabit.py index 1febc2e..c1e41f0 100644 --- a/ai_mouse/data_adapters/balabit.py +++ b/ai_mouse/data_adapters/balabit.py @@ -46,8 +46,35 @@ class Segment: def parse_session_csv(path: Path) -> list[MouseEvent]: - """Stub — implemented in Task 3.""" - raise NotImplementedError + """Parse a Balabit session CSV file into MouseEvent objects. + + Malformed rows are logged and skipped (not raised). + Client timestamps (seconds, float) are converted to int milliseconds. + + Args: + path: Path to a Balabit session CSV file. + + Returns: + List of MouseEvent in original order. Empty list if file is empty. + """ + import csv as csv_module + + events: list[MouseEvent] = [] + with path.open("r", encoding="utf-8", newline="") as f: + reader = csv_module.DictReader(f) + for row_idx, row in enumerate(reader, 2): # 1-based, header is line 1 + try: + client_ts = float(row["client timestamp"]) + t_ms = int(round(client_ts * 1000)) + button = row["button"].strip() + state = row["state"].strip() + x = int(row["x"]) + y = int(row["y"]) + except (KeyError, ValueError, TypeError) as exc: + logger.debug("Skipping malformed row %d in %s: %s", row_idx, path.name, exc) + continue + events.append(MouseEvent(t_ms=t_ms, button=button, state=state, x=x, y=y)) + return events def segment_by_clicks( diff --git a/tests/test_balabit_adapter.py b/tests/test_balabit_adapter.py index c959769..2716740 100644 --- a/tests/test_balabit_adapter.py +++ b/tests/test_balabit_adapter.py @@ -34,3 +34,62 @@ def test_segment_dataclass(): 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 ai_mouse.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 ai_mouse.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 ai_mouse.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 ai_mouse.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 == []