Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"

- name: Sanity-check Python sources
run: python3 -m compileall -q default.py plugin.py service.py lib

# No Kodi here, and none of these modules may import a third-party test
# runner (the addon ships with zero third-party dependencies, by the
# repo's own rule - see AutoCompletion.py's module docstring). tests/
# fakes xbmc/xbmcaddon/xbmcvfs in sys.modules before import instead of
# mocking a function call, because AutoCompletion.py resolves settings
# and paths at IMPORT time, not inside a function a test could patch.
- name: Run unit tests
run: python3 -m unittest discover -s tests -v
50 changes: 44 additions & 6 deletions lib/vendor/AutoCompletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,56 @@ def get_autocomplete_items(search_str, limit=10, provider=None):
if xbmc.getCondVisibility("System.HasHiddenInput"):
return []

language = resolve_language(search_str)
setting = SETTING("autocomplete_provider").lower()

if setting == "youtube":
provider = GoogleProvider(youtube=True, limit=limit)
provider = GoogleProvider(youtube=True, limit=limit, language=language)
elif setting == "google":
provider = GoogleProvider(limit=limit)
provider = GoogleProvider(limit=limit, language=language)
elif setting == "bing":
provider = BingProvider(limit=limit)
provider = BingProvider(limit=limit, language=language)
elif setting == "tmdb":
provider = TmdbProvider(limit=limit)
provider = TmdbProvider(limit=limit, language=language)
else:
provider = LocalDictProvider(limit=limit)
provider = LocalDictProvider(limit=limit, language=language)
provider.limit = limit
return provider.get_predictions(search_str)


def detect_language(text):
"""he/en from the query text alone: one Hebrew character (U+0590-U+05FF)
makes it Hebrew. That block is the same one `prep_search_str` below
already tests against (1488-1514 is its letter subset), reused here per
the request rather than invented fresh. Digits and Latin punctuation sit
outside the block, so neither affects the result; a mixed string with
even one Hebrew character returns "he" — the detector runs on the whole
query, not its first token, so "שובר bad" is Hebrew exactly like "שובר
שורות" is.
"""
for char in text or "":
if "֐" <= char <= "׿":
return "he"
return "en"


def resolve_language(search_str):
"""The effective language for this one request.

`autocomplete_lang` stays a fixed value for everyone who has not touched
the new "auto" option — existing installs keep behaving exactly as
before. Picking "auto" is what turns detection on, and the result is
returned per call rather than written back into any setting or global:
the configured value needs to survive being read again next time
unchanged, and a fixed value must never be overwritten by whichever
language happened to be typed last.
"""
configured = (SETTING("autocomplete_lang") or "").strip()
if configured.lower() == "auto":
return detect_language(search_str)
return configured


def prep_search_str(text):
for char in text:
if 1488 <= ord(char) <= 1514:
Expand All @@ -66,7 +100,11 @@ class BaseProvider(ABC):

def __init__(self, *args, **kwargs):
self.limit = kwargs.get("limit", 10)
self.language = SETTING("autocomplete_lang")
# `language` is passed per call by get_autocomplete_items (the already
# resolved value, auto-detected or fixed). The SETTING() fallback is
# for the provider classes' own stated contract of being constructible
# on their own; nothing in this codebase instantiates one without it.
self.language = kwargs.get("language") or SETTING("autocomplete_lang")

@abstractmethod
def build_url(self, query):
Expand Down
5 changes: 5 additions & 0 deletions resources/settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
<default>en</default>
<constraints>
<options>
<!-- Auto-detects he/en from each query's own text (AutoCompletion.py
detect_language), so Hebrew and English searches both get correct
suggestions with no manual switching. Any other language code here
behaves exactly as before - this option is purely additive. -->
<option>auto</option>
<option>bg</option>
<option>cs</option>
<option>da</option>
Expand Down
71 changes: 71 additions & 0 deletions tests/kodistubs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""Minimal fake `xbmc`/`xbmcaddon`/`xbmcvfs` so `lib/vendor/AutoCompletion.py`
can be imported outside Kodi.

