diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5e10d7d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + # Installing the full package also acts as a clean-install smoke test + # for the dependency resolution fixed in #65. + - name: Install package with test extras + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + - name: Run tests + run: pytest -v tests/ + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install linters + run: pip install "black==24.*" "isort==5.*" + # Scoped to tests/ for now; widen to the package in a follow-up once the + # existing sources are confirmed clean against these versions. + # black and isort read their settings from pyproject.toml. + - name: black + run: black --check tests/ + - name: isort + run: isort --check-only tests/ diff --git a/pyproject.toml b/pyproject.toml index da728956..40828a3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ authors = [{name = "Meta Platforms, Inc."}] dependencies = [ "neuralset==0.0.2", "neuraltrain==0.0.2", + # neuralset 0.0.2 references exca.steps.base.NoValue, which exca removed in + # 0.5.26 (moved to exca.steps.identity). Cap exca until neuralset is updated. + "exca>=0.5.20,<0.5.26", "torch>=2.5.1,<2.7", "numpy==2.2.6", "torchvision>=0.20,<0.22", @@ -63,5 +66,8 @@ line-length = 88 [tool.isort] profile = "black" +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.setuptools.packages.find] include = ["tribe*"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..0952fcc3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. diff --git a/tests/test_demo_utils.py b/tests/test_demo_utils.py new file mode 100644 index 00000000..3a5e939e --- /dev/null +++ b/tests/test_demo_utils.py @@ -0,0 +1,36 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Unit tests for the ``demo_utils`` input-suffix contract.""" + +from tribev2.demo_utils import VALID_SUFFIXES + + +def test_expected_input_modalities(): + assert set(VALID_SUFFIXES) == {"text_path", "audio_path", "video_path"} + + +def test_every_modality_has_at_least_one_suffix(): + for name, suffixes in VALID_SUFFIXES.items(): + assert suffixes, f"{name} has no accepted suffixes" + + +def test_suffixes_are_lowercase_and_dot_prefixed(): + for suffixes in VALID_SUFFIXES.values(): + for suffix in suffixes: + assert suffix.startswith("."), f"{suffix!r} is missing a leading dot" + assert suffix == suffix.lower(), f"{suffix!r} is not lowercase" + + +def test_known_suffixes_are_accepted(): + assert ".txt" in VALID_SUFFIXES["text_path"] + assert ".wav" in VALID_SUFFIXES["audio_path"] + assert ".mp4" in VALID_SUFFIXES["video_path"] + + +def test_suffixes_do_not_overlap_across_modalities(): + all_suffixes = [s for suffixes in VALID_SUFFIXES.values() for s in suffixes] + assert len(all_suffixes) == len(set(all_suffixes)), "a suffix maps to >1 modality" diff --git a/tests/test_eventstransforms.py b/tests/test_eventstransforms.py new file mode 100644 index 00000000..c71052c9 --- /dev/null +++ b/tests/test_eventstransforms.py @@ -0,0 +1,28 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Unit tests for ``ExtractWordsFromAudio`` language validation. + +The language guard runs before any transcription subprocess, so these tests +need no audio file, GPU, or the ``uvx``/``whisperx`` toolchain. +""" + +from pathlib import Path + +import pytest + +from tribev2.eventstransforms import ExtractWordsFromAudio + + +def test_unsupported_language_raises_value_error(): + with pytest.raises(ValueError, match="not supported"): + ExtractWordsFromAudio._get_transcript_from_audio( + Path("nonexistent.wav"), "klingon" + ) + + +def test_default_language_is_english(): + assert ExtractWordsFromAudio().language == "english" diff --git a/tests/test_temporal_smoothing.py b/tests/test_temporal_smoothing.py new file mode 100644 index 00000000..e4768eba --- /dev/null +++ b/tests/test_temporal_smoothing.py @@ -0,0 +1,67 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Unit tests for ``TemporalSmoothing`` (CPU only, no data or weights needed).""" + +import torch +from torch import nn + +from tribev2.model import TemporalSmoothing + + +def test_build_returns_depthwise_conv1d(): + conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=4) + + assert isinstance(conv, nn.Conv1d) + # Depthwise: groups == channels, so the weight is (dim, 1, kernel_size). + assert conv.groups == 4 + assert conv.weight.shape == (4, 1, 9) + assert conv.bias is None + + +def test_gaussian_kernel_is_normalized_per_channel(): + dim, kernel_size = 4, 9 + conv = TemporalSmoothing(kernel_size=kernel_size, sigma=2.0).build(dim=dim) + + per_channel_sum = conv.weight.detach().sum(dim=-1).reshape(dim) + assert torch.allclose(per_channel_sum, torch.ones(dim), atol=1e-5) + + +def test_gaussian_kernel_is_symmetric(): + conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=1) + + kernel = conv.weight.detach()[0, 0] + assert torch.allclose(kernel, torch.flip(kernel, dims=[0]), atol=1e-6) + + +def test_output_length_is_preserved(): + conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=4) + + x = torch.randn(2, 4, 50) + y = conv(x) + assert y.shape == (2, 4, 50) + + +def test_constant_signal_is_unchanged_in_interior(): + # A normalized smoothing kernel must leave a constant signal unchanged, + # away from the zero-padded borders. + conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=3) + + x = torch.ones(1, 3, 50) + y = conv(x).detach() + # kernel_size=9, padding=4 means only indices 0-3 and 46-49 are + # contaminated by zero-padding; 10:40 is safely interior. + interior = y[:, :, 10:40] + assert torch.allclose(interior, torch.ones_like(interior), atol=1e-5) + + +def test_default_sigma_is_none(): + # With no sigma the conv keeps its randomly initialized, trainable weights + # (the Gaussian branch is skipped). + conv = TemporalSmoothing(kernel_size=5).build(dim=2) + + assert isinstance(conv, nn.Conv1d) + assert conv.weight.shape == (2, 1, 5)