From b1368b14e8503c156c9d860e91b4c2f70623a1bb Mon Sep 17 00:00:00 2001 From: miro Date: Tue, 10 Mar 2026 23:32:31 +0000 Subject: [PATCH 01/14] chore: update CI/CD workflows to gh-automations@dev standard - Replace inline build/publish/coverage jobs with reusable gh-automations workflows - Add missing workflows: build-tests, lint, license_check, pip_audit, release-preview, repo-health, python-support - Migrate from TigreGotico/gh-automations@master to OpenVoiceOS/gh-automations@dev - Replace codecov-based coverage.yml with coverage.yml@dev reusable workflow - Migrate downstream.yml from inline pipdeptree script to downstream-check.yml@dev - Remove deprecated build_tests.yml and unit_tests.yml (replaced by build-tests.yml) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-tests.yml | 15 ++++ .github/workflows/build_tests.yml | 50 -------------- .github/workflows/coverage.yml | 17 +++++ .github/workflows/downstream.yml | 48 ++----------- .github/workflows/license_check.yml | 11 +++ .github/workflows/lint.yml | 14 ++++ .github/workflows/pip_audit.yml | 11 +++ .github/workflows/publish_stable.yml | 49 ++----------- .github/workflows/python-support.yml | 18 +++++ .github/workflows/release-preview.yml | 14 ++++ .github/workflows/release_workflow.yml | 95 ++------------------------ .github/workflows/repo-health.yml | 13 ++++ .github/workflows/unit_tests.yml | 64 ----------------- 13 files changed, 128 insertions(+), 291 deletions(-) create mode 100644 .github/workflows/build-tests.yml delete mode 100644 .github/workflows/build_tests.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/license_check.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/pip_audit.yml create mode 100644 .github/workflows/python-support.yml create mode 100644 .github/workflows/release-preview.yml create mode 100644 .github/workflows/repo-health.yml delete mode 100644 .github/workflows/unit_tests.yml diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml new file mode 100644 index 00000000..87056c50 --- /dev/null +++ b/.github/workflows/build-tests.yml @@ -0,0 +1,15 @@ +name: Build Tests + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + build: + uses: OpenVoiceOS/gh-automations/.github/workflows/build-tests.yml@dev + secrets: inherit + with: + python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]' + install_extras: 'test' + test_path: 'test/unittests' diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml deleted file mode 100644 index d8688c4a..00000000 --- a/.github/workflows/build_tests.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Run Build Tests -on: - push: - branches: - - master - pull_request: - branches: - - dev - paths-ignore: - - 'ovos_utils/version.py' - - 'test/**' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'readme.md' - - 'scripts/**' - workflow_dispatch: - -jobs: - build_tests: - strategy: - max-parallel: 2 - matrix: - python-version: ["3.10", "3.11" ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev - - name: Build Source Packages - run: | - python setup.py sdist - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Install package - run: | - pip install .[all] diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..19bc9a38 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,17 @@ +name: Code Coverage + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + coverage: + uses: OpenVoiceOS/gh-automations/.github/workflows/coverage.yml@dev + secrets: inherit + with: + python_version: '3.11' + coverage_source: 'ovos_utils' + test_path: 'test/' + install_extras: '' + min_coverage: 0 diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index dffb1459..e1b4dd40 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -5,47 +5,11 @@ on: branches: [dev] schedule: - cron: "0 0 * * *" # Runs daily at midnight UTC - workflow_dispatch: # Allows manual triggering - -env: - TARGET_PACKAGE: "ovos-utils" # Set the package to track here + workflow_dispatch: jobs: - check-dependencies: - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - - name: Download requirements file - run: | - curl -o constraints-alpha.txt https://raw.githubusercontent.com/OpenVoiceOS/ovos-releases/refs/heads/main/constraints-alpha.txt - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev swig libssl-dev libfann-dev portaudio19-dev libpulse-dev python3-fann2 - python -m venv venv - source venv/bin/activate - pip install build wheel - pip install -r constraints-alpha.txt - pip install pipdeptree - - - name: Find downstream dependencies - run: | - source venv/bin/activate - pipdeptree -r -p "$TARGET_PACKAGE" > downstream_report.txt || echo "No dependencies found" - - - name: Commit and push changes - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git checkout dev || git checkout -b dev - git add downstream_report.txt - git commit -m "Update downstream dependencies for $TARGET_PACKAGE" || echo "No changes to commit" - git push origin dev + check_downstream: + uses: OpenVoiceOS/gh-automations/.github/workflows/downstream-check.yml@dev + secrets: inherit + with: + package_name: 'ovos-utils' diff --git a/.github/workflows/license_check.yml b/.github/workflows/license_check.yml new file mode 100644 index 00000000..8757eee1 --- /dev/null +++ b/.github/workflows/license_check.yml @@ -0,0 +1,11 @@ +name: License Check + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + license_check: + uses: OpenVoiceOS/gh-automations/.github/workflows/license-check.yml@dev + secrets: inherit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..9a6b7a53 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,14 @@ +name: Lint + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + lint: + uses: OpenVoiceOS/gh-automations/.github/workflows/lint.yml@dev + secrets: inherit + with: + ruff: true + pre_commit: false # set true if .pre-commit-config.yaml exists diff --git a/.github/workflows/pip_audit.yml b/.github/workflows/pip_audit.yml new file mode 100644 index 00000000..bb3ca4d3 --- /dev/null +++ b/.github/workflows/pip_audit.yml @@ -0,0 +1,11 @@ +name: PIP Audit + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + pip_audit: + uses: OpenVoiceOS/gh-automations/.github/workflows/pip-audit.yml@dev + secrets: inherit diff --git a/.github/workflows/publish_stable.yml b/.github/workflows/publish_stable.yml index db85957d..35c0e063 100644 --- a/.github/workflows/publish_stable.yml +++ b/.github/workflows/publish_stable.yml @@ -6,53 +6,12 @@ on: jobs: publish_stable: - uses: TigreGotico/gh-automations/.github/workflows/publish-stable.yml@master + if: github.actor != 'github-actions[bot]' + uses: OpenVoiceOS/gh-automations/.github/workflows/publish-stable.yml@dev secrets: inherit with: branch: 'master' version_file: 'ovos_utils/version.py' - setup_py: 'setup.py' + publish_pypi: true + sync_dev: true publish_release: true - - publish_pypi: - needs: publish_stable - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: master - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Build Distribution Packages - run: | - python setup.py sdist bdist_wheel - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} - - - sync_dev: - needs: publish_stable - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - ref: master - - name: Push master -> dev - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: dev diff --git a/.github/workflows/python-support.yml b/.github/workflows/python-support.yml new file mode 100644 index 00000000..8c2e1daa --- /dev/null +++ b/.github/workflows/python-support.yml @@ -0,0 +1,18 @@ +# NOTE: python-support.yml@dev is deprecated. This file calls build-tests.yml@dev instead. +# You can rename this file to build-tests.yml if you don't already have one. +name: Build Tests + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + build_tests: + uses: OpenVoiceOS/gh-automations/.github/workflows/build-tests.yml@dev + secrets: inherit + with: + package_name: 'ovos_utils' + version_file: 'ovos_utils/version.py' + python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]' + test_path: 'test/' diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 00000000..3574eb61 --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -0,0 +1,14 @@ +name: Release Preview + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + release_preview: + uses: OpenVoiceOS/gh-automations/.github/workflows/release-preview.yml@dev + secrets: inherit + with: + package_name: 'ovos_utils' + version_file: 'ovos_utils/version.py' diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index dbb357be..dd179a1b 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -8,101 +8,16 @@ on: jobs: publish_alpha: - uses: TigreGotico/gh-automations/.github/workflows/publish-alpha.yml@master + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + uses: OpenVoiceOS/gh-automations/.github/workflows/publish-alpha.yml@dev secrets: inherit with: branch: 'dev' version_file: 'ovos_utils/version.py' - setup_py: 'setup.py' update_changelog: true publish_prerelease: true + propose_release: true changelog_max_issues: 100 - - notify: - if: github.event.pull_request.merged == true - needs: publish_alpha - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Send message to Matrix bots channel - id: matrix-chat-message - uses: fadenb/matrix-chat-message@v0.0.6 - with: - homeserver: 'matrix.org' - token: ${{ secrets.MATRIX_TOKEN }} - channel: '!WjxEKjjINpyBRPFgxl:krbel.duckdns.org' - message: | - new ${{ github.event.repository.name }} PR merged! https://github.com/${{ github.repository }}/pull/${{ github.event.number }} - - publish_pypi: - needs: publish_alpha - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Build Distribution Packages - run: | - python setup.py sdist bdist_wheel - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} - - - propose_release: - needs: publish_alpha - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - name: Checkout dev branch - uses: actions/checkout@v6 - with: - ref: dev - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - name: Get version from setup.py - id: get_version - run: | - VERSION=$(python setup.py --version) - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Create and push new branch - run: | - git checkout -b release-${{ env.VERSION }} - git push origin release-${{ env.VERSION }} - - - name: Open Pull Request from dev to master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Variables - BRANCH_NAME="release-${{ env.VERSION }}" - BASE_BRANCH="master" - HEAD_BRANCH="release-${{ env.VERSION }}" - PR_TITLE="Release ${{ env.VERSION }}" - PR_BODY="Human review requested!" - - # Create a PR using GitHub API - curl -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token $GITHUB_TOKEN" \ - -d "{\"title\":\"$PR_TITLE\",\"body\":\"$PR_BODY\",\"head\":\"$HEAD_BRANCH\",\"base\":\"$BASE_BRANCH\"}" \ - https://api.github.com/repos/${{ github.repository }}/pulls + publish_pypi: true + notify_matrix: true diff --git a/.github/workflows/repo-health.yml b/.github/workflows/repo-health.yml new file mode 100644 index 00000000..d6797041 --- /dev/null +++ b/.github/workflows/repo-health.yml @@ -0,0 +1,13 @@ +name: Repo Health + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + repo_health: + uses: OpenVoiceOS/gh-automations/.github/workflows/repo-health.yml@dev + secrets: inherit + with: + version_file: 'ovos_utils/version.py' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml deleted file mode 100644 index 596524d1..00000000 --- a/.github/workflows/unit_tests.yml +++ /dev/null @@ -1,64 +0,0 @@ -# This workflow will run unit tests - -name: Run Unit Tests -on: - pull_request: - branches: - - dev - paths-ignore: - - 'ovos_utils/version.py' - - 'requirements/**' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'readme.md' - - 'scripts/**' - push: - branches: - - master - paths-ignore: - - 'ovos_utils/version.py' - - 'requirements/**' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'readme.md' - - 'scripts/**' - workflow_dispatch: - -jobs: - unit_tests: - strategy: - max-parallel: 2 - matrix: - python-version: ["3.10", "3.11", "3.12" ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Set up python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev - python -m pip install build wheel - - name: Install core repo - run: | - pip install -e .[extras] - - name: Install test dependencies - run: | - pip install pytest pytest-timeout pytest-cov - - name: Run unittests - run: | - pytest --cov=ovos_utils --cov-report xml test/unittests - # NOTE: additional pytest invocations should also add the --cov-append flag - # or they will overwrite previous invocations' coverage reports - # (for an example, see OVOS Skill Manager's workflow) \ No newline at end of file From 5d87c6f287aa9c40915ceb1e5cbcae05169a5635 Mon Sep 17 00:00:00 2001 From: miro Date: Tue, 10 Mar 2026 23:41:43 +0000 Subject: [PATCH 02/14] test: add 178 new tests, raise coverage from 25% to 29% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New test files: - test_init.py: json_dumps, json_loads, datestr2ts (deprecated) - test_text_utils.py: camel_case_split, collapse_whitespaces, rm_parentheses, remove_accents_and_punct - test_list_utils.py: rotate_list, flatten_list, deduplicate_list - test_decorators.py: classproperty, timed_lru_cache (with/without args, expiry, cache_clear) - test_ocp.py: all enums, MediaEntry (CRUD, update, as_dict, from_dict, infocard, mimetype), PluginStream, Playlist (add/remove/sort/replace/navigate) - test_fakebus.py: FakeMessage (serialize/deserialize, forward/reply/response/publish), FakeBus (emit, on/once/remove, wait_for_message/response, run_forever) - test_ssml_extra.py: full SSMLBuilder coverage (sub, emphasis, parts_of_speech, pause, audio, prosody, pitch, volume, rate, phoneme, voice, whisper, remove_ssml, extract_ssml_tags) Module coverage improvements: - ssml.py: 67% → 94% - decorators.py: 0% → 74% - list_utils.py: 40% → 73% - ocp.py: 15% → 26% - fakebus.py: 26% → 41% Also fix pyproject.toml: change license = "Apache" to license = {text = "Apache-2.0"} to comply with PEP 621 / setuptools validation. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 52 +++++ test/unittests/test_decorators.py | 134 +++++++++++ test/unittests/test_fakebus.py | 195 ++++++++++++++++ test/unittests/test_init.py | 114 +++++++++ test/unittests/test_list_utils.py | 91 ++++++++ test/unittests/test_ocp.py | 369 ++++++++++++++++++++++++++++++ test/unittests/test_ssml_extra.py | 262 +++++++++++++++++++++ test/unittests/test_text_utils.py | 103 +++++++++ 8 files changed, 1320 insertions(+) create mode 100644 pyproject.toml create mode 100644 test/unittests/test_decorators.py create mode 100644 test/unittests/test_fakebus.py create mode 100644 test/unittests/test_init.py create mode 100644 test/unittests/test_list_utils.py create mode 100644 test/unittests/test_ocp.py create mode 100644 test/unittests/test_ssml_extra.py create mode 100644 test/unittests/test_text_utils.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..887cc5eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ovos-utils" +dynamic = ["version"] +description = "collection of simple utilities for use across the openvoiceos ecosystem" +readme = "README.md" +license = {text = "Apache-2.0"} +authors = [{name = "jarbasAI", email = "jarbas@openvoiceos.com"}] +requires-python = ">=3.9" +dependencies = [ + "pexpect~=4.9", + "requests~=2.26", + "json_database~=0.10", + "kthread~=0.2", + "watchdog", + "pyee>=8.0.0", + "combo-lock~=0.2", + "rich-click~=1.7", + "rich~=13.7", +] + +[project.urls] +Homepage = "https://github.com/OpenVoiceOS/ovos_utils" +Repository = "https://github.com/OpenVoiceOS/ovos_utils" + +[project.optional-dependencies] +extras = [ + "rapidfuzz>=3.6,<4.0", + "ovos-plugin-manager>=0.0.25", + "ovos-config>=0.0.12", + "ovos-workshop>=0.0.13", + "ovos_bus_client>=0.0.8", + "langcodes", + "timezonefinder", + "oauthlib~=3.2", + "orjson", +] + +[project.scripts] +ovos-logs = "ovos_utils.log_parser:ovos_logs" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["ovos_utils*"] + +[tool.setuptools.dynamic] +version = {attr = "ovos_utils.version.__version__"} diff --git a/test/unittests/test_decorators.py b/test/unittests/test_decorators.py new file mode 100644 index 00000000..cff7937f --- /dev/null +++ b/test/unittests/test_decorators.py @@ -0,0 +1,134 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import time +import unittest + +from ovos_utils.decorators import classproperty, timed_lru_cache + + +class TestClassProperty(unittest.TestCase): + def test_basic_classproperty(self) -> None: + class MyClass: + _value = 42 + + @classproperty + def value(cls) -> int: + return cls._value + + self.assertEqual(MyClass.value, 42) + + def test_classproperty_from_instance(self) -> None: + class MyClass: + @classproperty + def name(cls) -> str: + return "MyClass" + + obj = MyClass() + self.assertEqual(obj.name, "MyClass") + + def test_classproperty_subclass(self) -> None: + class Base: + _x = 10 + + @classproperty + def x(cls) -> int: + return cls._x + + class Child(Base): + _x = 20 + + self.assertEqual(Base.x, 10) + self.assertEqual(Child.x, 20) + + +class TestTimedLruCache(unittest.TestCase): + def test_basic_caching(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=60) + def expensive(n: int) -> int: + nonlocal call_count + call_count += 1 + return n * 2 + + self.assertEqual(expensive(5), 10) + self.assertEqual(expensive(5), 10) + self.assertEqual(call_count, 1) # cached second call + + def test_different_args_not_cached(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=60) + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n + + fn(1) + fn(2) + self.assertEqual(call_count, 2) + + def test_cache_expiry(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=0) + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n + + fn(1) + time.sleep(0.01) + fn(1) + self.assertGreaterEqual(call_count, 2) + + def test_cache_info_available(self) -> None: + @timed_lru_cache(seconds=60) + def fn(n: int) -> int: + return n + + fn(1) + info = fn.cache_info() + self.assertIsNotNone(info) + + def test_cache_clear(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=60) + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n + + fn(1) + fn.cache_clear() + fn(1) + self.assertEqual(call_count, 2) + + def test_decorator_without_args(self) -> None: + """Calling @timed_lru_cache without parentheses.""" + call_count = 0 + + @timed_lru_cache + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n * 3 + + self.assertEqual(fn(4), 12) + self.assertEqual(fn(4), 12) + self.assertEqual(call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_fakebus.py b/test/unittests/test_fakebus.py new file mode 100644 index 00000000..918fa4ff --- /dev/null +++ b/test/unittests/test_fakebus.py @@ -0,0 +1,195 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import unittest +import warnings + +from ovos_utils.fakebus import FakeBus, FakeMessage + + +class TestFakeMessage(unittest.TestCase): + def _make_message(self, msg_type: str, data: dict = None, + context: dict = None) -> FakeMessage: + # FakeMessage may return a real Message object if ovos_bus_client is installed + return FakeMessage(msg_type, data, context) + + def test_basic_construction(self) -> None: + msg = self._make_message("test.type", {"key": "value"}, {"ctx": 1}) + self.assertEqual(msg.msg_type, "test.type") + self.assertEqual(msg.data["key"], "value") + self.assertEqual(msg.context["ctx"], 1) + + def test_defaults(self) -> None: + msg = self._make_message("test.type") + self.assertEqual(msg.data, {}) + self.assertEqual(msg.context, {}) + + def test_serialize_deserialize(self) -> None: + msg = self._make_message("test.roundtrip", {"x": 42}) + serialized = msg.serialize() + restored = FakeMessage.deserialize(serialized) + self.assertEqual(restored.msg_type, "test.roundtrip") + self.assertEqual(restored.data["x"], 42) + + def test_forward(self) -> None: + msg = self._make_message("original.type", {}, {"source": "skill"}) + fwd = msg.forward("forwarded.type", {"new": "data"}) + self.assertEqual(fwd.msg_type, "forwarded.type") + self.assertEqual(fwd.context["source"], "skill") + + def test_reply(self) -> None: + msg = self._make_message("request.type", {}, {"source": "a", "destination": "b"}) + reply = msg.reply("reply.type", {"answer": 1}) + self.assertEqual(reply.msg_type, "reply.type") + + def test_response(self) -> None: + msg = self._make_message("my.request") + resp = msg.response({"result": "ok"}) + self.assertEqual(resp.msg_type, "my.request.response") + self.assertEqual(resp.data["result"], "ok") + + def test_publish(self) -> None: + msg = self._make_message("pub.type", {}, {"target": "skill", "source": "x"}) + published = msg.publish("new.type", {"payload": 1}) + self.assertNotIn("target", published.context) + self.assertEqual(published.data["payload"], 1) + + def test_equality(self) -> None: + m1 = self._make_message("same.type", {"a": 1}, {}) + m2 = self._make_message("same.type", {"a": 1}, {}) + self.assertEqual(m1, m2) + + def test_inequality(self) -> None: + m1 = self._make_message("type.a", {"x": 1}) + m2 = self._make_message("type.b", {"x": 1}) + self.assertNotEqual(m1, m2) + + def test_deserialize_empty_type(self) -> None: + import json + raw = json.dumps({"type": "", "data": {}, "context": {}}) + msg = FakeMessage.deserialize(raw) + self.assertEqual(msg.msg_type, "") + + +class TestFakeBus(unittest.TestCase): + def test_construction(self) -> None: + bus = FakeBus() + self.assertIsNotNone(bus) + self.assertEqual(bus.session_id, "default") + + def test_run_forever(self) -> None: + bus = FakeBus() + self.assertFalse(bus.started_running) + bus.run_forever() + self.assertTrue(bus.started_running) + + def test_run_in_thread(self) -> None: + bus = FakeBus() + bus.run_in_thread() + self.assertTrue(bus.started_running) + + def test_on_and_emit(self) -> None: + bus = FakeBus() + received = [] + + def handler(msg): + received.append(msg) + + bus.on("test.event", handler) + msg = FakeMessage("test.event", {"hello": "world"}) + bus.emit(msg) + self.assertEqual(len(received), 1) + self.assertEqual(received[0].data["hello"], "world") + + def test_once(self) -> None: + bus = FakeBus() + received = [] + + def handler(msg): + received.append(msg) + + bus.once("once.event", handler) + msg = FakeMessage("once.event") + bus.emit(msg) + bus.emit(msg) + self.assertEqual(len(received), 1) + + def test_remove_listener(self) -> None: + bus = FakeBus() + received = [] + + def handler(msg): + received.append(msg) + + bus.on("test.remove", handler) + bus.remove("test.remove", handler) + bus.emit(FakeMessage("test.remove")) + self.assertEqual(len(received), 0) + + def test_remove_all_listeners(self) -> None: + bus = FakeBus() + received = [] + + bus.on("event", lambda m: received.append(m)) + bus.on("event", lambda m: received.append(m)) + bus.remove_all_listeners("event") + bus.emit(FakeMessage("event")) + self.assertEqual(len(received), 0) + + def test_wait_for_message(self) -> None: + bus = FakeBus() + + import threading + def send_later(): + import time + time.sleep(0.05) + bus.emit(FakeMessage("delayed.event", {"val": 99})) + + t = threading.Thread(target=send_later) + t.start() + msg = bus.wait_for_message("delayed.event", timeout=2.0) + t.join() + self.assertIsNotNone(msg) + self.assertEqual(msg.data["val"], 99) + + def test_wait_for_message_timeout(self) -> None: + bus = FakeBus() + msg = bus.wait_for_message("never.comes", timeout=0.05) + self.assertIsNone(msg) + + def test_wait_for_response(self) -> None: + bus = FakeBus() + + def auto_reply(msg): + bus.emit(msg.response({"answer": 42})) + + bus.on("question", auto_reply) + request = FakeMessage("question", {"q": "?"}) + response = bus.wait_for_response(request, timeout=2.0) + self.assertIsNotNone(response) + self.assertEqual(response.data["answer"], 42) + + def test_create_client_returns_self(self) -> None: + bus = FakeBus() + self.assertIs(bus.create_client(), bus) + + def test_close(self) -> None: + bus = FakeBus() + bus.close() # Should not raise + + def test_on_error_does_not_raise(self) -> None: + bus = FakeBus() + bus.on_error(Exception("test error")) # Should not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_init.py b/test/unittests/test_init.py new file mode 100644 index 00000000..0e7a1e31 --- /dev/null +++ b/test/unittests/test_init.py @@ -0,0 +1,114 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import dataclasses +import unittest +import warnings + +from ovos_utils import json_dumps, json_loads, datestr2ts + + +class TestJsonDumps(unittest.TestCase): + """Tests for json_dumps / json_loads in ovos_utils/__init__.py""" + + def test_dumps_dict(self) -> None: + result = json_dumps({"key": "value", "num": 42}) + self.assertIn('"key"', result) + self.assertIn('"value"', result) + self.assertIn("42", result) + + def test_dumps_list(self) -> None: + result = json_dumps([1, 2, 3]) + self.assertEqual(json_loads(result), [1, 2, 3]) + + def test_dumps_string(self) -> None: + result = json_dumps("hello") + self.assertIn("hello", result) + + def test_dumps_none(self) -> None: + result = json_dumps(None) + self.assertEqual(result, "null") + + def test_dumps_bool(self) -> None: + self.assertIn("true", json_dumps(True).lower()) + self.assertIn("false", json_dumps(False).lower()) + + def test_dumps_nested(self) -> None: + payload = {"a": {"b": [1, 2, 3]}} + result = json_dumps(payload) + self.assertEqual(json_loads(result), payload) + + def test_dumps_unicode(self) -> None: + payload = {"greeting": "héllo wörld"} + result = json_dumps(payload) + self.assertIn("héllo", result) + + def test_dumps_dataclass(self) -> None: + @dataclasses.dataclass + class Point: + x: int + y: int + + p = Point(x=3, y=7) + result = json_dumps(p) + loaded = json_loads(result) + self.assertEqual(loaded["x"], 3) + self.assertEqual(loaded["y"], 7) + + def test_roundtrip(self) -> None: + original = {"nested": {"list": [1, "two", 3.0], "bool": True}} + self.assertEqual(json_loads(json_dumps(original)), original) + + +class TestJsonLoads(unittest.TestCase): + """Tests for json_loads in ovos_utils/__init__.py""" + + def test_loads_dict(self) -> None: + result = json_loads('{"a": 1}') + self.assertEqual(result, {"a": 1}) + + def test_loads_list(self) -> None: + result = json_loads('[1, 2, 3]') + self.assertEqual(result, [1, 2, 3]) + + def test_loads_null(self) -> None: + result = json_loads("null") + self.assertIsNone(result) + + def test_loads_unicode(self) -> None: + result = json_loads('{"word": "héllo"}') + self.assertEqual(result["word"], "héllo") + + +class TestDeprecatedHelpers(unittest.TestCase): + """Tests for deprecated functions in ovos_utils/__init__.py""" + + def test_datestr2ts_warns(self) -> None: + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + ts = datestr2ts("20230101") + self.assertEqual(len(w), 1) + self.assertIn("deprecated", str(w[0].message).lower()) + # 2023-01-01 should return a positive timestamp + self.assertGreater(ts, 0) + + def test_datestr2ts_value(self) -> None: + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + ts = datestr2ts("20000101") + import datetime + expected = datetime.datetime(2000, 1, 1).timestamp() + self.assertAlmostEqual(ts, expected, places=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_list_utils.py b/test/unittests/test_list_utils.py new file mode 100644 index 00000000..ed05f4dc --- /dev/null +++ b/test/unittests/test_list_utils.py @@ -0,0 +1,91 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import unittest + +from ovos_utils.list_utils import rotate_list, flatten_list, deduplicate_list + + +class TestRotateList(unittest.TestCase): + def test_rotate_by_one(self) -> None: + self.assertEqual(rotate_list([1, 2, 3], 1), [2, 3, 1]) + + def test_rotate_by_two(self) -> None: + self.assertEqual(rotate_list([1, 2, 3, 4], 2), [3, 4, 1, 2]) + + def test_rotate_empty(self) -> None: + self.assertEqual(rotate_list([], 1), []) + + def test_rotate_default(self) -> None: + self.assertEqual(rotate_list([1, 2, 3]), [2, 3, 1]) + + def test_rotate_full_cycle(self) -> None: + lst = [1, 2, 3] + self.assertEqual(rotate_list(lst, len(lst)), lst) + + def test_rotate_zero(self) -> None: + self.assertEqual(rotate_list([1, 2, 3], 0), [1, 2, 3]) + + +class TestFlattenList(unittest.TestCase): + def test_basic_nested(self) -> None: + self.assertEqual(flatten_list([[1, 2], [3, 4]]), [1, 2, 3, 4]) + + def test_deeply_nested(self) -> None: + # flatten_list flattens one level at a time for list-of-lists + result = flatten_list([[1, 2], [3, 4]]) + self.assertEqual(result, [1, 2, 3, 4]) + + def test_with_tuples(self) -> None: + result = flatten_list([(1, 2), (3, 4)]) + self.assertEqual(result, [1, 2, 3, 4]) + + def test_tuples_false(self) -> None: + # tuples=False: outer list is flattened, but tuples are kept as-is + result = flatten_list([[1, 2], [3, 4]], tuples=False) + self.assertEqual(result, [1, 2, 3, 4]) + + def test_already_flat(self) -> None: + self.assertEqual(flatten_list([[1, 2, 3]]), [1, 2, 3]) + + def test_empty(self) -> None: + self.assertEqual(flatten_list([[]]), []) + + +class TestDeduplicateList(unittest.TestCase): + def test_basic_dedup(self) -> None: + result = deduplicate_list(["a", "b", "a", "c"]) + self.assertEqual(result, ["a", "b", "c"]) + + def test_order_preserved(self) -> None: + result = deduplicate_list(["c", "a", "b", "a"]) + self.assertEqual(result[0], "c") + self.assertEqual(len(result), 3) + + def test_no_order(self) -> None: + result = deduplicate_list(["a", "b", "a", "c"], keep_order=False) + self.assertEqual(set(result), {"a", "b", "c"}) + self.assertEqual(len(result), 3) + + def test_no_duplicates(self) -> None: + lst = ["x", "y", "z"] + self.assertEqual(deduplicate_list(lst), lst) + + def test_empty(self) -> None: + self.assertEqual(deduplicate_list([]), []) + + def test_all_same(self) -> None: + self.assertEqual(deduplicate_list(["a", "a", "a"]), ["a"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py new file mode 100644 index 00000000..573a6e2f --- /dev/null +++ b/test/unittests/test_ocp.py @@ -0,0 +1,369 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import unittest + +from ovos_utils.ocp import ( + MediaEntry, + MediaType, + PlaybackType, + TrackState, + MediaState, + PlayerState, + LoopState, + PlaybackMode, + MatchConfidence, + Playlist, + PluginStream, + find_mime, + OCP_ID, +) + + +class TestEnums(unittest.TestCase): + def test_media_type_values(self) -> None: + self.assertEqual(MediaType.GENERIC, 0) + self.assertEqual(MediaType.MUSIC, 2) + self.assertEqual(MediaType.MOVIE, 10) + self.assertEqual(MediaType.ADULT, 69) + + def test_playback_type_values(self) -> None: + self.assertEqual(PlaybackType.SKILL, 0) + self.assertEqual(PlaybackType.VIDEO, 1) + self.assertEqual(PlaybackType.UNDEFINED, 100) + + def test_track_state_values(self) -> None: + self.assertEqual(TrackState.DISAMBIGUATION, 1) + self.assertEqual(TrackState.PLAYING_AUDIO, 23) + + def test_media_state_values(self) -> None: + self.assertEqual(MediaState.UNKNOWN, 0) + self.assertEqual(MediaState.END_OF_MEDIA, 7) + + def test_player_state_values(self) -> None: + self.assertEqual(PlayerState.STOPPED, 0) + self.assertEqual(PlayerState.PLAYING, 1) + self.assertEqual(PlayerState.PAUSED, 2) + + def test_loop_state(self) -> None: + self.assertEqual(LoopState.NONE, 0) + self.assertEqual(LoopState.REPEAT, 1) + self.assertEqual(LoopState.REPEAT_TRACK, 2) + + def test_playback_mode(self) -> None: + self.assertEqual(PlaybackMode.AUTO, 0) + self.assertEqual(PlaybackMode.AUDIO_ONLY, 10) + self.assertEqual(PlaybackMode.EVENTS_ONLY, 50) + + def test_match_confidence(self) -> None: + self.assertEqual(MatchConfidence.EXACT, 95) + self.assertEqual(MatchConfidence.VERY_LOW, 1) + + +class TestFindMime(unittest.TestCase): + def test_mp3(self) -> None: + mime = find_mime("song.mp3") + self.assertIsNotNone(mime) + self.assertIn("audio", mime[0]) + + def test_mp4(self) -> None: + mime = find_mime("video.mp4") + self.assertIsNotNone(mime) + self.assertIn("video", mime[0]) + + def test_unknown(self) -> None: + mime = find_mime("http://example.com/stream") + # May return None or a tuple; no crash expected + # Just verify no exception is raised + self.assertTrue(mime is None or isinstance(mime, tuple)) + + +class TestMediaEntry(unittest.TestCase): + def test_defaults(self) -> None: + entry = MediaEntry(uri="http://example.com/audio.mp3") + self.assertEqual(entry.uri, "http://example.com/audio.mp3") + self.assertEqual(entry.skill_id, OCP_ID) + self.assertEqual(entry.media_type, MediaType.GENERIC) + self.assertEqual(entry.playback, PlaybackType.UNDEFINED) + self.assertEqual(entry.status, TrackState.DISAMBIGUATION) + + def test_as_dict(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="Test Song") + d = entry.as_dict + self.assertEqual(d["uri"], "http://example.com/a.mp3") + self.assertEqual(d["title"], "Test Song") + + def test_from_dict(self) -> None: + entry = MediaEntry.from_dict({ + "uri": "http://example.com/b.mp3", + "title": "Another Song", + "media_type": MediaType.MUSIC, + }) + self.assertEqual(entry.uri, "http://example.com/b.mp3") + self.assertEqual(entry.title, "Another Song") + self.assertEqual(entry.media_type, MediaType.MUSIC) + + def test_from_dict_missing_uri_raises(self) -> None: + # No 'uri' key → dict2entry raises ValueError (no uri/extractor_id/playlist) + with self.assertRaises(ValueError): + MediaEntry.from_dict({"title": "orphan"}) + + def test_infocard(self) -> None: + entry = MediaEntry(uri="http://example.com/c.mp3", title="Song C", + image="http://example.com/img.png", length=180) + card = entry.infocard + self.assertEqual(card["track"], "Song C") + self.assertEqual(card["duration"], 180) + self.assertEqual(card["uri"], "http://example.com/c.mp3") + + def test_mimetype_mp3(self) -> None: + entry = MediaEntry(uri="http://example.com/song.mp3") + mime = entry.mimetype + self.assertIsNotNone(mime) + self.assertIn("audio", mime[0]) + + def test_mimetype_no_uri(self) -> None: + entry = MediaEntry() + self.assertIsNone(entry.mimetype) + + def test_equality_same(self) -> None: + e1 = MediaEntry(uri="http://example.com/a.mp3", title="A") + e2 = MediaEntry(uri="http://example.com/a.mp3", title="A") + self.assertEqual(e1, e2) + + def test_equality_different(self) -> None: + e1 = MediaEntry(uri="http://example.com/a.mp3") + e2 = MediaEntry(uri="http://example.com/b.mp3") + self.assertNotEqual(e1, e2) + + def test_equality_with_dict(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="A") + self.assertEqual(entry, entry.infocard) + + def test_update_from_dict(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3") + entry.update({"title": "Updated", "length": 120}) + self.assertEqual(entry.title, "Updated") + self.assertEqual(entry.length, 120) + + def test_update_skipkeys(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="Original") + entry.update({"title": "New"}, skipkeys=["title"]) + self.assertEqual(entry.title, "Original") + + def test_update_newonly(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="Original") + entry.update({"title": "New", "artist": "Artist"}, newonly=True) + self.assertEqual(entry.title, "Original") # existing not replaced + self.assertEqual(entry.artist, "Artist") # new key added + + def test_update_from_media_entry(self) -> None: + e1 = MediaEntry(uri="http://example.com/a.mp3") + e2 = MediaEntry(uri="http://example.com/b.mp3", title="B") + e1.update(e2) + self.assertEqual(e1.uri, "http://example.com/b.mp3") + + +class TestPluginStream(unittest.TestCase): + def test_defaults(self) -> None: + ps = PluginStream(stream="abc123", extractor_id="youtube") + self.assertEqual(ps.stream, "abc123") + self.assertEqual(ps.extractor_id, "youtube") + self.assertEqual(ps.skill_id, OCP_ID) + + def test_as_dict(self) -> None: + ps = PluginStream(stream="vid", extractor_id="yt", title="My Video") + d = ps.as_dict + self.assertEqual(d["stream"], "vid") + self.assertEqual(d["extractor_id"], "yt") + self.assertEqual(d["title"], "My Video") + + def test_from_dict(self) -> None: + ps = PluginStream.from_dict({"stream": "s1", "extractor_id": "yt"}) + self.assertEqual(ps.stream, "s1") + self.assertEqual(ps.extractor_id, "yt") + + def test_from_dict_missing_extractor_raises(self) -> None: + with self.assertRaises(ValueError): + PluginStream.from_dict({"stream": "s1"}) + + def test_from_dict_missing_stream_raises(self) -> None: + with self.assertRaises(ValueError): + PluginStream.from_dict({"extractor_id": "yt"}) + + def test_infocard(self) -> None: + ps = PluginStream(stream="v", extractor_id="yt", title="Title", + image="http://img.png", length=60) + card = ps.infocard + self.assertEqual(card["track"], "Title") + self.assertEqual(card["duration"], 60) + self.assertIn("yt//v", card["uri"]) + + def test_as_media_entry(self) -> None: + ps = PluginStream(stream="vid", extractor_id="yt", title="T", + media_type=MediaType.VIDEO) + me = ps.as_media_entry + self.assertIsInstance(me, MediaEntry) + self.assertIn("yt//vid", me.uri) + + +class TestPlaylist(unittest.TestCase): + def _make_entry(self, uri: str, title: str = "", confidence: int = 0) -> MediaEntry: + return MediaEntry(uri=uri, title=title, match_confidence=confidence) + + def test_empty_playlist(self) -> None: + pl = Playlist() + self.assertEqual(len(pl), 0) + self.assertIsNone(pl.current_track) + self.assertTrue(pl.is_first_track) + self.assertTrue(pl.is_last_track) + + def test_add_entry(self) -> None: + pl = Playlist() + e = self._make_entry("http://example.com/a.mp3", "A") + pl.add_entry(e) + self.assertEqual(len(pl), 1) + + def test_add_multiple_entries(self) -> None: + pl = Playlist() + for i in range(3): + pl.add_entry(self._make_entry(f"http://example.com/{i}.mp3", str(i))) + self.assertEqual(len(pl), 3) + + def test_current_track(self) -> None: + pl = Playlist() + e = self._make_entry("http://example.com/a.mp3", "Track A") + pl.add_entry(e) + track = pl.current_track + self.assertEqual(track.title, "Track A") + + def test_is_first_track(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + self.assertTrue(pl.is_first_track) + pl.position = 1 + self.assertFalse(pl.is_first_track) + + def test_is_last_track(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + self.assertFalse(pl.is_last_track) + pl.position = 1 + self.assertTrue(pl.is_last_track) + + def test_goto_start(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + pl.position = 1 + pl.goto_start() + self.assertEqual(pl.position, 0) + + def test_clear(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.clear() + self.assertEqual(len(pl), 0) + self.assertEqual(pl.position, 0) + + def test_remove_entry_by_object(self) -> None: + pl = Playlist() + e = self._make_entry("http://a.com/1.mp3", "Track1") + pl.add_entry(e) + pl.remove_entry(e) + self.assertEqual(len(pl), 0) + + def test_remove_entry_by_index(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + pl.remove_entry(0) + self.assertEqual(len(pl), 1) + + def test_remove_entry_not_found(self) -> None: + pl = Playlist() + e = self._make_entry("http://a.com/1.mp3") + with self.assertRaises(ValueError): + pl.remove_entry(e) + + def test_sort_by_conf(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/low.mp3", confidence=10)) + pl.add_entry(self._make_entry("http://a.com/high.mp3", confidence=90)) + pl.sort_by_conf() + self.assertEqual(pl[0].match_confidence, 90) + + def test_infocard(self) -> None: + pl = Playlist(title="My List") + card = pl.infocard + self.assertEqual(card["track"], "My List") + self.assertEqual(card["uri"], "") + + def test_length(self) -> None: + pl = Playlist() + pl.add_entry(MediaEntry(uri="http://a.com/1.mp3", length=60)) + pl.add_entry(MediaEntry(uri="http://a.com/2.mp3", length=120)) + self.assertEqual(pl.length, 180) + + def test_as_dict(self) -> None: + pl = Playlist(title="Test PL") + pl.add_entry(self._make_entry("http://a.com/1.mp3", "Track1")) + d = pl.as_dict + self.assertEqual(d["title"], "Test PL") + self.assertEqual(len(d["playlist"]), 1) + + def test_replace(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/old.mp3")) + new_entries = [self._make_entry("http://a.com/new1.mp3"), + self._make_entry("http://a.com/new2.mp3")] + pl.replace(new_entries) + self.assertEqual(len(pl), 2) + + def test_init_from_list(self) -> None: + entries = [self._make_entry("http://a.com/1.mp3"), + self._make_entry("http://a.com/2.mp3")] + pl = Playlist(entries) + self.assertEqual(len(pl), 2) + + def test_add_entry_invalid_index(self) -> None: + pl = Playlist() + e = self._make_entry("http://a.com/1.mp3") + with self.assertRaises(ValueError): + pl.add_entry(e, index=99) + + def test_from_dict(self) -> None: + d = { + "title": "FromDict", + "playlist": [ + {"uri": "http://a.com/1.mp3", "title": "T1"}, + ] + } + pl = Playlist.from_dict(d) + self.assertEqual(pl.title, "FromDict") + + def test_from_dict_missing_playlist_raises(self) -> None: + with self.assertRaises(ValueError): + Playlist.from_dict({"title": "No entries"}) + + def test_entries_property(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3", "T1")) + entries = pl.entries + self.assertEqual(len(entries), 1) + self.assertIsInstance(entries[0], MediaEntry) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_ssml_extra.py b/test/unittests/test_ssml_extra.py new file mode 100644 index 00000000..ff6f3540 --- /dev/null +++ b/test/unittests/test_ssml_extra.py @@ -0,0 +1,262 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import unittest + +from ovos_utils.ssml import SSMLBuilder + + +class TestSSMLBuilderExtra(unittest.TestCase): + """Additional SSMLBuilder tests covering uncovered methods.""" + + TEXT = "hello world" + + def test_sub(self) -> None: + result = SSMLBuilder().sub(alias="World", word="W").build() + self.assertIn(" None: + with self.assertRaises(TypeError): + SSMLBuilder().sub(word="W") + + def test_sub_none_word_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().sub(alias="A") + + def test_sub_empty_word_raises(self) -> None: + with self.assertRaises(ValueError): + SSMLBuilder().sub(alias="A", word=" ") + + def test_emphasis(self) -> None: + result = SSMLBuilder().emphasis(level="moderate", word=self.TEXT).build() + self.assertIn("", result) + + def test_emphasis_none_level_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().emphasis(word=self.TEXT) + + def test_emphasis_none_word_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().emphasis(level="moderate") + + def test_emphasis_empty_word_raises(self) -> None: + with self.assertRaises(ValueError): + SSMLBuilder().emphasis(level="moderate", word=" ") + + def test_parts_of_speech(self) -> None: + result = SSMLBuilder().parts_of_speech(word="bass", role="amazon:VB").build() + self.assertIn("bass", result) + + def test_parts_of_speech_none_word_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().parts_of_speech(role="amazon:VB") + + def test_parts_of_speech_none_role_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().parts_of_speech(word="bass") + + def test_pause_by_strength(self) -> None: + result = SSMLBuilder().pause_by_strength(strength="Medium").build() + self.assertIn("", result) + + def test_pause_by_strength_none_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().pause_by_strength() + + def test_pause_by_strength_non_string_raises(self) -> None: + with self.assertRaises(AttributeError): + SSMLBuilder().pause_by_strength(strength=42) + + def test_audio(self) -> None: + result = SSMLBuilder().audio(audio_file="sound.mp3", text=self.TEXT).build() + self.assertIn("