From 95119e3da16157a649efbd14c6451e683c4c96d0 Mon Sep 17 00:00:00 2001 From: orla-ske Date: Sun, 7 Jun 2026 11:34:18 +0200 Subject: [PATCH 1/3] add unit tests for comparator, utils, and gesture catalog --- tests/__init__.py | 0 tests/test_comparator.py | 135 +++++++++++++++++++++++++++++++++++++++ tests/test_samples.py | 82 ++++++++++++++++++++++++ tests/test_utils.py | 128 +++++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_comparator.py create mode 100644 tests/test_samples.py create mode 100644 tests/test_utils.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_comparator.py b/tests/test_comparator.py new file mode 100644 index 0000000..8566adb --- /dev/null +++ b/tests/test_comparator.py @@ -0,0 +1,135 @@ +import numpy as np +import pytest + +from vision.core.comparator import ( + CORRECT, + PARTIAL, + CompareResult, + _band, + compare_gesture, +) +from vision.gestures.samples import GESTURES + + +# ── _band ───────────────────────────────────────────────────────────────────── + +def test_band_at_correct_threshold_is_correct(): + assert _band(CORRECT) == "correct" + + +def test_band_above_correct_is_correct(): + assert _band(CORRECT + 0.01) == "correct" + + +def test_band_between_partial_and_correct_is_partial(): + midpoint = (PARTIAL + CORRECT) / 2 + assert _band(midpoint) == "partial" + + +def test_band_at_partial_threshold_is_partial(): + assert _band(PARTIAL) == "partial" + + +def test_band_just_below_partial_is_incorrect(): + assert _band(PARTIAL - 0.001) == "incorrect" + + +def test_band_zero_is_incorrect(): + assert _band(0.0) == "incorrect" + + +# ── compare_gesture ─────────────────────────────────────────────────────────── + +def test_compare_identical_inputs_high_accuracy(): + ref = GESTURES["hello"] + result = compare_gesture(ref.tolist(), ref) + assert result.accuracy > 0.9 + + +def test_compare_identical_band_is_correct(): + ref = GESTURES["hello"] + result = compare_gesture(ref.tolist(), ref) + assert result.band == "correct" + + +def test_compare_identical_no_incorrect_points(): + ref = GESTURES["hello"] + result = compare_gesture(ref.tolist(), ref) + assert result.incorrect_points == [] + + +def test_compare_returns_compare_result(): + ref = GESTURES["hello"] + result = compare_gesture(ref.tolist(), ref) + assert isinstance(result, CompareResult) + + +def test_compare_result_accuracy_in_range(): + ref = GESTURES["hello"] + result = compare_gesture(ref.tolist(), ref) + assert 0.0 <= result.accuracy <= 1.0 + + +def test_compare_wrong_reference_shape_raises(): + bad_ref = np.zeros((10, 3), dtype=np.float32) + with pytest.raises(ValueError, match="reference must be"): + compare_gesture(GESTURES["hello"].tolist(), bad_ref) + + +def test_compare_very_different_gesture_low_accuracy(): + ref = GESTURES["hello"] + user = GESTURES["sorry"] + result = compare_gesture(user.tolist(), ref, allow_mirror=False) + assert result.accuracy < 0.95 + + +def test_compare_different_gestures_have_incorrect_points(): + ref = GESTURES["hello"] + user = GESTURES["sorry"] + result = compare_gesture(user.tolist(), ref, allow_mirror=False) + assert len(result.incorrect_points) > 0 + + +def test_compare_to_dict_has_expected_keys(): + ref = GESTURES["hello"] + result = compare_gesture(ref.tolist(), ref) + d = result.to_dict() + assert set(d.keys()) == {"accuracy", "band", "incorrect_points"} + + +def test_compare_to_dict_accuracy_rounded(): + ref = GESTURES["hello"] + result = compare_gesture(ref.tolist(), ref) + d = result.to_dict() + assert isinstance(d["accuracy"], float) + + +def test_compare_mirrored_input_matches_with_mirror_enabled(): + ref = GESTURES["hello"] + flipped = ref.copy() + flipped[:, 0] *= -1.0 + result = compare_gesture(flipped.tolist(), ref, allow_mirror=True) + assert result.accuracy > 0.9 + + +def test_compare_mirrored_input_lower_without_mirror(): + ref = GESTURES["hello"] + flipped = ref.copy() + flipped[:, 0] *= -1.0 + with_mirror = compare_gesture(flipped.tolist(), ref, allow_mirror=True) + without_mirror = compare_gesture(flipped.tolist(), ref, allow_mirror=False) + assert with_mirror.accuracy >= without_mirror.accuracy + + +def test_compare_accepts_numpy_array_input(): + ref = GESTURES["hello"] + result = compare_gesture(ref, ref) + assert result.accuracy > 0.9 + + +def test_compare_incorrect_points_are_valid_landmark_indices(): + ref = GESTURES["hello"] + user = GESTURES["sorry"] + result = compare_gesture(user.tolist(), ref, allow_mirror=False) + for idx in result.incorrect_points: + assert 0 <= idx <= 20 diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 0000000..ebdd7d1 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,82 @@ +import numpy as np +import pytest + +from vision.core.utils import LANDMARK_COUNT +from vision.gestures.samples import GESTURES, get_reference + + +# ── get_reference ───────────────────────────────────────────────────────────── + +def test_get_reference_returns_numpy_array(): + ref = get_reference("hello") + assert isinstance(ref, np.ndarray) + + +def test_get_reference_correct_shape(): + ref = get_reference("hello") + assert ref.shape == (LANDMARK_COUNT, 3) + + +def test_get_reference_dtype_is_float32(): + ref = get_reference("hello") + assert ref.dtype == np.float32 + + +def test_get_reference_unknown_raises_key_error(): + with pytest.raises(KeyError, match="no reference gesture"): + get_reference("not-a-gesture") + + +def test_get_reference_key_error_contains_id(): + with pytest.raises(KeyError, match="unknown-id"): + get_reference("unknown-id") + + +# ── GESTURES catalog ────────────────────────────────────────────────────────── + +def test_all_gestures_have_correct_shape(): + for name, arr in GESTURES.items(): + assert arr.shape == (LANDMARK_COUNT, 3), f"wrong shape for '{name}'" + + +def test_all_gestures_dtype_is_float32(): + for name, arr in GESTURES.items(): + assert arr.dtype == np.float32, f"wrong dtype for '{name}'" + + +def test_catalog_is_nonempty(): + assert len(GESTURES) > 0 + + +def test_at_least_30_gestures_registered(): + assert len(GESTURES) >= 30 + + +def test_core_vocabulary_present(): + expected = [ + "hello", "thank_you", "please", "sorry", "water", "food", + "help", "stop", "yes", "no", "bathroom", "pain", "tired", + "sleep", "more", "finished", + ] + for gesture_id in expected: + assert gesture_id in GESTURES, f"core gesture missing: {gesture_id}" + + +def test_all_26_alphabet_letters_present(): + for letter in "abcdefghijklmnopqrstuvwxyz": + key = f"letter_{letter}" + assert key in GESTURES, f"missing alphabet gesture: {key}" + + +def test_gestures_wrist_at_origin(): + for name, arr in GESTURES.items(): + wrist = arr[0] + np.testing.assert_allclose( + wrist, [0.0, 0.0, 0.0], atol=1e-5, + err_msg=f"wrist of '{name}' is not at origin", + ) + + +def test_get_reference_returns_same_object_as_gestures(): + ref = get_reference("hello") + assert ref is GESTURES["hello"] diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3c76bd2 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,128 @@ +import numpy as np +import pytest + +from vision.core.utils import ( + LANDMARK_COUNT, + _as_array, + mirror_horizontal, + normalize_landmarks, + to_vector, +) + + +def make_raw_landmarks(scale=1.0, offset=(0.0, 0.0, 0.0)): + pts = [] + for i in range(LANDMARK_COUNT): + pts.append([ + offset[0] + scale * (i * 0.05), + offset[1] + scale * (-(i * 0.1)), + offset[2] + scale * (i * 0.01), + ]) + return pts + + +# ── _as_array ───────────────────────────────────────────────────────────────── + +def test_as_array_wrong_coord_count_raises(): + with pytest.raises(ValueError): + _as_array([[0.1, 0.2]] * LANDMARK_COUNT) + + +def test_as_array_wrong_point_count_raises(): + with pytest.raises(ValueError): + _as_array([[0.1, 0.2, 0.3]] * (LANDMARK_COUNT - 1)) + + +def test_as_array_accepts_list_of_21(): + arr = _as_array([[0.1, 0.2, 0.3]] * LANDMARK_COUNT) + assert arr.shape == (LANDMARK_COUNT, 3) + + +def test_as_array_dtype_is_float32(): + arr = _as_array([[0.1, 0.2, 0.3]] * LANDMARK_COUNT) + assert arr.dtype == np.float32 + + +# ── normalize_landmarks ─────────────────────────────────────────────────────── + +def test_normalize_wrist_lands_at_origin(): + pts = make_raw_landmarks(offset=(5.0, 3.0, 1.0)) + result = normalize_landmarks(pts) + np.testing.assert_allclose(result[0], [0.0, 0.0, 0.0], atol=1e-5) + + +def test_normalize_output_shape(): + result = normalize_landmarks(make_raw_landmarks()) + assert result.shape == (LANDMARK_COUNT, 3) + + +def test_normalize_accepts_numpy_array(): + arr = np.array(make_raw_landmarks(), dtype=np.float32) + result = normalize_landmarks(arr) + assert result.shape == (LANDMARK_COUNT, 3) + + +def test_normalize_wrong_shape_raises(): + with pytest.raises(ValueError): + normalize_landmarks([[0.1, 0.2, 0.3]] * 10) + + +def test_normalize_translation_invariant(): + pts_a = make_raw_landmarks(offset=(0.0, 0.0, 0.0)) + pts_b = make_raw_landmarks(offset=(100.0, 50.0, 25.0)) + na = normalize_landmarks(pts_a) + nb = normalize_landmarks(pts_b) + np.testing.assert_allclose(na, nb, atol=1e-4) + + +# ── to_vector ───────────────────────────────────────────────────────────────── + +def test_to_vector_output_shape(): + pts = normalize_landmarks(make_raw_landmarks()) + v = to_vector(pts) + assert v.shape == (63,) + + +def test_to_vector_dtype_is_float32(): + pts = normalize_landmarks(make_raw_landmarks()) + v = to_vector(pts) + assert v.dtype == np.float32 + + +def test_to_vector_is_flattened(): + pts = normalize_landmarks(make_raw_landmarks()) + v = to_vector(pts) + assert v.ndim == 1 + + +# ── mirror_horizontal ───────────────────────────────────────────────────────── + +def test_mirror_flips_x_coordinates(): + pts = np.array(make_raw_landmarks(), dtype=np.float32) + mirrored = mirror_horizontal(pts) + np.testing.assert_allclose(mirrored[:, 0], -pts[:, 0], atol=1e-6) + + +def test_mirror_preserves_y_coordinates(): + pts = np.array(make_raw_landmarks(), dtype=np.float32) + mirrored = mirror_horizontal(pts) + np.testing.assert_allclose(mirrored[:, 1], pts[:, 1], atol=1e-6) + + +def test_mirror_preserves_z_coordinates(): + pts = np.array(make_raw_landmarks(), dtype=np.float32) + mirrored = mirror_horizontal(pts) + np.testing.assert_allclose(mirrored[:, 2], pts[:, 2], atol=1e-6) + + +def test_mirror_does_not_mutate_original(): + pts = np.array(make_raw_landmarks(), dtype=np.float32) + original_x = pts[:, 0].copy() + mirror_horizontal(pts) + np.testing.assert_allclose(pts[:, 0], original_x) + + +def test_mirror_twice_returns_original(): + pts = np.array(make_raw_landmarks(), dtype=np.float32) + double_mirrored = mirror_horizontal(mirror_horizontal(pts)) + np.testing.assert_allclose(double_mirrored, pts, atol=1e-6) From bc90147459a28c357a00c88163a8e21786b171ac Mon Sep 17 00:00:00 2001 From: orla-ske Date: Sun, 7 Jun 2026 12:22:04 +0200 Subject: [PATCH 2/3] add CodeQL clean code analysis workflow --- .github/workflows/codeql-analysis.yml | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..ad7ed4e --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,37 @@ +name: CodeQL Clean Code Analysis + +on: + push: + branches: [main, "feature/**", "new-feature/**"] + pull_request: + branches: [main] + +jobs: + codeql: + name: CodeQL Analysis + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + config: | + queries: + - uses: HighFive-SWE/codeQL/.codeql/queries/too-many-parameters.ql@main + - uses: HighFive-SWE/codeQL/.codeql/queries/long-function.ql@main + - uses: HighFive-SWE/codeQL/.codeql/queries/missing-public-docstring.ql@main + - uses: HighFive-SWE/codeQL/.codeql/queries/magic-numbers.ql@main + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: clean-code From d2f19940b7dfc2d1eb615e0d5b1b2a435c6d006c Mon Sep 17 00:00:00 2001 From: orla-ske Date: Sun, 7 Jun 2026 12:26:25 +0200 Subject: [PATCH 3/3] fix: checkout codeQL queries repo before referencing as local path --- .github/workflows/codeql-analysis.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ad7ed4e..6ab927d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,16 +17,23 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Checkout CodeQL queries + uses: actions/checkout@v4 + with: + repository: HighFive-SWE/codeQL + path: codeql-queries + token: ${{ secrets.GITHUB_TOKEN }} + - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: python config: | queries: - - uses: HighFive-SWE/codeQL/.codeql/queries/too-many-parameters.ql@main - - uses: HighFive-SWE/codeQL/.codeql/queries/long-function.ql@main - - uses: HighFive-SWE/codeQL/.codeql/queries/missing-public-docstring.ql@main - - uses: HighFive-SWE/codeQL/.codeql/queries/magic-numbers.ql@main + - uses: ./codeql-queries/.codeql/queries/too-many-parameters.ql + - uses: ./codeql-queries/.codeql/queries/long-function.ql + - uses: ./codeql-queries/.codeql/queries/missing-public-docstring.ql + - uses: ./codeql-queries/.codeql/queries/magic-numbers.ql - name: Autobuild uses: github/codeql-action/autobuild@v3