That module reads addon settings and resolves paths at IMPORT time (module-
level `PLUGIN_ADDON = xbmcaddon.Addon(PLUGIN_ID)`), so a test cannot simply
mock a function call — the fakes have to exist in `sys.modules` before the
`import AutoCompletion` line runs. `install()` does exactly that and nothing
else: no pip install, no real Kodi, consistent with this repo's own
zero-third-party-dependency rule (the module docstring: "requests replaced
with stdlib urllib").

Call `install()` once at the top of a test module, before importing
AutoCompletion. `settings` is a plain dict a test can mutate directly to
change what `SETTING("autocomplete_lang")` returns mid-test.
"""
from __future__ import annotations

import sys
import types

settings: dict[str, str] = {"autocomplete_lang": "en", "autocomplete_provider": "Google"}


class _FakeAddon:
def getSetting(self, key):
return settings.get(key, "")

def getAddonInfo(self, key):
return {"path": "/addon", "id": "plugin.program.autocompletion",
"profile": "/profile"}.get(key, "")


def _fake_xbmcaddon():
module = types.ModuleType("xbmcaddon")
module.Addon = lambda *a, **k: _FakeAddon()
return module


def _fake_xbmcvfs():
module = types.ModuleType("xbmcvfs")
module.translatePath = lambda path: path
module.exists = lambda path: False
module.mkdirs = lambda path: None
module.File = lambda *a, **k: None
return module


def _fake_xbmc():
module = types.ModuleType("xbmc")
module.getCondVisibility = lambda condition: False
module.log = lambda msg="", level=0: None
module.LOGDEBUG = 0

class _Monitor:
def abortRequested(self):
return False

def waitForAbort(self, seconds):
return False

module.Monitor = _Monitor
return module


def install():
"""Idempotent: safe to call from every test module that needs it."""
for name, factory in (("xbmc", _fake_xbmc), ("xbmcaddon", _fake_xbmcaddon),
("xbmcvfs", _fake_xbmcvfs)):
if name not in sys.modules:
sys.modules[name] = factory()
126 changes: 126 additions & 0 deletions tests/test_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""Hebrew/English auto-detection: `detect_language`, `resolve_language`, and
the providers that build a URL from whichever language came out of them.

The failure mode this guards against is not "detection is wrong" in the
abstract — it is "a fixed install with `autocomplete_lang=en` quietly starts
behaving differently", or "the Google/TMDb URL gets `hl=auto`/`language=auto`
sent to the real API because the sentinel leaked past the point meant to
resolve it".
"""
import os
import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib", "vendor"))
sys.path.insert(0, os.path.dirname(__file__))

import kodistubs # noqa: E402

kodistubs.install()

import AutoCompletion # noqa: E402


class TestDetectLanguage(unittest.TestCase):
def test_the_examples_from_the_spec(self):
cases = {
"brea": "en",
"שובר": "he",
"breaking": "en",
"breaking bad": "en",
"שובר שורות": "he",
"game of thrones": "en",
"משחקי הכס": "he",
"matrix 2": "en",
"מטריקס 2": "he",
}
for query, expected in cases.items():
self.assertEqual(AutoCompletion.detect_language(query), expected, query)

def test_mixed_text_is_hebrew_if_any_hebrew_character_is_present(self):
"""A single Hebrew character anywhere in the string is enough — the
detector reads the whole query, not just its first word."""
self.assertEqual(AutoCompletion.detect_language("שובר bad"), "he")
self.assertEqual(AutoCompletion.detect_language("bad שובר"), "he")

def test_numbers_and_punctuation_never_flip_the_result(self):
self.assertEqual(AutoCompletion.detect_language("matrix 2!?"), "en")
self.assertEqual(AutoCompletion.detect_language("מטריקס 2!?"), "he")
self.assertEqual(AutoCompletion.detect_language("123 456"), "en")

def test_empty_or_none_is_english_rather_than_an_exception(self):
"""A query comes from a live keyboard; an empty string arrives, and
must not be the thing that turns a normal search into a traceback."""
self.assertEqual(AutoCompletion.detect_language(""), "en")
self.assertEqual(AutoCompletion.detect_language(None), "en")


class TestResolveLanguage(unittest.TestCase):
def setUp(self):
kodistubs.settings["autocomplete_lang"] = "en"

def test_auto_detects_from_the_query(self):
kodistubs.settings["autocomplete_lang"] = "auto"
self.assertEqual(AutoCompletion.resolve_language("שובר"), "he")
self.assertEqual(AutoCompletion.resolve_language("breaking"), "en")

def test_auto_is_matched_case_and_space_insensitively(self):
"""A value typed by hand into an advanced setting, or carried over
from an older skin, should not silently stop auto-detecting."""
for value in ("Auto", "AUTO", " auto ", "auto"):
kodistubs.settings["autocomplete_lang"] = value
self.assertEqual(AutoCompletion.resolve_language("שובר"), "he")

def test_a_fixed_language_is_returned_unchanged_no_matter_what_was_typed(self):
"""This is the backward-compatibility guarantee: an existing install
with a real language code set must behave byte-for-byte as before -
Hebrew text typed into an `en`-fixed install still resolves to `en`."""
for fixed in ("en", "fr", "he", "ja"):
kodistubs.settings["autocomplete_lang"] = fixed
self.assertEqual(AutoCompletion.resolve_language("שובר"), fixed)
self.assertEqual(AutoCompletion.resolve_language("breaking"), fixed)

def test_an_empty_setting_does_not_crash_and_is_not_treated_as_auto(self):
kodistubs.settings["autocomplete_lang"] = ""
self.assertEqual(AutoCompletion.resolve_language("שובר"), "")


class TestProvidersUseTheResolvedLanguage(unittest.TestCase):
"""The sentinel "auto" must never reach a real request URL."""

def test_google_hl_parameter(self):
url = AutoCompletion.GoogleProvider(language="he").build_url("x")
self.assertIn("hl=he", url)
self.assertNotIn("auto", url)

def test_tmdb_language_parameter(self):
url = AutoCompletion.TmdbProvider(language="he").build_url("x")
self.assertIn("language=he", url)
self.assertNotIn("auto", url)

def test_bing_is_untouched_because_it_never_used_the_language_setting(self):
"""Bing's build_url never referenced self.language before this change
and must not start now - the spec says explicitly not to touch a
provider that doesn't already use the setting."""
url = AutoCompletion.BingProvider(language="he").build_url("x")
self.assertNotIn("he", url)
self.assertNotIn("language", url)

def test_local_dict_provider_keeps_its_own_separate_setting(self):
"""LocalDictProvider has always read `autocomplete_lang_local`, a
different setting, and must keep doing so even though it now also
receives a `language` kwarg it does not ask for."""
kodistubs.settings["autocomplete_lang_local"] = "fr"
provider = AutoCompletion.LocalDictProvider(language="he")
self.assertEqual(provider.language, "fr")

def test_a_provider_built_with_no_language_kwarg_falls_back_to_the_setting(self):
"""Nothing in this codebase constructs a provider this way anymore,
but the class is still a public contract on its own."""
kodistubs.settings["autocomplete_lang"] = "de"
self.assertEqual(AutoCompletion.GoogleProvider().language, "de")


if __name__ == "__main__":
unittest.main()