diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..03c814ec --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,113 @@ +name: CI + +on: + push: + branches-ignore: + - "master" + - "releases/**" + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9'] + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: install + run: | + pip install -r requirements.txt + + - name: test + run: | + pytest + - name: createcoverage + run: | + pytest --cov=plugin.program.autowidget + # - name: Coveralls + # uses: coverallsapp/github-action@master + # with: + # github-token: ${{ secrets.GITHUB_TOKEN }} + - name: make package + run: | + sed -i "s/name=\"AutoWidget\" version=\"CURRENT_VERSION\"/name=\"AutoWidget\" version=\"${CIRCLE_TAG-0.0.$CIRCLE_BUILD_NUM}\"/" ./plugin.program.autowidget/addon.xml + zip -Xr /tmp/plugin.program.autowidget.zip plugin.program.autowidget + # - store_artifacts: + # path: /tmp/plugin.program.autowidget.zip + # - persist_to_workspace: + # root: /tmp + # paths: + # - plugin.program.autowidget.zip + + +# circle ci config + +# jobs: +# build: +# docker: +# - image: circleci/python:2 +# steps: +# - checkout +# release: +# parameters: +# commit_branch: +# type: string +# default: repo +# overwrite: +# type: boolean +# default: false +# docker: +# - image: circleci/python:2 +# steps: +# - attach_workspace: +# at: /tmp +# - checkout +# - run: | +# git checkout << parameters.commit_branch >> +# python create_repository.py -d ./zips /tmp/plugin.program.autowidget.zip +# find ./zips -name "*.zip" | xargs python create_repository.py -d ./zips +# - unless: +# condition: << parameters.overwrite >> +# steps: +# run: git ls-files -m ./zips/|grep -v ".zip" +# - run: | +# git add ./zips +# git commit -m "Release version ${CIRCLE_TAG-0.0.$CIRCLE_BUILD_NUM}" +# git push +# workflows: +# version: 2 +# build_and_release: +# jobs: +# - build: +# filters: +# tags: +# only: /.*/ +# branches: +# ignore: +# - repo +# - devrepo +# - release: +# context: publish +# requires: +# - build +# filters: +# branches: +# ignore: /.*/ +# tags: +# only: /.*/ +# - release: +# commit_branch: devrepo +# overwrite: true +# context: publish +# requires: +# - build +# filters: +# branches: +# ignore: +# - devrepo +# - master +# - repo diff --git a/.gitignore b/.gitignore index ac8c5da3..dd005226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,16 @@ -*.pyc -*.pyo -.DS* -.pylint_rc -/.idea -/.project -/.pydevproject -/.settings -Thumbs.db -*~ -.cache -lib -bin -.venv -.vscode -venv/ +*.pyc +*.pyo +.DS* +.pylint_rc +/.idea +/.project +/.pydevproject +/.settings +Thumbs.db +*~ +.cache +lib +bin +.venv +.vscode +venv/ diff --git a/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po b/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po index 709f6425..cc30f973 100644 --- a/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po +++ b/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po @@ -16,8 +16,6 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#This is a comment - msgctxt "#32000" msgid "General" msgstr "" @@ -95,7 +93,7 @@ msgid "Create a new group of shortcuts." msgstr "" msgctxt "#32019" -msgid "View the "{}" group." +msgid "View the \"{}\" group." msgstr "" msgctxt "#32020" @@ -563,7 +561,7 @@ msgid "Predefined Color" msgstr "" msgctxt "#32136" -msgid "Hex codes must be of the format "#RRGGBB"." +msgid "Hex codes must be of the format \"#RRGGBB\"." msgstr "" msgctxt "#32137" diff --git a/plugin.program.autowidget/resources/lib/common/directory.py b/plugin.program.autowidget/resources/lib/common/directory.py index 20edf705..5e30a77a 100644 --- a/plugin.program.autowidget/resources/lib/common/directory.py +++ b/plugin.program.autowidget/resources/lib/common/directory.py @@ -6,6 +6,7 @@ import six from resources.lib.common import utils +from resources.lib import common try: from urllib.parse import urlencode @@ -60,8 +61,8 @@ def add_sort_methods(handle): def add_menu_item(title, params=None, path=None, info=None, cm=None, art=None, isFolder=False, props=None): - _plugin = sys.argv[0] - _handle = int(sys.argv[1]) + _plugin = common.dispatched_plugin + _handle = common.dispatched_handle if params is not None: diff --git a/plugin.program.autowidget/resources/lib/common/router.py b/plugin.program.autowidget/resources/lib/common/router.py index 1b269012..8cc48314 100644 --- a/plugin.program.autowidget/resources/lib/common/router.py +++ b/plugin.program.autowidget/resources/lib/common/router.py @@ -10,6 +10,7 @@ from resources.lib import refresh from resources.lib.common import directory from resources.lib.common import utils +from resources.lib import common def _log_params(_plugin, _handle, _params): @@ -27,6 +28,9 @@ def _log_params(_plugin, _handle, _params): def dispatch(_plugin, _handle, _params): + common.dispatched_plugin = _plugin + common.dispatched_handle = _handle + params = _log_params(_plugin, int(_handle), _params) category = 'AutoWidget' is_dir = False diff --git a/plugin.program.autowidget/resources/lib/common/utils.py b/plugin.program.autowidget/resources/lib/common/utils.py index beaf0002..92cbe9ec 100644 --- a/plugin.program.autowidget/resources/lib/common/utils.py +++ b/plugin.program.autowidget/resources/lib/common/utils.py @@ -1,6 +1,6 @@ -from kodi_six import xbmc -from kodi_six import xbmcaddon -from kodi_six import xbmcgui +import xbmc +import xbmcaddon +import xbmcgui import codecs import contextlib diff --git a/plugin.program.autowidget/resources/lib/menu.py b/plugin.program.autowidget/resources/lib/menu.py index 43cdaf29..2d406f0c 100644 --- a/plugin.program.autowidget/resources/lib/menu.py +++ b/plugin.program.autowidget/resources/lib/menu.py @@ -1,4 +1,4 @@ -from kodi_six import xbmcgui +import xbmcgui import re import uuid @@ -313,7 +313,7 @@ def show_path(group_id, path_label, widget_id, path, idx=0, titles=None, num=1, directory.add_menu_item(title=title[0], path=file['file'], - art=file['art'], + art=file.get('art',{}), info=file, isFolder=file['filetype'] == 'directory', props=properties) diff --git a/plugin.program.autowidget/resources/settings.xml b/plugin.program.autowidget/resources/settings.xml index ffea9a60..056cca3b 100644 --- a/plugin.program.autowidget/resources/settings.xml +++ b/plugin.program.autowidget/resources/settings.xml @@ -37,9 +37,9 @@ - - - + + + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..83c41689 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +addopts = --doctest-modules +doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ELLIPSIS +python_files = test_*.py tests.py +testpaths = tests \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..d1da4f27 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +certifi==2020.4.5.1 +chardet==3.0.4 +idna==2.9 +requests==2.23.0 +urllib3==1.25.9 +pytz +polib~=1.1.0 +future~=0.17.1 +bs4 +pytest +vcrpy +pylint +coverage +flake8 +flake8-docstrings +parameterized +darglint; python_version>"3.5" +kodistubs==18.0.0; python_version<"3.7" +kodistubs==19.0.1; python_version>="3.7" +contextlib2; python_version<"3" +requests-mock +Mock +tzlocal +Pillow +pytest +pytest-cov +coveralls diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..97efd6b4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,134 @@ +import mock_kodi +import os +import threading +import xbmcgui +import xbmcplugin +import xbmcaddon +import xbmc +from mock_kodi import makedirs +import runpy +from urllib.parse import urlparse +import sys +import doctest +import time +import pytest +import tempfile +import shutil + + +@pytest.fixture +def service(): + # from ..plugin.program.autowidget.resources.lib import refresh # need to ensure loaded late due to caching addon path + # _monitor = refresh.RefreshService() + # _monitor.waitForAbort() + pass + +@pytest.fixture +def autowidget(): + mock_kodi.MOCK = mock_kodi.MockKodi() + mock_kodi.MOCK.SEREN_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),"../plugin.program.autowidget")) + mock_kodi.MOCK.PROFILE_ROOT = tempfile.mkdtemp() + # os.environ['SEREN_INTERACTIVE_MODE'] = 'True' + mock_kodi.MOCK.INTERACTIVE_MODE = True + _addon = xbmcaddon.Addon() + + # + # + # String.IsEqual(Window(10000).Property(context.autowidget),true) + # + mock_kodi.MOCK.DIRECTORY.register_contextmenu( + _addon.getLocalizedString(32003), + "plugin.program.autowidget", + "context_add", + lambda : True + ) + + # + # + # String.Contains(ListItem.FolderPath, plugin://plugin.program.autowidget) + # + mock_kodi.MOCK.DIRECTORY.register_contextmenu( + _addon.getLocalizedString(32006), + "plugin.program.autowidget", + "context_refresh", + lambda : True + ) + path = "plugin://plugin.program.autowidget" + mock_kodi.MOCK.DIRECTORY.register_action(path, "main") + yield path + # teardown after test + shutil.rmtree(mock_kodi.MOCK.PROFILE_ROOT) + +@pytest.fixture +def dummy(): + def dummy_folder(path): + for i in range(1,21): + p = "plugin://dummy/item{}".format(i) + xbmcplugin.addDirectoryItem( + handle=1, + url=p, + listitem=xbmcgui.ListItem("Dummy Item {}".format(i), path=p), + isFolder=False + ) + xbmcplugin.endOfDirectory(handle=1) + path = "plugin://dummy" + mock_kodi.MOCK.DIRECTORY.register_action(path, dummy_folder) + return path + + +@pytest.fixture +def dummy2(): + "Register changed contents under same path" + def dummy_folder(path): + for i in range(1, 21): + p = "plugin://dummy/item{}".format(i) + xbmcplugin.addDirectoryItem( + handle=1, + url=p, + listitem=xbmcgui.ListItem("Dummy2 Item {}".format(i), path=p), + isFolder=False + ) + xbmcplugin.endOfDirectory(handle=1) + path = "plugin://dummy" + mock_kodi.MOCK.DIRECTORY.register_action(path, dummy_folder) + return path + + +@pytest.fixture +def home_with_dummy(autowidget, dummy): + def home(path): + url="plugin://plugin.program.autowidget/" + xbmcplugin.addDirectoryItem( + handle=1, + url=autowidget, + listitem=xbmcgui.ListItem("AutoWidget", path=autowidget), + isFolder=True + ) + # add our fake plugin + xbmcplugin.addDirectoryItem( + handle=1, + url=dummy, + listitem=xbmcgui.ListItem("Dummy", path=dummy), + isFolder=True + ) + xbmcplugin.endOfDirectory(handle=1) + mock_kodi.MOCK.DIRECTORY.register_action("", home) + + +def press(keys): + for input in keys.split(" > "): + mock_kodi.MOCK.INPUT_QUEUE.put(input) + for _ in keys.split(" > "): + mock_kodi.MOCK.INPUT_QUEUE.join() # wait until the action got processed (ie until we wait for more input) + + +@pytest.fixture +def start_kodi(): + threading.Thread(target=mock_kodi.MOCK.DIRECTORY.handle_directory, daemon=True).start() + time.sleep(1) # give the home menu enough time to output + +@pytest.fixture +def start_kodi_with_service(start_kodi, service): + start_kodi() + t = threading.Thread(target=service, daemon=True).start() + time.sleep(1) # give the home menu enough time to output diff --git a/tests/mock_kodi/__init__.py b/tests/mock_kodi/__init__.py new file mode 100644 index 00000000..cdacec6f --- /dev/null +++ b/tests/mock_kodi/__init__.py @@ -0,0 +1,1344 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals, print_function + +import functools +import json +import os +import re +import shutil +import sys +import time +import types +import runpy +from urllib.parse import urlparse +import queue +import doctest + +import polib + +try: + WindowsError = WindowsError +except NameError: + WindowsError = Exception + + +try: + import xml.etree.cElementTree as ElementTree +except ImportError: + import xml.etree.ElementTree as ElementTree + +import xbmc +import xbmcaddon +import xbmcdrm +import xbmcgui +import xbmcplugin +import xbmcvfs + +PYTHON3 = True if sys.version_info.major == 3 else False +PYTHON2 = not PYTHON3 +SUPPORTED_LANGUAGES = { + "en-de": ("en-de", "eng-deu", "English-Central Europe"), + "en-aus": ("en-aus", "eng-aus", "English-Australia (12h)"), + "en-gb": ("en-gb", "eng-gbr", "English-UK (12h)"), + "en-us": ("en-us", "eng-usa", "English-USA (12h)"), + "de-de": ("de-de", "ger-deu", "German-Deutschland"), + "nl-nl": ("nl-nl", "dut-nld", "Dutch-NL"), +} + +PLUGIN_NAME = "plugin.program.autowidget" + +LOG_LEVEL = "LOGINFO" + +def get_input(prompt=""): + if MOCK.INPUT_QUEUE: + if MOCK.INPUT_QUEUE.unfinished_tasks: + MOCK.INPUT_QUEUE.task_done() + keys = str(MOCK.INPUT_QUEUE.get(True)) + #print(f"{prompt}{keys}") + return keys + else: + if PYTHON2: + return raw_input(prompt) # noqa + else: + return input(prompt) + + + +def makedirs(name, mode=0o777, exist_ok=False): + """makedirs(name [, mode=0o777][, exist_ok=False]) + Super-mkdir; create a leaf directory and all intermediate ones. Works like + mkdir, except that any intermediate path segment (not just the rightmost) + will be created if it does not exist. If the target directory already + exists, raise an OSError if exist_ok is False. Otherwise no exception is + raised. This is recursive. + :param name:Name of the directory to be created + :type name:str|unicode + :param mode:Unix file mode for created directories + :type mode:int + :param exist_ok:Boolean to indicate whether is should raise on an exception + :type exist_ok:bool + """ + try: + os.makedirs(name, mode, exist_ok) + except (OSError, FileExistsError, WindowsError): + if not exist_ok: + raise + +def pick_item(selected, items, start=0): + """ + >>> abc = ["A","B","C"] + >>> pick_item("1", abc) + 0 + >>> pick_item("B", abc) + 1 + >>> pick_item("blah", abc) is None + True + >>> pick_item("5", abc) is None + True + >>> pick_item("0", abc, -1) + -1 + """ + + try: + action = int(selected) - 1 + except: + action = next((i for i,item in enumerate(items, start) if str(item)==selected), None) + if action is None: + return None + if not(start <= action < len(items)-start): + return None + return action + + +class Directory: + """Directory class to keep track of items added to the virtual directory of the mock""" + + def __init__(self): + pass + + history = [] + items = [] + last_action = "" + next_action = "" + current_list_item = None + content = "movies" + sort_method = {} + action_callbacks = {} + context_callbacks = [] + + def handle_directory(self): + """ + :return: + :rtype: + """ + if not MOCK.INTERACTIVE_MODE: + return + + while True: + old_items = self.items + self.items = [] + self._execute_action() + if not self.items: # plugin didn't open a menu, keep on teh same one + self.items = old_items + else: + self.current_list_item = None + + while True: + + if self.next_action != self.last_action: + self.history.append(self.last_action) + self.last_action = self.next_action + + print("-------------------------------") + print("-1) Back") + print(" 0) Home") + print("-------------------------------") + for idx, item in enumerate(self.items): + print(" {}) {}".format(idx + 1, item[1])) + + print("-------------------------------") + print("Enter Action Number") + action = get_input() + if self._try_handle_menu_action(action): + break + elif self._try_handle_context_menu_action(action): + break + elif self._try_handle_action(action): + break + else: + print("Please enter a valid entry") + + def _try_handle_menu_action(self, selected): + action = pick_item(selected, ["Back", "Home"] + [str(i[1]) for i in self.items], -2) + if action is None: + return False + if action == -2: + if self.history: + self.next_action = self.history.pop(-1) + self.last_action = "" + self.current_list_item = None + return True + elif action == -1: + self.next_action = "" + self.current_list_item = None + return True + else: + self.next_action = self.items[action][0] + self.current_list_item = self.items[action][1] + return True + + def _try_handle_context_menu_action(self, action): + get_context_check = re.findall(r"^c(\d*)", action) + if len(get_context_check) == 1: + cur_item = self.items[int(get_context_check[0]) - 1] + self.current_list_item = cur_item[1] + items = [] + for context_item in cur_item[1].cm: + items.append( + (context_item[0], re.findall(r".*?\((.*?)\)", context_item[1])[0]) + ) + # Get contenxt menus from addons + for label, path, visible in self.context_callbacks: + # TODO: handle conditions + items.append( + (path, label) + ) + # Show context menu + for idx, item in enumerate(items): + print(" {}) {}".format(idx + 1, item[1])) + + action = get_input("Enter Context Menu: ") + action = pick_item(action, ["Back", "Cancel"]+[str(i[1]) for i in items], -2) + if action is None: + return False + self.next_action = items[action][0] + return True + + return True + return False + + def _try_handle_action(self, action): + if action.startswith("action"): + try: + self.next_action[2] = re.findall(r"action (.*?)$", action)[0] + return True + except: + print("Failed to parse action {}".format(action)) + return False + + def _execute_action(self, next_path=None): + if next_path is None: + next_path = self.next_action + #from resources.lib.modules.globals import g + + #g.init_globals(["", 0, self.next_action]) + for path, script in sorted(self.action_callbacks.items(), reverse=True): + if not next_path.startswith(path): + continue + if type(script) == type(""): + self._run_addon_script(script, next_path) + else: + script(next_path) + break + + def _run_addon_script(self, script, path): + argv = sys.argv + p = urlparse(path) + sys.argv = ["{}://{}".format(p.scheme, p.hostname), 1, "?"+p.query] + + sys.path.insert(0, MOCK.SEREN_ROOT) # This allows relative imports to work. There might be a better solution + #script = os.path.join(MOCK.SEREN_ROOT, script) + # TODO: should really use run_path and include full path seems to get different behaviour for some reason + # Context menus worked but main plugin didn't display the menu for some reason + runpy.run_module(script, run_name='__main__',) + sys.path.pop(0) + sys.argv = argv + + + def get_items_dictionary(self, path=None, properties=[]): + """ + :return: + :rtype: + """ + if path is not None: + old_items = self.items + self.items = [] + self._execute_action(path) + result = json.loads(json.dumps([i.toJSONRPC(properties) for _, i, _ in self.items], cls=JsonEncoder)) + if path is not None: + self.items = old_items + else: + self.items = [] + return result + + def executeInfoLabel(self, value): + # handle ListItem or Container infolabels + ListItem = self.current_list_item + Container = self + class Window: + @staticmethod + def getInfoLabel(key, *params): + params = [p for p in params if p] + a = getattr(Window, key) + if a: + return a(*params) + + @staticmethod + def IsActive(window): + if window == 'home': + return self.next_action == "" + raise Exception(f"Not handled Window.IsActive({window})") + + @staticmethod + def Property(prop): + if prop == 'xmlfile': + return '' + + @staticmethod + def IsMedia(): + return False + #value = value.replace(".", ".get") + v1 = re.sub(r"\.([^\d\W]\w*)\(([.\w]*)\)", r".getInfoLabel('\1','\2')", value) # ListItem.Art(banner) + v2 = re.sub(r"\.(?!getInfoLabel)([^\d\W]\w*)(?!\()", r".getInfoLabel('\1')", v1) # ListItem.Art + return eval(v2, locals()) + + @property + def ListItem(self): + return self.current_list_item + + def getInfoLabel(self, key): + if key == 'Content': + return self.content + elif key == 'ListItem': + return self.current_list_item + else: + raise Exception(f"Not found {key}") + + def register_action(self, path, script): + # TODO: read config from the addon.xml for actions and context menu + self.action_callbacks[path] = script + + def register_contextmenu(self, label, plugin, script, visible=None): + # TODO: read config from the addon.xml for actions and context menu + path = "plugin://{}/{}".format(plugin, label.replace(" ", "_")) # HACK: there is probably an offical way to encode + self.context_callbacks.append((label, path, visible)) + self.action_callbacks[path] = script + + +class SerenStubs: + @staticmethod + def create_stubs(): + """Returns the methods used in the new kodistubs monkey patcher + :return:Dictionary with the stub mapping + :rtype:dict + """ + return { + "xbmc": { + "getInfoLabel": SerenStubs.xbmc.getInfoLabel, + "translatePath": SerenStubs.xbmc.translatePath, + "log": SerenStubs.xbmc.log, + "getSupportedMedia": SerenStubs.xbmc.getSupportedMedia, + "getLanguage": SerenStubs.xbmc.getLanguage, + "getCondVisibility": SerenStubs.xbmc.getCondVisibility, + "executebuiltin": SerenStubs.xbmc.executebuiltin, + "executeJSONRPC": SerenStubs.xbmc.executeJSONRPC, + "PlayList": SerenStubs.xbmc.PlayList, + "Monitor": SerenStubs.xbmc.Monitor, + "validatePath": lambda t: t, + "sleep": lambda t: time.sleep(t / 1000), + }, + "xbmcaddon": {"Addon": SerenStubs.xbmcaddon.Addon}, + "xbmcgui": { + "ListItem": SerenStubs.xbmcgui.ListItem, + "Window": SerenStubs.xbmcgui.Window, + "Dialog": SerenStubs.xbmcgui.Dialog, + "DialogBusy": SerenStubs.xbmcgui.DialogBusy, + "DialogProgress": SerenStubs.xbmcgui.DialogProgress, + "DialogProgressBG": SerenStubs.xbmcgui.DialogProgressBG, + }, + "xbmcplugin": { + "addDirectoryItem": SerenStubs.xbmcplugin.addDirectoryItem, + "addDirectoryItems": SerenStubs.xbmcplugin.addDirectoryItems, + "endOfDirectory": SerenStubs.xbmcplugin.endOfDirectory, + "addSortMethod": SerenStubs.xbmcplugin.addSortMethod, + "setContent": SerenStubs.xbmcplugin.setContent, + "setPluginCategory": SerenStubs.xbmcplugin.setPluginCategory, + }, + "xbmcvfs": { + "File": SerenStubs.xbmcvfs.open, + "exists": os.path.exists, + "mkdir": os.mkdir, + "mkdirs": os.makedirs, + "rmdir": shutil.rmtree, + "validatePath": lambda t: t, + }, + } + + class xbmc: + """Placeholder for the xbmc stubs""" + + @staticmethod + def translatePath(path): + """Returns the translated path""" + valid_dirs = [ + "xbmc", + "home", + "temp", + "masterprofile", + "profile", + "subtitles", + "userdata", + "database", + "thumbnails", + "recordings", + "screenshots", + "musicplaylists", + "videoplaylists", + "cdrips", + "skin", + ] + + if not path.startswith("special://"): + return path + parts = path.split("/")[2:] + assert len(parts) > 1, "Need at least a single root directory" + + name = parts[0] + assert name in valid_dirs, "{} is not a valid root dir.".format(name) + + parts.pop(0) # remove name property + + dir_master = os.path.join(MOCK.PROFILE_ROOT, "userdata") + + makedirs(dir_master, exist_ok=True) + + if name == "xbmc": + return os.path.join(MOCK.XBMC_ROOT, *parts) + elif name in ("home", "logpath"): + if not MOCK.RUN_AGAINST_INSTALLATION and all( + x in parts for x in ["addons", PLUGIN_NAME] + ): + return MOCK.PROFILE_ROOT + return os.path.join(MOCK.PROFILE_ROOT, *parts) + elif name in ("masterprofile", "profile"): + return os.path.join(dir_master, *parts) + elif name == "database": + return os.path.join(dir_master, "Database", *parts) + elif name == "thumbnails": + return os.path.join(dir_master, "Thumbnails", *parts) + elif name == "musicplaylists": + return os.path.join(dir_master, "playlists", "music", *parts) + elif name == "videoplaylists": + return os.path.join(dir_master, "playlists", "video", *parts) + else: + import tempfile + + tempdir = os.path.join(tempfile.gettempdir(), "XBMC", name) + makedirs(tempdir, exist_ok=True) + return os.path.join(tempdir, *parts) + + @staticmethod + def getInfoLabel(value): + """Returns information about infolabels + :param value: + :type value: + :return: + :rtype: + """ + if value == "System.BuildVersion": + if PYTHON2: + return "18" + if PYTHON3: + return "19" + elif any(value.startswith(i) for i in ["ListItem.", "Container.", "Window."]): + res = MOCK.DIRECTORY.executeInfoLabel(value) + if res is not None: + return res + print(f"Couldn't find the infolabel: {value}") + return "" + + @staticmethod + def getCondVisibility(value): + if value == "Window.IsMedia": + return 0 + res = MOCK.DIRECTORY.executeInfoLabel(value) + if res is not None: + return res + print(f"Couldn't find condition: {value}") + + + + @staticmethod + def getSupportedMedia(media): + """Returns the supported file types for the specific media as a string""" + if media == "video": + return ( + ".m4v|.3g2|.3gp|.nsv|.tp|.ts|.ty|.strm|.pls|.rm|.rmvb|.mpd|.m3u|.m3u8|.ifo|.mov|.qt|.divx|.xvid|.bivx|.vob|.nrg|.img|.iso|.pva|.wmv" + "|.asf|.asx|.ogm|.m2v|.avi|.bin|.dat|.mpg|.mpeg|.mp4|.mkv|.mk3d|.avc|.vp3|.svq3|.nuv|.viv|.dv|.fli|.flv|.rar|.001|.wpl|.zip|.vdr|.dvr" + "-ms|.xsp|.mts|.m2t|.m2ts|.evo|.ogv|.sdp|.avs|.rec|.url|.pxml|.vc1|.h264|.rcv|.rss|.mpls|.webm|.bdmv|.wtv|.pvr|.disc " + ) + elif media == "music": + return ( + ".nsv|.m4a|.flac|.aac|.strm|.pls|.rm|.rma|.mpa|.wav|.wma|.ogg|.mp3|.mp2|.m3u|.gdm|.imf|.m15|.sfx|.uni|.ac3|.dts|.cue|.aif|.aiff|.wpl" + "|.ape|.mac|.mpc|.mp+|.mpp|.shn|.zip|.rar|.wv|.dsp|.xsp|.xwav|.waa|.wvs|.wam|.gcm|.idsp|.mpdsp|.mss|.spt|.rsd|.sap|.cmc|.cmr|.dmc|.mpt" + "|.mpd|.rmt|.tmc|.tm8|.tm2|.oga|.url|.pxml|.tta|.rss|.wtv|.mka|.tak|.opus|.dff|.dsf|.cdda " + ) + elif media == "picture": + return ".png|.jpg|.jpeg|.bmp|.gif|.ico|.tif|.tiff|.tga|.pcx|.cbz|.zip|.cbr|.rar|.rss|.webp|.jp2|.apng" + return "" + + @staticmethod + def log(msg, level=xbmc.LOGDEBUG): + """Write a string to XBMC's log file and the debug window""" + if PYTHON2: + levels = [ + "LOGDEBUG", + "LOGINFO", + "LOGNOTICE", + "LOGWARNING", + "LOGERROR", + "LOGSEVERE", + "LOGFATAL", + "LOGNONE", + ] + else: + levels = [ + "LOGDEBUG", + "LOGINFO", + "LOGWARNING", + "LOGERROR", + "LOGSEVERE", + "LOGFATAL", + "LOGNONE", + ] + value = "{} - {}".format(levels[level], msg) + if levels.index(LOG_LEVEL) <= level: + print(value) + MOCK.LOG_HISTORY.append(value) + + @staticmethod + def getLanguage(format=xbmc.ENGLISH_NAME, region=False): + """Returns the active language as a string.""" + result = SUPPORTED_LANGUAGES.get(MOCK.KODI_UI_LANGUAGE, ())[format] + if region: + return result + else: + return result.split("-")[0] + + @staticmethod + def executebuiltin(function, wait=False): + """Execute a built in Kodi function""" + print("EXECUTE BUILTIN: {} wait:{}".format(function, wait)) + + @staticmethod + def executeJSONRPC(jsonrpccommand): + command = json.loads(jsonrpccommand) + method = command.get("method") + if method == 'JSONRPC.Version': + res = dict(result=dict(version=dict(major=19,minor=0,patch=0))) + elif method == "Files.GetDirectory": + path = command['params']['directory'] + props = command['params'].get('properties',[]) + files = MOCK.DIRECTORY.get_items_dictionary(path, properties=props) + res = dict(result=dict(files=files)) + else: + raise Exception(f"executeJSONRPC not handled for {method}") + return json.dumps(res) + + + class PlayList(xbmc.PlayList): + def __init__(self, playList): + self.list = [] + + def add(self, url, listitem=None, index=-1): + self.list.append([url, listitem]) + + def getposition(self): + return 0 + + def clear(self): + self.list.clear() + + def size(self): + return len(self.list) + + class Monitor: + def __init__(self, *args, **kwargs): + pass + + def abortRequested(self): + return False + + def waitForAbort(self, timeout=0): + time.sleep(timeout) + return True + + def onSettingsChanged(self): + pass + + class xbmcaddon: + class Addon(xbmcaddon.Addon): + def __init__(self, addon_id=None): + self._id = addon_id + self._config = {} + self._strings = {} + self._current_user_settings = {} + + def _load_addon_config(self): + # Parse the addon config + try: + filepath = os.path.join(MOCK.SEREN_ROOT, "addon.xml") + xml = ElementTree.parse(filepath) + self._config = xml.getroot() + self._id = self.getAddonInfo("id") or self._id + except ElementTree.ParseError: + pass + except IOError: + pass + + def _load_language_string(self): + only_digits = re.compile(r"\D") + + langfile = self.get_po_location( + xbmc.getLanguage( + format=xbmc.ISO_639_1, + region=True) + ) + if os.path.exists(langfile): + po = polib.pofile(langfile) + else: + po = polib.pofile(self.get_po_location("en-gb")) + self._strings = { + int(only_digits.sub("", entry.msgctxt)): entry.msgstr + if entry.msgstr + else entry.msgid + for entry in po + } + + def get_po_location(self, language): + langfile = os.path.join( + MOCK.SEREN_ROOT, + "resources", + "language", + "resource.language.{}".format(language).replace("-", "_"), + "strings.po", + ) + return langfile + + def _load_user_settings(self): + current_settings_file = os.path.join( + os.path.join( + MOCK.PROFILE_ROOT, + "userdata", + "addon_data", + PLUGIN_NAME, + "settings.xml", + ) + ) + if not os.path.exists(current_settings_file): + self._init_user_settings() + return + xml = ElementTree.parse(current_settings_file) + settings = xml.findall("./setting") + for node in settings: + setting_id = node.get("id") + setting_value = node.text + item = {"id": setting_id} + if setting_value: + item["value"] = setting_value + self._current_user_settings.update({setting_id: item}) + + def _init_user_settings(self): + settings_def_file = os.path.join( + os.path.join( + MOCK.SEREN_ROOT, + "resources", + "settings.xml", + ) + ) + addon_dir = os.path.join( + os.path.join( + MOCK.PROFILE_ROOT, + "userdata", + "addon_data", + PLUGIN_NAME, + ) + ) + makedirs(addon_dir, exist_ok=True) + current_settings_file = os.path.join( + os.path.join( + MOCK.PROFILE_ROOT, + "userdata", + "addon_data", + PLUGIN_NAME, + "settings.xml", + ) + ) + xml = ElementTree.parse(settings_def_file) + settings = xml.findall("./category/setting") + for node in settings: + setting_id = node.get("id") + setting_value = node.get("default") + item = {"id": setting_id} + if setting_value: + item["value"] = setting_value + self._current_user_settings.update({setting_id: item}) + # TODO: write into current_settings_file + + + def getAddonInfo(self, key): + if not self._config: + self._load_addon_config() + + properties = [ + "author", + "changelog", + "description", + "disclaimer", + "fanart", + "icon", + "id", + "name", + "path", + "profile", + "stars", + "summary", + "type", + "version", + ] + if key not in properties: + raise ValueError("{} is not a valid property.".format(key)) + if key == "profile": + return "special://profile/addon_data/{0}/".format(self._id) + if key == "path": + return "special://home/addons/{0}".format(self._id) + if self._config and key in self._config.attrib: + return self._config.attrib[key] + return None + + def getLocalizedString(self, key): + if not self._strings: + self._load_language_string() + + if key in self._strings: + return kodi_to_ansi(self._strings[key]) + print("Cannot find localized string {}".format(key)) + return None + + def getSetting(self, key): + if not self._current_user_settings: + self._load_user_settings() + if key in self._current_user_settings: + return self._current_user_settings[key].get("value") + return None + + def setSetting(self, key, value): + if not self._current_user_settings: + self._load_user_settings() + self._current_user_settings.update({key: {"value": str(value)}}) + + class xbmcplugin: + @staticmethod + def addDirectoryItem(handle, url, listitem, isFolder=False, totalItems=0): + listitem.is_folder = isFolder + MOCK.DIRECTORY.items.append((url, listitem, isFolder)) + + @staticmethod + def addDirectoryItems(handle, items, totalItems=0): + MOCK.DIRECTORY.items.extend(items) + + @staticmethod + def endOfDirectory( + handle, succeeded=True, updateListing=False, cacheToDisc=True + ): + #MOCK.DIRECTORY.handle_directory() + pass + + @staticmethod + def setContent(handle, content): + MOCK.DIRECTORY.content = content + + @staticmethod + def setPluginCategory(handle, category): + MOCK.DIRECTORY.content = category + + + @staticmethod + def addSortMethod(handle, sortMethod, label2Mask=""): + MOCK.DIRECTORY.sort_method = sortMethod + + class xbmcgui: + class ListItem(xbmcgui.ListItem): + def __init__( + self, + label="", + label2="", + iconImage="", + thumbnailImage="", + path="", + offscreen=False, + ): + self.contentLookup = None + self._label = label + self._label2 = label2 + self._icon = iconImage + self._thumb = thumbnailImage + self._path = path + self._offscreen = offscreen + self._props = {} + self._selected = False + self.cm = [] + self.vitags = {} + self.art = {} + self.votes = {} + self.info = {} + self.info_type = "" + self.uniqueIDs = {} + self.ratings = {} + self.contentLookup = True + self.stream_info = {} + self.is_folder = False + self.mimeType = '' + + def addContextMenuItems(self, items, replaceItems=False): + [self.cm.append(i) for i in items] + + def getLabel(self): + return self._label + + def getLabel2(self): + return self._label2 + + def getProperty(self, key): + key = key.lower() + if key in self._props: + return self._props[key] + return "" + + def getRating(self, key=None): #make key optional for getInfoLabel + return self.ratings.get(key, 0.0) if key else 0.0 + + def getArt(self, key='thumb'): + return self.art.get(key, "") + + def getPath(self): + return self._path + + def getVotes(self, key=None): + if key is None: + return self.votes.values[0] if self.votes else 0 + else: + return self.votes.get(key, 0) + + def isSelected(self): + return self._selected + + def select(self, selected): + self._selected = selected + + def setArt(self, values): + if not values: + return + self.art.update(values) + + def setIconImage(self, value): + self._icon = value + + def setInfo(self, type, infoLabels): + if type: + self.info_type = type + if isinstance(infoLabels, dict): + self.info.update(infoLabels) + + def setLabel(self, label): + self._label = label + + def setLabel2(self, label): + self._label2 = label + + def setProperty(self, key, value): + key = key.lower() + self._props[key] = value + + def setProperties(self, properties): + for key, value in properties.items(): + self.setProperty(key, value) + + def setThumbnailImage(self, value): + self._thumb = value + + def setCast(self, actors): + """Set cast including thumbnails. Added in v17.0""" + pass + + def setUniqueIDs(self, ids, **kwargs): + self.uniqueIDs.update(ids) + + def setRating(self, rating_type, rating, votes=0, default=False): + self.ratings.update({rating_type: [rating, votes, default]}) + + def setContentLookup(self, enable): + self.contentLookup = enable + + def addStreamInfo(self, cType, dictionary): + self.stream_info.update({cType: dictionary}) + + def setMimeType(self, mimetype): + self._props['MimeType'] = mimetype + + def __str__(self): + return self._label + + # Additional methods for infolabels + + def getInfoLabel(self, key, *params): + # return value or function + if hasattr(self, 'get'+key): + return getattr(self, 'get'+key)(*params) + if key in self.info: + return self.info[key] + else: + return "" # HACK + + def getFolderPath(self): + return self._path + + def getIsFolder(self): + return self.is_folder + + def toJSONRPC(self, properties=[]): + item = dict( + filetype='direcotry' if self.is_folder else 'file', + title=self._label, + type="unknown", + file=self._path, + label=self._label, + art=self.art, + mimetype=self._props.get('MimeType','') + ) + item.update({k:v for k,v in self.info.items() if k in properties}) + return item + + class Window(xbmcgui.Window): + def __init__(self, windowId=0): + self._props = {} + + def clearProperties(self): + self._props.clear() + + def clearProperty(self, key): + key = key.lower() + if key in self._props: + del self._props[key] + + def getProperty(self, key): + key = key.lower() + if key in self._props: + return self._props[key] + return None + + def setProperty(self, key, value): + key = key.lower() + self._props[key] = value + + class Dialog(xbmcgui.Dialog): + def notification( + self, + heading, + message, + icon=xbmcgui.NOTIFICATION_INFO, + time=5000, + sound=True, + ): + if icon == xbmcgui.NOTIFICATION_WARNING: + prefix = "[WARNING]" + elif icon == xbmcgui.NOTIFICATION_ERROR: + prefix = "[ERROR]" + else: + prefix = "[INFO]" + print("NOTIFICATION: {0} {1}: {2}".format(prefix, heading, message)) + + def ok(self, heading, message): + print("{}: \n{}".format(heading, message)) + return True + + def select( + self, heading, list, autoclose=False, preselect=None, useDetails=False + ): + print(heading) + action = None + for idx, i in enumerate(list): + print("{}) {}".format(idx, i)) + while action is None: + action = pick_item(get_input(), ["Back", "Cancel"] + list, -2) + if action < 0: + return -1 + return action + + def textviewer(self, heading, text, usemono=False): + print(heading) + print(text) + + def yesno( + self, + heading, + message, + nolabel="", + yeslabel="", + customlabel="", + autoclose=0, + ): + if not MOCK.INTERACTIVE_MODE: + return 1 + print("") + print("{}\n{}".format(heading, message)) + print("1) {}/ 0) {}".format(yeslabel, nolabel)) + action = get_input() + return action + + def input( + self, + heading, + defaultt="", + type=xbmcgui.INPUT_ALPHANUM, + option=None, + autoclose=None + ): + print(heading) + while True: + if type==xbmcgui.INPUT_ALPHANUM: + return get_input("Enter AlphaNum:") + elif type==xbmcgui.INPUT_NUMERIC: + try: + return float(get_input("Enter Number:")) + except: + pass + elif type==xbmcgui.INPUT_DATE: + date = get_input("Enter Date (DD/MM/YYYY):") + #TODO: + return date + elif type==xbmcgui.INPUT_TIME: + time = get_input("Enter Time (HH:MM):") + #TODO: + return time + elif type==xbmcgui.INPUT_IPADDRESS: + ip = get_input("Enter IP Address (#.#.#.#):") + #TODO: + return ip + elif type == xbmcgui.INPUT_PASSWORD: + return get_input("Enter Password:") + # TODO: needs to be md5 hashed, and optionally verified + + + + class DialogBusy: + """Show/Hide the progress indicator. Added in v17.0""" + + def create(self): + print("[BUSY] show") + + def update(self, percent): + print("[BUSY] update: {0}".format(percent)) + + def close(self): + print("[BUSY] close") + + def iscanceled(self): + return False + + class DialogProgress(xbmcgui.DialogProgress): + canceled = False + + def __init__(self): + self._created = False + self._heading = None + self._message = None + self._percent = -1 + + def update(self, percent, message=""): + if percent: + self._percent = percent + if message: + self._message = message + print( + "[PROGRESS] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def create(self, heading, message=""): + self._created = True + self._heading = heading + self._message = message + self._percent = 0 + print( + "[PROGRESS] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def iscanceled(self): + return self.canceled + + def close(self): + print("[PROGRESS] closing") + + class DialogProgressBG(xbmcgui.DialogProgressBG): + def __init__(self): + self._created = False + self._heading = "" + self._message = "" + self._percent = 0 + + def create(self, heading, message=""): + self._created = True + self._heading = heading + self._message = message + self._percent = 0 + print( + "[BACKGROUND] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def close(self): + self._created = False + print("[BACKGROUND] closing") + + def update(self, percent=0, heading="", message=""): + self._percent = percent + if heading: + self._heading = heading + if message: + self._message = message + print( + "[BACKGROUND] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def isFinished(self): + return not self._created + + class xbmcvfs: + @staticmethod + def open(filepath, mode="r"): + if sys.version_info.major == 3: + return open(filepath, mode, encoding="utf-8") + else: + return open(filepath, mode) + + +class MonkeyPatchKodiStub: + """Helper class for Monkey patching kodistubs to add functionality.""" + + def __init__(self): + self._dict = SerenStubs.create_stubs() + + def trace_log(self): + self._walk_kodi_dependencies(self._trace_log_decorator) + + def monkey_patch(self): + self._walk_kodi_dependencies(self._monkey_patch) + + def _walk_kodi_dependencies(self, func): + [ + self._walk_item(i, func) + for i in [xbmc, xbmcgui, xbmcaddon, xbmcdrm, xbmcplugin, xbmcvfs] + ] + + def _walk_item(self, item, func, path=None): + if path is None: + path = [] + path.append(item.__name__) + for k, v in vars(item).items(): + if isinstance(v, (types.FunctionType, staticmethod)): + result = func(path, v) + if result: + setattr(item, k, result) + if type(v) is type: + result = func(path, v) + if result: + setattr(item, k, result) + else: + self._walk_item(v, func, path) + path.pop(-1) + + @staticmethod + def _trace_log_decorator(path, func): + """Add trace logging to the function it decorates. + :param func: Function to decorate + :type func: types.FunctionType + :return: Wrapped function + :rtype: types.FunctionType + """ + joined_path = ".".join(path) + + @functools.wraps(func) + def _wrapped(*args, **kwargs): + try: + if args: + print( + "Entering: {}.{} with parameters {}".format( + joined_path, func.__name__, args + ) + ) + else: + print("Entering: {}.{}".format(joined_path, func.__name__)) + try: + return func(*args, **kwargs) + except Exception as e: + print( + "Exception in {}.{} : {}".format(joined_path, func.__name__, e) + ) + raise e + finally: + print("Exiting: {}.{}".format(joined_path, func.__name__)) + + return _wrapped + + @staticmethod + def _decorate(func, patch): + @functools.wraps(func) + def _wrapped(*args, **kwargs): + return patch(func(*args, **kwargs)) + + return _wrapped + + def _monkey_patch(self, path, item): + patch = None + for p in path: + if patch: + patch = patch.get(p, {}) + else: + patch = self._dict.get(p, {}) + patch = patch.get(item.__name__) + if patch: + return patch + elif isinstance(item, types.FunctionType): + return self._log_not_patched_method(path, item) + + @staticmethod + def _log_not_patched_method(path, func): + """Add logging to the function that indicates that there is not mockey patch available. + :param path: path of the calling method + :type path: list[string] + :param func: Function to decorate + :type func: types.FunctionType + :return: Wrapped function + :rtype: types.FunctionType + """ + joined_path = ".".join(path) + + @functools.wraps(func) + def _wrapped(*args, **kwargs): + object_type = "method" if isinstance(func, types.FunctionType) else "object" + print( + "Call to not patched {}: {}.{}".format( + object_type, joined_path, func.__name__ + ) + ) + return func(*args, **kwargs) + + return _wrapped + + +class MockKodi: + """KODIStub mock helper""" + + def __init__(self): + here = os.path.abspath(os.path.join(os.path.dirname(__file__))) + self.XBMC_ROOT = here + #self.XBMC_ROOT = os.environ.get("KODI_ROOT", self.XBMC_ROOT) + self.PROFILE_ROOT = os.path.abspath(os.path.join(self.XBMC_ROOT, "../")) + self.PROFILE_ROOT = os.environ.get("KODI_PROFILE_ROOT", self.PROFILE_ROOT) + self.SEREN_ROOT = os.path.abspath(os.path.join(here, "../")) + self.KODI_UI_LANGUAGE = os.environ.get("KODI_UI_LANGUAGE", "en-gb") + self.INTERACTIVE_MODE = ( + os.environ.get("SEREN_INTERACTIVE_MODE", False) == "True" + ) + self.RUN_AGAINST_INSTALLATION = ( + os.environ.get("SEREN_RUN_AGAINST_INSTALLATION", False) == "True" + ) + if self.RUN_AGAINST_INSTALLATION and os.path.exists( + self.get_kodi_installation() + ): + self.PROFILE_ROOT = self.get_kodi_installation() + self.SEREN_ROOT = os.path.join( + self.PROFILE_ROOT, "addons", PLUGIN_NAME + ) + + self.DIRECTORY = Directory() + self.LOG_HISTORY = [] + self.INPUT_QUEUE = queue.Queue() + self._monkey_patcher = MonkeyPatchKodiStub() + # self._monkey_patcher.trace_log() + self._monkey_patcher.monkey_patch() + + @staticmethod + def get_kodi_installation(): + """ + :return: + :rtype: + """ + dir_home = os.path.expanduser("~") + if sys.platform == "win32": + return os.path.join(dir_home, "AppData", "Roaming", "Kodi") + return os.path.join(dir_home, ".kodi") + + +MOCK = MockKodi() + + +class JsonEncoder(json.JSONEncoder): + """Json encoder for serialising all objects""" + + def default(self, o): + """ + :param o: + :type o: + :return: + :rtype: + """ + return o.__dict__ + + +def kodi_to_ansi(string): + """ + :param string: + :type string: + :return: + :rtype: + """ + if string is None: + return None + string = string.replace("[B]", "\033[1m") + string = string.replace("[/B]", "\033[21m") + string = string.replace("[I]", "\033[3m") + string = string.replace("[/I]", "\033[23m") + string = string.replace("[COLOR gray]", "\033[30;1m") + string = string.replace("[COLOR red]", "\033[31m") + string = string.replace("[COLOR green]", "\033[32m") + string = string.replace("[COLOR yellow]", "\033[33m") + string = string.replace("[COLOR blue]", "\033[34m") + string = string.replace("[COLOR purple]", "\033[35m") + string = string.replace("[COLOR cyan]", "\033[36m") + string = string.replace("[COLOR white]", "\033[37m") + string = string.replace("[/COLOR]", "\033[39;0m") + return string + + +class MockKodiUILanguage(object): + def __init__(self, new_language): + self.new_language = new_language + self.original_language = MOCK.KODI_UI_LANGUAGE + + def __enter__(self): + MOCK.KODI_UI_LANGUAGE = self.new_language + return self.new_language + + def __exit__(self, exc_type, exc_val, exc_tb): + MOCK.KODI_UI_LANGUAGE = self.original_language + +if __name__ == '__main__': + doctest.testmod() diff --git a/tests/test_caching.py b/tests/test_caching.py new file mode 100644 index 00000000..4b96f232 --- /dev/null +++ b/tests/test_caching.py @@ -0,0 +1,171 @@ +from conftest import press + +def test_add_widget_cycling(): + """ + >>> getfixture("home_with_dummy") # TODO: + >>> getfixture("start_kodi") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + ------------------------------- + Enter Action Number + + >>> press("c2") + 1) Add to AutoWidget Group + + >>> press("Add to AutoWidget Group") + Add as + 0) Shortcut + 1) Widget + 2) Clone as Shortcut Group + 3) Explode as Widget Group + + >>> press("Widget") + Choose a Group + 0) Create New Widget Group + + >>> press("Create New Widget Group") + Name for Group + + >>> press("Widget1") + Choose a Group + 0) Create New Widget Group + 1) Widget1 + + >>> press("Widget1") + Widget Label + + >>> press("My Label") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + ------------------------------- + Enter Action Number + + >>> press("AutoWidget") + LOGINFO - plugin.program.autowidget: [ root ] + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) My Groups + 2) Active Widgets + 3) Tools + ------------------------------- + Enter Action Number + + >>> press("My Groups") + LOGINFO - plugin.program.autowidget: [ mode: group ] + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) Widget1 + ------------------------------- + Enter Action Number + + >>> press("Widget1") + LOGINFO - plugin.program.autowidget: [ mode: group ][ group: widget1-... ] + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) My Label + 2) Widget1 (Static) + 3) Widget1 (Cycling) + 4) Widget1 (Merged) + ------------------------------- + Enter Action Number + + >>> press("Widget1 (Cycling)") + LOGINFO - plugin.program.autowidget: [ mode: path ][ action: cycling ][ group: widget1-... ] + Choose an Action + 0) Random Path + 1) Next Path + + >>> press("Next Path") + LOGINFO - plugin.program.autowidget: Empty cache 0B (exp:-1 day, ... + LOGINFO - plugin.program.autowidget: Blocking cache path read: ... + LOGINFO - plugin.program.autowidget: Wrote cache ... + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) Dummy Item 1 + 2) Dummy Item 2 + 3) Dummy Item 3 + 4) Dummy Item 4 + ... + 20) Dummy Item 20 + ------------------------------- + Enter Action Number + + """ + + +def test_add_widget_merged(): + """ + >>> getfixture("home_with_dummy") # TODO: + >>> getfixture("start_kodi") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + ------------------------------- + Enter Action Number + """ + + +def test_cache_widget(): + """ + Add in a widget group + >>> getfixture("home_with_dummy") + >>> getfixture("start_kodi") + ---... + ... + >>> press("c2 > Add to AutoWidget Group > Widget > Create New Widget Group > Widget1" + ... " > Widget1 > My Label") + 1)... + ... + + Access it the first time it will get cached + >>> press("Home > AutoWidget > My Groups > Widget1 > Widget1 (Cycling) > Next Path") + ---... + ... + 1) Dummy Item 1 + ... + + Now change the connent of dummy menu + + >>> _ = getfixture("dummy2") + >>> press("Home > Dummy") + ---... + ... + 1) Dummy2 Item 1 + ... + + But our widget is still cached + + >>> press("Home > AutoWidget > My Groups > Widget1 > Widget1 (Cycling) > Next Path") + ---... + ... + 1) Dummy Item 1 + ... + + """ + + +# if __name__ == '__main__': +# autowidget() +# dummy() +# home_with_dummy() +# doctest.testmod(optionflags=doctest.ELLIPSIS|doctest.REPORT_NDIFF|doctest.REPORT_ONLY_FIRST_FAILURE) +# #teardown()