From 2872c08d48f667e74a1130cc2b7b55b24a694acf Mon Sep 17 00:00:00 2001 From: Guillermo Dylan Carvajal Aza Date: Mon, 2 Mar 2026 16:45:19 +0100 Subject: [PATCH 1/2] Update Testing Workflow --- .github/workflows/test_on_pr.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test_on_pr.yml b/.github/workflows/test_on_pr.yml index df15a85..56ec9b5 100644 --- a/.github/workflows/test_on_pr.yml +++ b/.github/workflows/test_on_pr.yml @@ -1,6 +1,13 @@ name: Test on Pull Request on: pull_request: + paths: + - 'src/**' + - 'test/**' + - 'requirements.txt' + - 'pyproject.toml' + - 'setup.py' + - '.github/workflows/test_on_pr.yml' jobs: test: runs-on: ubuntu-latest From e938da7a6017c8fce64f7756273c70b4b15fb0d8 Mon Sep 17 00:00:00 2001 From: Guillermo Dylan Carvajal Aza Date: Thu, 12 Mar 2026 13:43:10 +0100 Subject: [PATCH 2/2] fix: Constants, Validation, Metrics & Tests Fixed some metrics that had bus when working with DF instead of traces --- src/pywib/constants.py | 9 +++++ src/pywib/core/mouse.py | 16 ++++----- src/pywib/core/movement.py | 44 +++++++++++++++-------- src/pywib/utils/validation.py | 11 ++++++ test/test_mouse.py | 8 ++--- test/test_movement.py | 68 +++++++++++++++++++++++++++++++---- test/utils.py | 6 ++++ 7 files changed, 129 insertions(+), 33 deletions(-) diff --git a/src/pywib/constants.py b/src/pywib/constants.py index 2b7a94d..a451717 100644 --- a/src/pywib/constants.py +++ b/src/pywib/constants.py @@ -58,6 +58,7 @@ class ComponentTypes: class ColumnNames: """ Standard column names for DataFrame operations.""" + # Columns present on input DataFrame SESSION_ID = 'sessionId' SCENE_ID = 'sceneId' EVENT_TYPE = 'eventType' @@ -68,6 +69,7 @@ class ColumnNames: KEY_VALUE_EVENT = 'keyValueEvent' KEY_CODE_EVENT = 'keyCodeEvent' SOURCE_SESSION_ID = 'sourceSessionId' + # From here downwards we have the columns generated by methods DT = 'dt' DX = 'dx' DY = 'dy' @@ -77,6 +79,13 @@ class ColumnNames: AUC_RATIO = 'auc_ratio' NUMBER_OF_PAUSES = 'num_pauses' MEAN_PAUSE_PER_TRACE = 'mean_pauses_per_trace' + CLICK_SLIPS = 'click_slips' + MAX_CLICK_SLIP = 'max_click_slip' + MIN_CLICK_SLIP = 'min_click_slip' + MEAN_CLICK_SLIP = 'mean_click_slip' + MAX_CLICK_DURATION = 'max_click_duration' + MIN_CLICK_DURATION = 'min_click_duration' + MEAN_CLICK_DURATION = 'mean_click_duration' class KeyCodeEvents: """ Key code event constants for keyboard interactions.""" diff --git a/src/pywib/core/mouse.py b/src/pywib/core/mouse.py index 1f505e2..9827a7c 100644 --- a/src/pywib/core/mouse.py +++ b/src/pywib/core/mouse.py @@ -91,19 +91,19 @@ def click_slip(df: pd.DataFrame, threshold: float = 5.0) -> dict: last_move_y = None mouse_down_time = None accumulated_move_distance = 0.0 + # TODO this variable is never used?? click_slips_per_session[session_id] = { "slips": slips, "distances": distances, } metrics = { - "click_slips": slips, - "longest_click_slip": max(distances) if distances else 0, - "shortest_click_slip": min(distances) if distances else 0, - "average_click_slip": slips / len(distances) if distances else 0, - "average_click_slip_distance": np.mean(distances) if distances else 0, - "average_click_duration": np.mean(durations) if durations else 0, - "max_click_duration": max(durations) if durations else 0, - "min_click_duration": min(durations) if durations else 0, + ColumnNames.CLICK_SLIPS: slips, + ColumnNames.MAX_CLICK_SLIP: max(distances) if distances else 0, + ColumnNames.MIN_CLICK_SLIP: min(distances) if distances else 0, + ColumnNames.MEAN_CLICK_SLIP: np.mean(distances) if distances else 0, + ColumnNames.MEAN_CLICK_DURATION: np.mean(durations) if durations else 0, + ColumnNames.MAX_CLICK_DURATION: max(durations) if durations else 0, + ColumnNames.MIN_CLICK_DURATION: min(durations) if durations else 0, } metrics_per_session[session_id] = metrics return metrics_per_session \ No newline at end of file diff --git a/src/pywib/core/movement.py b/src/pywib/core/movement.py index fd8908b..2f90c31 100644 --- a/src/pywib/core/movement.py +++ b/src/pywib/core/movement.py @@ -7,6 +7,7 @@ compute_metrics_from_traces, extract_traces_by_session, auc_ratio_traces, auc_ratio_df) from pywib.constants import ColumnNames +from pywib.utils.validation import validate_not_none, validate_one_not_none def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = False) -> dict[str, list[pd.DataFrame]]: """ @@ -22,8 +23,8 @@ def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = No Returns: dict[str, list[pd.DataFrame]]: Dictionary of traces with computed 'velocity' column. """ - if df is None and traces is None: - raise ValueError("Either 'df' or 'traces' must be provided.") + + validate_one_not_none(df, traces) if not per_traces: # Compute directly on the DataFrame (no trace extraction) @@ -50,13 +51,21 @@ def velocity_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] = N Returns: dict: A dictionary with keys as (sessionId) and values as dictionaries with 'mean ', 'max', and 'min' velocity. """ + validate_one_not_none(df, traces) + if (traces is None): + if df is not None or (ColumnNames.VELOCITY not in df.columns): + validate_dataframe(df) + traces = velocity(df, per_traces=True) + else: + raise ValueError("Either 'df' or 'traces' must be provided.") + return compute_metrics_from_traces( df=df, traces=traces, column_name=ColumnNames.VELOCITY, - compute_traces_fn=velocity, - preprocess_fn=lambda s: s[s > 0] # Exclude zero velocities + compute_traces_fn=lambda _: traces, # Already computed above + preprocess_fn=lambda s: s[s != 0] # Exclude zero accelerations ) def acceleration(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = False) -> dict[str, list[pd.DataFrame]]: @@ -65,8 +74,7 @@ def acceleration(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] If `traces` is None, they will be computed from the DataFrame. """ - if df is None and traces is None: - raise ValueError("Either 'df' or 'traces' must be provided.") + validate_one_not_none(df, traces) if not per_traces: # Compute directly on the DataFrame (no trace extraction) @@ -91,11 +99,14 @@ def acceleration_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] Returns: dict: A dictionary with keys as (sessionId) and values as dictionaries with 'mean', 'max', and 'min' acceleration. """ - if (ColumnNames.ACCELERATION not in df.columns) and (traces is None): + + validate_one_not_none(df, traces) + + if (ColumnNames.ACCELERATION not in df.columns) or (traces is None): validate_dataframe(df) if ColumnNames.VELOCITY not in df.columns: - traces = velocity(df, per_traces=True) - traces = acceleration(None, traces, per_traces=True) + traces = velocity(df, traces, per_traces=True) + traces = acceleration(df, traces, per_traces=True) return compute_metrics_from_traces( df=df, @@ -123,8 +134,8 @@ def jerkiness(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = N dict[str, list[pd.DataFrame]] Dictionary of traces, each containing the computed 'jerkiness' column. """ - if df is None and traces is None: - raise ValueError("Either 'df' or 'traces' must be provided.") + + validate_one_not_none(df, traces) if not per_traces: # Compute directly on the DataFrame (no trace extraction) @@ -149,13 +160,16 @@ def jerkiness_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] = Returns: dict: A dictionary with keys as (sessionId) and values as dictionaries with 'mean', 'max', and 'min' jerkiness. """ - if((ColumnNames.JERKINESS not in df.columns) and (traces is None)): + + validate_one_not_none(df, traces) + + if((ColumnNames.JERKINESS not in df.columns) or (traces is None)): validate_dataframe(df) if(ColumnNames.ACCELERATION not in df.columns): if(ColumnNames.VELOCITY not in df.columns): traces = velocity(df, per_traces=True) - traces = acceleration(None, traces, per_traces=True) - traces = jerkiness(None, traces, per_traces=True) + traces = acceleration(df, traces, per_traces=True) + traces = jerkiness(df, traces, per_traces=True) return compute_metrics_from_traces( df=df, @@ -177,6 +191,8 @@ def path(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None) Returns: pd.DataFrame: DataFrame with an additional 'distance' column representing the path length. """ + + validate_one_not_none(df, traces) if traces is None: validate_dataframe(df) diff --git a/src/pywib/utils/validation.py b/src/pywib/utils/validation.py index e732e8e..03b7f6f 100644 --- a/src/pywib/utils/validation.py +++ b/src/pywib/utils/validation.py @@ -9,6 +9,17 @@ ColumnNames.KEY_VALUE_EVENT, ColumnNames.KEY_CODE_EVENT ] +def validate_not_none(*params): + for param in params: + if param is None: + raise ValueError("Either 'df' or 'traces' must be provided.") + +def validate_one_not_none(*params): + for param in params: + if param is not None: + return + raise ValueError("Either 'df' or 'traces' must be provided.") + def validate_dataframe(df: pd.DataFrame): for col in required_columns: diff --git a/test/test_mouse.py b/test/test_mouse.py index 6931fe8..f7b04e4 100644 --- a/test/test_mouse.py +++ b/test/test_mouse.py @@ -19,8 +19,8 @@ class TestData: 'SESSION_B': 2 } expected_click_slips = { - 'SESSION_A': {'click_slips': 0,'longest_click_slip':0, 'shortest_click_slip': 0, 'average_click_slip': 0, 'average_click_slip_distance': 0, 'average_click_duration': np.float64(30.0), 'max_click_duration': np.float64(30.0), 'min_click_duration': np.float64(30.0)}, - 'SESSION_B': {'click_slips': 1, 'longest_click_slip': np.float64(25.0), 'shortest_click_slip': np.float64(25.0), 'average_click_slip': 1.0, 'average_click_slip_distance': np.float64(25.0), 'average_click_duration': np.float64(30.0), 'max_click_duration': np.float64(30.0), 'min_click_duration': np.float64(30.0)} + 'SESSION_A': {'click_slips': 0,'max_click_slip':0, 'min_click_slip': 0, 'mean_click_slip': 0, 'mean_click_duration': np.float64(30.0), 'max_click_duration': 30, 'min_click_duration': 30}, + 'SESSION_B': {'click_slips': 1, 'max_click_slip': np.float64(25.0), 'min_click_slip': np.float64(25.0), 'mean_click_slip': np.float64(25.0), 'mean_click_duration': np.float64(30.0), 'max_click_duration': np.float64(30.0), 'min_click_duration': 30} } class TestMouse(unittest.TestCase): @@ -46,8 +46,8 @@ def test_click_duration(self): durations = click_slip(self.test_data) self.assertIn('SESSION_A', durations) self.assertIn('SESSION_B', durations) - self.assertGreaterEqual(durations['SESSION_A']['average_click_duration'], 30) - self.assertGreaterEqual(durations['SESSION_B']['average_click_duration'], 30) + self.assertGreaterEqual(durations['SESSION_A']['mean_click_duration'], 30) + self.assertGreaterEqual(durations['SESSION_B']['mean_click_duration'], 30) self.assertGreaterEqual(durations['SESSION_A']['max_click_duration'], durations['SESSION_B']['min_click_duration']) self.assertGreaterEqual(durations['SESSION_B']['max_click_duration'], durations['SESSION_B']['min_click_duration']) diff --git a/test/test_movement.py b/test/test_movement.py index dda72e5..06c6a55 100644 --- a/test/test_movement.py +++ b/test/test_movement.py @@ -3,7 +3,7 @@ import sys import os sys.path.insert(0, os.path.dirname(__file__)) -from utils import process_csv, import_pyModule +from utils import assert_between_zero_inf, process_csv, import_pyModule import_pyModule() from pywib import (velocity, acceleration, compute_space_time_diff, @@ -54,12 +54,37 @@ def test_velocity_metrics(self): metrics = velocity_metrics(self.test_data.copy()) for _, session_id in metrics.items(): - self.assertIn('mean', session_id) - self.assertIn('max', session_id) - self.assertIn('min', session_id) - self.assertGreaterEqual(session_id['mean'], 0) - self.assertGreaterEqual(session_id['max'], 0) - self.assertGreaterEqual(session_id['min'], 0) + assert_between_zero_inf(self, session_id, 'mean') + assert_between_zero_inf(self, session_id, 'max') + assert_between_zero_inf(self, session_id, 'min') + + def test_velocity_metrics_from_velocity_df(self): + """Test velocity metrics from a previosly computed velocity dataframe""" + # Compute space-time differences + df = compute_space_time_diff(self.test_data.copy()) + + # Calculate velocity + df_velocity = velocity(df) + metrics = velocity_metrics(df_velocity) + + for _, session_id in metrics.items(): + assert_between_zero_inf(self, session_id, 'mean') + assert_between_zero_inf(self, session_id, 'max') + assert_between_zero_inf(self, session_id, 'min') + + def test_velocity_metrics_from_velocity_traces(self): + """Test velocity metrics from a previosly computed velocity dataframe""" + # Compute space-time differences + df = compute_space_time_diff(self.test_data.copy()) + + # Calculate velocity + df_velocity = velocity(df, per_traces=True) + metrics = velocity_metrics(None, traces=df_velocity) + + for _, session_id in metrics.items(): + assert_between_zero_inf(self, session_id, 'mean') + assert_between_zero_inf(self, session_id, 'max') + assert_between_zero_inf(self, session_id, 'min') def test_acceleration(self): """Test acceleration calculation""" @@ -103,6 +128,21 @@ def test_acceleration_metrics(self): self.assertGreaterEqual(session['mean'], 0) self.assertGreaterEqual(session['max'], session['min']) + def test_acceleration_metrics_from_aceleration_df(self): + """Test acceleration metrics from precomputed acceleration df""" + df = compute_space_time_diff(self.test_data.copy()) + + # Calculate acceleration + df_acceleration = acceleration(df) + acc_metrics = acceleration_metrics(df_acceleration) + + for _, session in acc_metrics.items(): + self.assertIn('mean', session) + self.assertIn('max', session) + self.assertIn('min', session) + self.assertGreaterEqual(session['mean'], 0) + self.assertGreaterEqual(session['max'], session['min']) + def test_jerkiness(self): """Test jerkiness calculation""" jk_df = jerkiness(self.test_data.copy(), per_traces=True) @@ -127,6 +167,17 @@ def test_jerkiness_metrics(self): self.assertGreaterEqual(session['mean'], 0) self.assertGreaterEqual(session['max'], session['min']) + def test_jerkiness_from_df(self): + jk_df = jerkiness(self.test_data.copy()) + metrics = jerkiness_metrics(jk_df) + + for _, session in metrics.items(): + self.assertIn('mean', session) + self.assertIn('max', session) + self.assertIn('min', session) + self.assertGreaterEqual(session['mean'], 0) + self.assertGreaterEqual(session['max'], session['min']) + def test_auc(self): auc = auc_ratio(self.test_data_auc.copy()) for _, session in auc.items(): @@ -143,5 +194,8 @@ def test_auc_metrics(self): self.assertIn('max_ratio', session) self.assertGreaterEqual(session['mean_ratio'], 0) + + + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/test/utils.py b/test/utils.py index d5a31e9..1f9aea9 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,3 +1,4 @@ +import numpy as np import pandas as pd from collections import defaultdict @@ -62,3 +63,8 @@ def csv_to_df_no_checks(file_path): file_path (str): Path to the CSV file. """ return pd.read_csv(file_path, encoding='utf-8', sep=',') + +def assert_between_zero_inf(self, data, column): + self.assertIn(column, data) + self.assertGreaterEqual(data[column], 0) + self.assertLessEqual(data[column], np.inf) \ No newline at end of file