diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..664844d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=crlf + +# Preserve CRLF bytes for files covered by the repository's line-ending review policy. +.gitattributes -text +.github/workflows/*.yml -text +pytest.ini -text +requirements-test.txt -text +tests/*.py -text diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..22bfdb6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + pytest: + name: pytest (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.13" + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + cache: pip + cache-dependency-path: requirements-test.txt + python-version: ${{ matrix.python-version }} + - name: Install test dependencies + run: python -m pip install --requirement requirements-test.txt + - name: Run tests + run: python -m pytest diff --git a/.gitignore b/.gitignore index ad6c95e..5dff63b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ desktop.ini .idea/ .env .vscode +.pytest_cache/ +.coverage +htmlcov/ # internal-only math assets (keep local, do not commit) src/assets_master_script/general_assets_internal.csv \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bc6db74 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = -ra +testpaths = tests diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..747e419 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +numpy +pandas +pytest +scipy diff --git a/tests/test_create_input.py b/tests/test_create_input.py new file mode 100644 index 0000000..f660a5a --- /dev/null +++ b/tests/test_create_input.py @@ -0,0 +1,130 @@ +from unittest.mock import Mock + +import pandas as pd +import pytest + +from src import create_input + + +ACR_COLUMNS = [ + "rating_clips", + "math", + "pair_a", + "pair_b", + "trapping_clips", + "trapping_ans", +] + + +@pytest.fixture(autouse=True) +def clear_condition_cache(): + """ + Clear the filename-to-condition cache around each test. + + :return: None. + """ + create_input.file_to_condition_map.clear() + yield + create_input.file_to_condition_map.clear() + + +def test_conv_filename_to_condition_returns_sorted_groups(): + """ + Verify that named condition groups are returned in sorted order. + + :return: None. + """ + pattern = r"(?P[^_]+)_(?P\d+)\.wav" + + result = create_input.conv_filename_to_condition("white_10.wav", pattern) + + assert list(result.items()) == [("level", "10"), ("noise", "white")] + + +def test_conv_filename_to_condition_marks_nonmatching_filename(): + """ + Verify that a filename that does not match is marked as unknown. + + :return: None. + """ + pattern = r"(?P[^_]+)_(?P\d+)\.wav" + + result = create_input.conv_filename_to_condition("unexpected.wav", pattern) + + assert result == {"Unknown": "NoCondition"} + + +def test_validate_inputs_accepts_minimal_acr_columns(): + """ + Verify that the required ACR input columns pass validation. + + :return: None. + """ + cfg = {"number_of_gold_clips_per_session": "0"} + data = pd.DataFrame(columns=ACR_COLUMNS) + + create_input.validate_inputs(cfg, data, "acr") + + +def test_validate_inputs_rejects_missing_required_column(): + """ + Verify that validation identifies a missing required ACR column. + + :return: None. + """ + cfg = {"number_of_gold_clips_per_session": "0"} + data = pd.DataFrame(columns=[column for column in ACR_COLUMNS if column != "pair_b"]) + + with pytest.raises(AssertionError, match="pair_b"): + create_input.validate_inputs(cfg, data, "acr") + + +def test_validate_inputs_requires_gold_columns_when_enabled(): + """ + Verify that enabling gold clips requires their input columns. + + :return: None. + """ + cfg = {"number_of_gold_clips_per_session": "1"} + data = pd.DataFrame(columns=ACR_COLUMNS) + + with pytest.raises(AssertionError, match="gold_clips"): + create_input.validate_inputs(cfg, data, "acr") + + +def test_create_input_for_mturk_dispatches_acr(monkeypatch): + """ + Verify that ACR-compatible methods use the ACR input generator. + + :param monkeypatch: Pytest fixture used to replace the input generator. + :return: None. + """ + cfg = object() + data = object() + output_path = "output.csv" + create_acr = Mock(return_value=3) + monkeypatch.setattr(create_input, "create_input_for_acr", create_acr) + + result = create_input.create_input_for_mturk(cfg, data, "p835", output_path) + + assert result == 3 + create_acr.assert_called_once_with(cfg, data, output_path, "p835") + + +def test_create_input_for_mturk_dispatches_comparison_method(monkeypatch): + """ + Verify that comparison methods use the DCR/CCR input generator. + + :param monkeypatch: Pytest fixture used to replace the input generator. + :return: None. + """ + cfg = object() + data = object() + output_path = "output.csv" + create_comparison = Mock(return_value=2) + monkeypatch.setattr(create_input, "create_input_for_dcrccr", create_comparison) + + result = create_input.create_input_for_mturk(cfg, data, "ccr", output_path) + + assert result == 2 + create_comparison.assert_called_once_with(cfg, data, output_path) diff --git a/tests/test_result_parser.py b/tests/test_result_parser.py new file mode 100644 index 0000000..2c84edc --- /dev/null +++ b/tests/test_result_parser.py @@ -0,0 +1,36 @@ +from src.result_parser import outliers_modified_z_score, outliers_z_score + + +def test_outliers_modified_z_score_removes_clear_outlier(): + """ + Verify that the modified z-score filter removes a clear outlier. + + :return: None. + """ + votes = [1, 2, 2, 2, 3, 100] + + assert outliers_modified_z_score(votes) == [1, 2, 2, 2, 3] + + +def test_outliers_z_score_preserves_constant_votes(): + """ + Verify that the z-score filter preserves votes with no variance. + + :return: None. + """ + votes = [3, 3, 3] + + assert outliers_z_score(votes) == votes + + +def test_outliers_z_score_uses_threshold(): + """ + Verify that the z-score cutoff distinguishes values around the threshold. + + :return: None. + """ + below_threshold = [10] * 10 + [1000] + above_threshold = [10] * 11 + [1000] + + assert outliers_z_score(below_threshold) == below_threshold + assert outliers_z_score(above_threshold) == [10] * 11