diff --git a/.github/workflows/ovoscope.yml b/.github/workflows/ovoscope.yml new file mode 100644 index 0000000..01f77ba --- /dev/null +++ b/.github/workflows/ovoscope.yml @@ -0,0 +1,21 @@ +name: Ovoscope End-to-End Tests + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + ovoscope: + uses: OpenVoiceOS/gh-automations/.github/workflows/ovoscope.yml@dev + secrets: inherit + with: + runner: "ubuntu-latest" + python_version: "3.11" + install_extras: "test" + test_path: "test/end2end/" + bus_coverage: true + bus_coverage_exclude: "^Thread-|^intents$|^skills$|^__core__$" + pr_comment: true diff --git a/ovos_gui/namespace.py b/ovos_gui/namespace.py index 7f0666a..af79f7f 100644 --- a/ovos_gui/namespace.py +++ b/ovos_gui/namespace.py @@ -40,7 +40,7 @@ over the GUI message bus. """ import shutil -from os.path import join, dirname, exists +from os.path import join, dirname from threading import Lock, Timer from typing import List, Union, Optional, Dict @@ -57,11 +57,36 @@ ) from ovos_gui.constants import GUI_CACHE_PATH from ovos_gui.page import GuiPage +from ovos_gui.templates import ( + is_system_template, + normalize_template, + resolve_render_name, +) namespace_lock = Lock() RESERVED_KEYS = ['__from', '__idle'] +#: OVOS-SESSION-1 reserved session_id for an absent/empty session and the +#: on-device display. +DEFAULT_SESSION_ID = "default" + + +def _read_session_id(message: Optional[Message]) -> str: + """Extract the routing ``session_id`` from a Message (OVOS-GUI-1 §5.1). + + A GUI Message is routed solely by the ``session_id`` in its + ``context.session``. An absent or empty session defaults to the + reserved value ``"default"`` (OVOS-SESSION-1 §3.1). + + @param message: the incoming Message (may be None) + @return: the resolved session_id + """ + if message is None: + return DEFAULT_SESSION_ID + session = (message.context or {}).get("session") or {} + return session.get("session_id") or DEFAULT_SESSION_ID + def _validate_page_message(message: Message) -> bool: """ @@ -408,32 +433,97 @@ def global_back(self): self.page_gained_focus(self.page_number - 1) +class GUISession: + """Per-session GUI display state (OVOS-GUI-1 §4.3 / §5.1). + + Each ``session_id`` owns an independent namespace stack so that two + sessions cannot collide. Clients that share a ``session_id`` (e.g. a + multi-room screen group) share one of these. The on-device display + uses the reserved ``"default"`` session. + + Attributes: + session_id: the routing key this state belongs to + loaded_namespaces: cache of namespaces introduced in this session + active_namespaces: LIFO stack of namespaces displayed in this session + remove_namespace_timers: per-session auto-removal timers + """ + + def __init__(self, session_id: str = DEFAULT_SESSION_ID): + self.session_id = session_id + self.loaded_namespaces: Dict[str, Namespace] = dict() + self.active_namespaces: List[Namespace] = list() + self.remove_namespace_timers: Dict[str, Timer] = dict() + + class NamespaceManager: """ Manages the active namespace stack and the content of namespaces. + State is partitioned per ``session_id`` (OVOS-GUI-1 §4.3 / §5.1): each + session owns an independent namespace stack via a :class:`GUISession`. + The on-device display uses the reserved ``"default"`` session, and the + ``loaded_namespaces`` / ``active_namespaces`` / ``remove_namespace_timers`` + attributes proxy to that default session for the legacy single-screen + QML render path. + Attributes: core_bus: client for communicating with the core message bus gui_bus: client for communicating with the GUI message bus - loaded_namespaces: cache of namespaces that have been introduced - active_namespaces: LIFO stack of namespaces being displayed - remove_namespace_timers: background process to remove a namespace with - a persistence expressed in seconds + sessions: per-session display state keyed by session_id idle_display_skill: skill ID of the skill that controls the idle screen """ def __init__(self, core_bus: MessageBusClient): self.core_bus = core_bus self.gui_bus = create_gui_service(self) - self.loaded_namespaces: Dict[str, Namespace] = dict() - self.active_namespaces: List[Namespace] = list() - self.remove_namespace_timers: Dict[str, Timer] = dict() + self.sessions: Dict[str, GUISession] = { + DEFAULT_SESSION_ID: GUISession(DEFAULT_SESSION_ID) + } self.idle_display_skill = _get_idle_display_config() self.active_extension = _get_active_gui_extension() self._system_res_dir = join(dirname(__file__), "res", "gui") self._init_gui_file_share() self._define_message_handlers() + def get_session(self, session_id: str = DEFAULT_SESSION_ID) -> GUISession: + """Return the state for ``session_id``, creating it on first use. + + @param session_id: routing key (OVOS-GUI-1 §5.1) + @return: the GUISession for that key + """ + if session_id not in self.sessions: + LOG.debug(f"Creating GUI session: {session_id}") + self.sessions[session_id] = GUISession(session_id) + return self.sessions[session_id] + + # --- legacy single-screen proxies ----------------------------------- + # The legacy QML/WebSocket transport is single-screen and synchronizes + # off these attributes; they map to the reserved "default" session so + # existing render backends keep working unchanged. + @property + def loaded_namespaces(self) -> Dict[str, Namespace]: + return self.sessions[DEFAULT_SESSION_ID].loaded_namespaces + + @loaded_namespaces.setter + def loaded_namespaces(self, value: Dict[str, Namespace]): + self.sessions[DEFAULT_SESSION_ID].loaded_namespaces = value + + @property + def active_namespaces(self) -> List[Namespace]: + return self.sessions[DEFAULT_SESSION_ID].active_namespaces + + @active_namespaces.setter + def active_namespaces(self, value: List[Namespace]): + self.sessions[DEFAULT_SESSION_ID].active_namespaces = value + + @property + def remove_namespace_timers(self) -> Dict[str, Timer]: + return self.sessions[DEFAULT_SESSION_ID].remove_namespace_timers + + @remove_namespace_timers.setter + def remove_namespace_timers(self, value: Dict[str, Timer]): + self.sessions[DEFAULT_SESSION_ID].remove_namespace_timers = value + def _init_gui_file_share(self): """ Initialize optional GUI file collection. if `gui_file_path` is @@ -540,9 +630,10 @@ def handle_clear_namespace(self, message: Message): "Request to delete namespace failed: no namespace specified" ) else: - if self.loaded_namespaces.get(namespace_name): + session = self.get_session(_read_session_id(message)) + if session.loaded_namespaces.get(namespace_name): with namespace_lock: - self._remove_namespace(namespace_name) + self._remove_namespace(namespace_name, session) @staticmethod def handle_send_event(message: Message): @@ -578,11 +669,12 @@ def handle_delete_all_pages(self, message: Message): else: LOG.info(f"Got {namespace_name} request to delete all pages") + session = self.get_session(_read_session_id(message)) with namespace_lock: - namespace = self.loaded_namespaces.get(namespace_name) + namespace = session.loaded_namespaces.get(namespace_name) if namespace: to_rm = [p.name for p in namespace.pages if p.name not in except_pages] - self._remove_pages(namespace_name, to_rm) + self._remove_pages(namespace_name, to_rm, session) def handle_delete_page(self, message: Message): """ @@ -594,18 +686,21 @@ def handle_delete_page(self, message: Message): namespace_name = message.data["__from"] pages_to_remove = message.data.get("page_names") LOG.debug(f"Got {namespace_name} request to delete: {pages_to_remove}") + session = self.get_session(_read_session_id(message)) with namespace_lock: - self._remove_pages(namespace_name, pages_to_remove) + self._remove_pages(namespace_name, pages_to_remove, session) - def _remove_pages(self, namespace_name: str, pages_to_remove: List[str]): + def _remove_pages(self, namespace_name: str, pages_to_remove: List[str], + session: GUISession): """ Removes one or more pages from a namespace. Pages are removed from the bottom of the stack. @param namespace_name: the affected namespace @param pages_to_remove: names of pages to delete + @param session: the session whose stack is affected """ - namespace = self.loaded_namespaces.get(namespace_name) - if namespace is not None and namespace in self.active_namespaces: + namespace = session.loaded_namespaces.get(namespace_name) + if namespace is not None and namespace in session.active_namespaces: page_positions = [] for index, page in enumerate(namespace.pages): if page.name in pages_to_remove: @@ -647,16 +742,42 @@ def handle_show_page(self, message: Message): namespace_name = message.data["__from"] page_ids_to_show = message.data.get('page_names') - persistence = message.data["__idle"] + # OVOS-GUI-1 §3.3/§4.3 - producers may omit __idle entirely; an + # absent key means "use the namespace default", not an error. + persistence = message.data.get("__idle") show_index = message.data.get("index", 0) + session = self.get_session(_read_session_id(message)) LOG.debug(f"Got {namespace_name} request to show: {page_ids_to_show} at index: {show_index}") + # OVOS-GUI-1 §3.2/§4.2/§8.3 - the SYSTEM_ prefix is the discriminator + # for a conformant template intent. The first page_names entry must be + # a SYSTEM_* template. A page name without the prefix is not a template + # of this specification; it is routed to the deployment-specific legacy + # path (custom QML rendering), never dispatched as a template. + first_page = page_ids_to_show[0] if page_ids_to_show else None + if first_page is None: + LOG.error(f"Activated namespace '{namespace_name}' has no pages!") + return + if not is_system_template(first_page): + LOG.debug( + f"Namespace '{namespace_name}' requested non-template page " + f"'{first_page}' - routing via legacy custom-page path " + f"(not a SYSTEM_* template, OVOS-GUI-1 §4.2)" + ) + pages = list() - persist, duration = self._parse_persistence(message.data["__idle"]) + persist, duration = self._parse_persistence(message.data.get("__idle")) for page in page_ids_to_show: - pages.append(GuiPage(name=page, persistent=persist, duration=duration, - namespace=namespace_name)) + # OVOS-GUI-1 §3.1/§8.1 - accept both spec (SYSTEM_text) and legacy + # (SYSTEM_TextFrame) template names. Resolve a spec name to the + # render resource the current backends ship so QML keeps rendering. + render_name = resolve_render_name(page) if is_system_template(page) else page + if render_name != page: + LOG.debug(f"Resolved spec template '{page}' -> render resource " + f"'{render_name}'") + pages.append(GuiPage(name=render_name, persistent=persist, + duration=duration, namespace=namespace_name)) if not pages: LOG.error(f"Activated namespace '{namespace_name}' has no pages!") @@ -664,62 +785,67 @@ def handle_show_page(self, message: Message): return with namespace_lock: - if not self.active_namespaces: - self._activate_namespace(namespace_name) + if not session.active_namespaces: + self._activate_namespace(namespace_name, session) else: - active_namespace = self.active_namespaces[0] + active_namespace = session.active_namespaces[0] if active_namespace.skill_id != namespace_name: - self._activate_namespace(namespace_name) - self._load_pages(pages, show_index) - self._update_namespace_persistence(persistence) + self._activate_namespace(namespace_name, session) + self._load_pages(pages, show_index, session) + self._update_namespace_persistence(persistence, session) - def _activate_namespace(self, namespace_name: str): + def _activate_namespace(self, namespace_name: str, session: GUISession): """ Instructs the GUI to load a namespace and its associated data. @param namespace_name: the name of the namespace to load + @param session: the session whose stack is affected """ - namespace = self._ensure_namespace_exists(namespace_name) + namespace = self._ensure_namespace_exists(namespace_name, session) - if namespace in self.active_namespaces: - namespace_position = self.active_namespaces.index(namespace) + if namespace in session.active_namespaces: + namespace_position = session.active_namespaces.index(namespace) namespace.activate(namespace_position) if namespace_position != 0: LOG.info(f"Activating namespace: {namespace_name}") - self.active_namespaces.insert( - 0, self.active_namespaces.pop(namespace_position) + session.active_namespaces.insert( + 0, session.active_namespaces.pop(namespace_position) ) else: LOG.info(f"New namespace: {namespace_name}") namespace.add() - self.active_namespaces.insert(0, namespace) + session.active_namespaces.insert(0, namespace) # sync initial state for key, value in namespace.data.items(): namespace.load_data(key, value) - self._emit_namespace_displayed_event() + self._emit_namespace_displayed_event(session) - def _ensure_namespace_exists(self, namespace_name: str) -> Namespace: + def _ensure_namespace_exists(self, namespace_name: str, + session: GUISession) -> Namespace: """ Retrieves the requested namespace, creating one if it doesn't exist. @param namespace_name: the name of the namespace being retrieved + @param session: the session that owns the namespace @returns: requested namespace """ # TODO: - Update sync to match. - namespace = self.loaded_namespaces.get(namespace_name) + namespace = session.loaded_namespaces.get(namespace_name) if namespace is None: namespace = Namespace(namespace_name) - self.loaded_namespaces[namespace_name] = namespace + session.loaded_namespaces[namespace_name] = namespace return namespace - def _load_pages(self, pages_to_show: List[GuiPage], show_index: int): + def _load_pages(self, pages_to_show: List[GuiPage], show_index: int, + session: GUISession): """ Loads the requested pages in the namespace. @param pages_to_show: list of pages to be loaded @param show_index: index to load pages at + @param session: the session whose active namespace is targeted """ - if not self.active_namespaces: + if not session.active_namespaces: LOG.error("received 'load_pages' request but there are no active namespaces") return @@ -727,7 +853,7 @@ def _load_pages(self, pages_to_show: List[GuiPage], show_index: int): LOG.error(f"requested invalid page index: {show_index}, defaulting to last page") show_index = len(pages_to_show) - 1 - active_namespace = self.active_namespaces[0] + active_namespace = session.active_namespaces[0] oldp = [p.name for p in active_namespace.pages] active_namespace.load_pages(pages_to_show, show_index) # LOG only on change @@ -736,7 +862,8 @@ def _load_pages(self, pages_to_show: List[GuiPage], show_index: int): LOG.info(f"Loaded {active_namespace.skill_id} at index: {pn} " f"pages: {[p.name for p in active_namespace.pages]}") - def _update_namespace_persistence(self, persistence: Union[bool, int]): + def _update_namespace_persistence(self, persistence: Union[bool, int], + session: GUISession): """ Sets the persistence of the namespace being activated. A namespace's persistence is the same as the persistence of the @@ -746,11 +873,12 @@ def _update_namespace_persistence(self, persistence: Union[bool, int]): 15 seconds. This would ensure that the namespace isn't removed while the skill is showing the pages. @param persistence: length of time the namespace should be displayed + @param session: the session whose stack is affected """ - for idx, namespace in enumerate(self.active_namespaces): + for idx, namespace in enumerate(session.active_namespaces): if idx: if not namespace.persistent: - self._remove_namespace(namespace.skill_id) + self._remove_namespace(namespace.skill_id, session) else: if namespace.persistent != persistence: LOG.info(f"Setting namespace '{namespace.skill_id}' persistence to: {persistence}") @@ -763,69 +891,75 @@ def _update_namespace_persistence(self, persistence: Union[bool, int]): # check if there is a scheduled remove_namespace_timer # and cancel it if namespace.persistent and namespace.skill_id in \ - self.remove_namespace_timers: - self.remove_namespace_timers[namespace.skill_id].cancel() - self._del_namespace_in_remove_timers(namespace.skill_id) + session.remove_namespace_timers: + session.remove_namespace_timers[namespace.skill_id].cancel() + self._del_namespace_in_remove_timers(namespace.skill_id, session) if not namespace.persistent: - self._schedule_namespace_removal(namespace) + self._schedule_namespace_removal(namespace, session) - self.active_namespaces[idx] = namespace + session.active_namespaces[idx] = namespace - def _schedule_namespace_removal(self, namespace: Namespace): + def _schedule_namespace_removal(self, namespace: Namespace, + session: GUISession): """ Uses a timer thread to remove the namespace. @param namespace: the namespace to be removed + @param session: the session whose stack is affected """ # Before removing check if there isn't already a timer for this namespace - if namespace.skill_id in self.remove_namespace_timers: + if namespace.skill_id in session.remove_namespace_timers: return remove_namespace_timer = Timer( namespace.duration, self._remove_namespace_via_timer, - args=(namespace.skill_id,) + args=(namespace.skill_id, session) ) LOG.info(f"Removal of namespace {namespace.skill_id} in " f"{namespace.duration} seconds") remove_namespace_timer.start() - self.remove_namespace_timers[namespace.skill_id] = remove_namespace_timer + session.remove_namespace_timers[namespace.skill_id] = remove_namespace_timer - def _remove_namespace_via_timer(self, namespace_name: str): + def _remove_namespace_via_timer(self, namespace_name: str, + session: GUISession): """ Removes a namespace and the corresponding timer instance. @param namespace_name: name of namespace to remove + @param session: the session whose stack is affected """ - self._remove_namespace(namespace_name) - self._del_namespace_in_remove_timers(namespace_name) + self._remove_namespace(namespace_name, session) + self._del_namespace_in_remove_timers(namespace_name, session) - def _remove_namespace(self, namespace_name: str): + def _remove_namespace(self, namespace_name: str, session: GUISession): """ Removes a namespace from the active namespace stack. @param namespace_name: name of namespace to remove + @param session: the session whose stack is affected """ # Remove all timers associated with the namespace - if namespace_name in self.remove_namespace_timers: - self.remove_namespace_timers[namespace_name].cancel() - self._del_namespace_in_remove_timers(namespace_name) + if namespace_name in session.remove_namespace_timers: + session.remove_namespace_timers[namespace_name].cancel() + self._del_namespace_in_remove_timers(namespace_name, session) - namespace: Namespace = self.loaded_namespaces.get(namespace_name) - if namespace is not None and namespace in self.active_namespaces: + namespace: Namespace = session.loaded_namespaces.get(namespace_name) + if namespace is not None and namespace in session.active_namespaces: LOG.info(f"Removing namespace {namespace_name}") self.core_bus.emit(Message("gui.namespace.removed", data={"skill_id": namespace.skill_id})) - namespace_position = self.active_namespaces.index(namespace) + namespace_position = session.active_namespaces.index(namespace) namespace.remove(namespace_position) - self.active_namespaces.remove(namespace) + session.active_namespaces.remove(namespace) - self._emit_namespace_displayed_event() + self._emit_namespace_displayed_event(session) - def _emit_namespace_displayed_event(self): + def _emit_namespace_displayed_event(self, session: GUISession): """ Emit a `gui.namespace.displayed` Message to notify core of changes. + @param session: the session whose top namespace is reported """ - if self.active_namespaces: - displaying_namespace = self.active_namespaces[0] + if session.active_namespaces: + displaying_namespace = session.active_namespaces[0] message_data = dict(skill_id=displaying_namespace.skill_id) # TODO - no known listeners ? self.core_bus.emit( @@ -856,20 +990,23 @@ def handle_set_value(self, message: Message): "namespace specified" ) else: + session = self.get_session(_read_session_id(message)) with namespace_lock: - self._update_namespace_data(namespace_name, message.data) + self._update_namespace_data(namespace_name, message.data, session) - def _update_namespace_data(self, namespace_name: str, data: dict): + def _update_namespace_data(self, namespace_name: str, data: dict, + session: GUISession): """ Updates the values of namespace data attributes, unless unchanged. @param namespace_name: the name of the namespace to update @param data: the name and new value of one or more data attributes + @param session: the session that owns the namespace """ - namespace = self._ensure_namespace_exists(namespace_name) + namespace = self._ensure_namespace_exists(namespace_name, session) for key, value in data.items(): if key not in RESERVED_KEYS and namespace.data.get(key) != value: namespace.data[key] = value - if namespace in self.active_namespaces: + if namespace in session.active_namespaces: namespace.load_data(key, value) def handle_client_connected(self, message: Message): @@ -905,8 +1042,9 @@ def handle_page_interaction(self, message: Message): # Update and increase the namespace duration and reset the remove timer namespace_name = message.data.get("skill_id") pidx = message.data.get('page_number') + session = self.get_session(_read_session_id(message)) LOG.info(f"GUI interacted with page in namespace {namespace_name}") - namespace = self.loaded_namespaces.get(namespace_name) + namespace = session.loaded_namespaces.get(namespace_name) if namespace and pidx is not None and pidx != namespace.page_number: # update focused page @@ -915,10 +1053,10 @@ def handle_page_interaction(self, message: Message): # reschedule namespace timeout if namespace_name != self.idle_display_skill and \ not namespace.persistent and \ - self.remove_namespace_timers[namespace.skill_id]: - self.remove_namespace_timers[namespace.skill_id].cancel() - self._del_namespace_in_remove_timers(namespace.skill_id) - self._schedule_namespace_removal(namespace) + session.remove_namespace_timers[namespace.skill_id]: + session.remove_namespace_timers[namespace.skill_id].cancel() + self._del_namespace_in_remove_timers(namespace.skill_id, session) + self._schedule_namespace_removal(namespace, session) def handle_page_gained_focus(self, message: Message): """ @@ -927,11 +1065,12 @@ def handle_page_gained_focus(self, message: Message): """ namespace_name = message.data.get("skill_id") namespace_page_number = message.data.get("page_number") + session = self.get_session(_read_session_id(message)) LOG.debug(f"Page in namespace {namespace_name} gained focus") - namespace = self.loaded_namespaces.get(namespace_name) + namespace = session.loaded_namespaces.get(namespace_name) # first check if the namespace is already active - if namespace in self.active_namespaces: + if namespace in session.active_namespaces: # if the namespace is already active, # check if the page number has changed if namespace_page_number != namespace.page_number: @@ -942,14 +1081,15 @@ def handle_namespace_global_back(self, message: Optional[Message]): Handles global back events from the GUI. @param message: the event sent by the GUI """ - if not self.active_namespaces: + session = self.get_session(_read_session_id(message)) + if not session.active_namespaces: LOG.debug("received 'back' signal but there are no active namespaces, attempting to show homescreen") self.core_bus.emit(Message("homescreen.manager.show_active")) return - namespace_name = self.active_namespaces[0].skill_id - namespace = self.loaded_namespaces.get(namespace_name) - if namespace in self.active_namespaces: + namespace_name = session.active_namespaces[0].skill_id + namespace = session.loaded_namespaces.get(namespace_name) + if namespace in session.active_namespaces: # prev page if namespace.page_number > 0: namespace.global_back() @@ -957,21 +1097,20 @@ def handle_namespace_global_back(self, message: Optional[Message]): else: self.core_bus.emit(Message("homescreen.manager.show_active")) - def _del_namespace_in_remove_timers(self, namespace_name: str): + def _del_namespace_in_remove_timers(self, namespace_name: str, + session: GUISession): """ Delete namespace from remove_namespace_timers dict. @param namespace_name: name of namespace to be deleted + @param session: the session whose timers are affected """ - if namespace_name in self.remove_namespace_timers: - del self.remove_namespace_timers[namespace_name] + if namespace_name in session.remove_namespace_timers: + del session.remove_namespace_timers[namespace_name] def _cache_system_resources(self): """ Copy system GUI resources to the served file path """ output_path = f"{GUI_CACHE_PATH}/system" - if exists(output_path): - LOG.info(f"Removing existing system resources before updating") - shutil.rmtree(output_path) - shutil.copytree(self._system_res_dir, output_path) + shutil.copytree(self._system_res_dir, output_path, dirs_exist_ok=True) LOG.debug(f"Copied system resources from {self._system_res_dir} to {output_path}") diff --git a/ovos_gui/templates.py b/ovos_gui/templates.py new file mode 100644 index 0000000..0136ba0 --- /dev/null +++ b/ovos_gui/templates.py @@ -0,0 +1,151 @@ +# Copyright 2024 OpenVoiceOS +# +# 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. +# +"""The OVOS-GUI-1 closed template vocabulary. + +This module is the single source of truth for the ``SYSTEM_*`` template +vocabulary defined by the **OVOS-GUI-1** specification (§3). A render +backend styles each template once; producers may only name templates from +this closed set (§3.1). + +Two recognition concerns live here: + +* **The ``SYSTEM_`` prefix gate** (§3.2 / §8.3). The prefix is the + discriminator the GUI service uses to recognise a conformant template + intent. A page name that does not begin with ``SYSTEM_`` is *not* a + template of this specification; the service must not dispatch it as one + (it may still route it to a deployment-specific legacy path, §4.2). + +* **Legacy ⇄ spec frame-name aliasing** (§3.1 / §8.1). Historically the + producer (``ovos-bus-client``) emitted CamelCase frame names such as + ``SYSTEM_TextFrame``; the spec vocabulary uses ``SYSTEM_text``. The + service accepts **both** so the producer rename can land without + breaking the QML render path that dispatches on the legacy names. The + alias map is additive: every legacy name resolves to its spec template, + and every spec/legacy name resolves to the legacy QML resource name the + current render backends expect. +""" +from typing import Optional + +#: Reserved prefix that discriminates a conformant template intent (§3.2). +SYSTEM_PREFIX = "SYSTEM_" + +#: The closed GUI-1 template vocabulary (§3.4). Grows only by amendment of +#: the specification. +SYSTEM_TEMPLATES = frozenset({ + # State and feedback + "SYSTEM_idle", + "SYSTEM_loading", + "SYSTEM_status", + "SYSTEM_error", + # Content primitives + "SYSTEM_text", + "SYSTEM_image", + "SYSTEM_animated_image", + "SYSTEM_list", + "SYSTEM_grid", + "SYSTEM_table", + "SYSTEM_html", + "SYSTEM_url", + # Media + "SYSTEM_audio_player", + "SYSTEM_video_player", + "SYSTEM_media_player", + # Domain cards + "SYSTEM_clock", + "SYSTEM_timer", + "SYSTEM_weather", + "SYSTEM_map", + "SYSTEM_face", + # Interactive companions + "SYSTEM_confirm", + "SYSTEM_select", +}) + +#: Legacy CamelCase frame names (as emitted by ``ovos-bus-client``'s GUI +#: API and shipped as QML resources) mapped to their GUI-1 spec template. +#: This lets the service accept the spec names additively — a producer may +#: emit either, and the service treats them as the same template. +LEGACY_TO_SPEC = { + "SYSTEM_TextFrame": "SYSTEM_text", + "SYSTEM_ImageFrame": "SYSTEM_image", + "SYSTEM_AnimatedImageFrame": "SYSTEM_animated_image", + "SYSTEM_HtmlFrame": "SYSTEM_html", + "SYSTEM_UrlFrame": "SYSTEM_url", + "SYSTEM_Status": "SYSTEM_status", + "SYSTEM_Loading": "SYSTEM_loading", + "SYSTEM_Face": "SYSTEM_face", + "SYSTEM_InputBox": "SYSTEM_confirm", +} + +#: Spec template name -> legacy QML resource name. The current QML render +#: backends ship resources keyed by the legacy CamelCase names, so when a +#: producer emits a spec name we resolve it to the legacy resource so +#: rendering keeps working. Only the templates that have a shipped legacy +#: resource are mapped; spec templates without a legacy resource resolve +#: to themselves. +SPEC_TO_LEGACY = {spec: legacy for legacy, spec in LEGACY_TO_SPEC.items()} + + +def is_system_template(page_name: str) -> bool: + """Whether ``page_name`` is recognised as a GUI-1 template intent. + + A page name is a template intent if it begins with the reserved + ``SYSTEM_`` prefix (§3.2). This intentionally accepts both the spec + vocabulary (``SYSTEM_text``) and the legacy frame names + (``SYSTEM_TextFrame``) — both carry the prefix. A name without the + prefix is a custom (non-spec) page and must not be dispatched as a + template. + + @param page_name: candidate page name + @return: True if the name is a ``SYSTEM_*`` template intent + """ + return isinstance(page_name, str) and page_name.startswith(SYSTEM_PREFIX) + + +def normalize_template(page_name: str) -> str: + """Resolve a template name to its canonical GUI-1 spec name. + + Legacy CamelCase frame names are mapped to their spec equivalent; spec + names and unknown ``SYSTEM_*`` names pass through unchanged. + + @param page_name: a ``SYSTEM_*`` template name (spec or legacy) + @return: the canonical spec template name where known, else the input + """ + return LEGACY_TO_SPEC.get(page_name, page_name) + + +def resolve_render_name(page_name: str) -> str: + """Resolve a template name to the resource name the render backend expects. + + The current QML render backends ship resources under the legacy + CamelCase names. When a producer emits a spec name (``SYSTEM_text``) + we resolve it to the legacy resource (``SYSTEM_TextFrame``) so existing + QML keeps rendering. Legacy names and names without a legacy resource + pass through unchanged. + + @param page_name: a ``SYSTEM_*`` template name (spec or legacy) + @return: the render-backend resource name + """ + return SPEC_TO_LEGACY.get(page_name, page_name) + + +def is_known_template(page_name: str) -> Optional[str]: + """Return the canonical spec name if ``page_name`` is in the closed set. + + @param page_name: candidate template name (spec or legacy) + @return: canonical spec name if it is a known GUI-1 template, else None + """ + spec = normalize_template(page_name) + return spec if spec in SYSTEM_TEMPLATES else None diff --git a/pyproject.toml b/pyproject.toml index 0b54df9..9bbd14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,14 +17,37 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", ] dependencies = [ - "ovos_bus_client>=2.2.0a1,<3.0.0", - "ovos-spec-tools>=0.9.0a1", + "ovos_bus_client>=2.5.1a1,<3.0.0", + "ovos-spec-tools>=0.17.3a1", "ovos-utils>=0.0.37,<1.0.0", "ovos-config>=0.0.12,<3.0.0", "tornado~=6.0, >=6.0.3", "ovos-plugin-manager>=2.5.0a1,<3.0.0", ] +[project.optional-dependencies] +test = [ + # ovoscope ships a pytest11 plugin and requires pytest>=8 (the + # pytest_pycollect_makemodule hook dropped 'path' in pytest 8). + "pytest>=8", + "pytest-cov>=4.1", + # ovoscope drives the in-repo OVOS-GUI-1 end-to-end conformance + # (test/end2end/test_gui1_service_e2e.py): it boots the real + # NamespaceManager + the GUIInterface producer on a bus and captures the + # gui.* wire with GUICaptureSession. The 1.0.2a1 line is the first whose + # transitive ovos-core no longer caps ovos-bus-client<2.0.0, so it resolves + # against the GUI-1 floors below. Prerelease-floor pin (>=) so pip resolves + # the prerelease with no --pre. + "ovoscope>=1.0.2a1", + # GUI-1 service-contract floors: per-session routing reads context.session + # via the bus-client 2.5.x session carrier; the SYSTEM_ template vocabulary + # lives in ovos-spec-tools; ovoscope's GUICaptureSession + the FakeBus + # legacy<->ovos.* session bridging needs ovos-utils>=0.12.0a1. + "ovos_bus_client>=2.5.1a1,<3.0.0", + "ovos-spec-tools>=0.17.3a1", + "ovos-utils>=0.12.0a1,<1.0.0", +] + [project.urls] Homepage = "https://github.com/OpenVoiceOS/ovos-gui" diff --git a/test/end2end/__init__.py b/test/end2end/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/end2end/test_gui1_service_e2e.py b/test/end2end/test_gui1_service_e2e.py new file mode 100644 index 0000000..44a7fc9 --- /dev/null +++ b/test/end2end/test_gui1_service_e2e.py @@ -0,0 +1,289 @@ +# Copyright 2024 OpenVoiceOS +# +# 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. +# +"""OVOS-GUI-1 in-repo end-to-end conformance. + +The unit suite (``test/unittests/test_gui1_conformance.py``) calls the +:class:`~ovos_gui.namespace.NamespaceManager` handlers directly. This e2e +suite instead drives the **whole producer -> bus -> service** path with the +real components and observes the wire with ovoscope's ``GUICaptureSession``: + +* the installed producer helper ``ovos_bus_client.apis.gui.GUIInterface`` + emits ``gui.value.set`` / ``gui.page.show`` / ``gui.clear.namespace`` on a + real bus (the on-device ``"default"`` session); +* the real ``NamespaceManager`` (its websocket render service mocked out, as + there is no QML backend in CI) consumes those Messages off the same bus and + drives the namespace lifecycle, emitting ``gui.namespace.*`` back on the + core bus; +* ovoscope captures the ``gui.*`` traffic and the assertions check the + GUI-1 bus contract end to end. + +Clauses asserted (OVOS-GUI-1, ``ovos/org/architecture/gui-1.md``): + +* §2.3 producer emits its §4 wire protocol with no render backend attached; +* §3.1/§8.1 the service dual-accepts the legacy (``SYSTEM_TextFrame``) and + spec (``SYSTEM_text``) frame vocabulary; +* §3.2/§4.2/§8.3 the ``SYSTEM_`` prefix gates a template intent; a non- + ``SYSTEM_`` first page is routed to the legacy path, not dispatched as a + template; +* §4.1 ``__from`` rides every GUI Message and the reserved keys are protocol + metadata, never namespace session data; +* §4.3 the namespace lifecycle — activate on ``gui.page.show``, remove on + ``gui.clear.namespace`` (observable as ``gui.namespace.removed`` on the + core bus); +* §4.3/§5.1/§8.3 each ``session_id`` owns an independent namespace stack and + an absent session defaults to the reserved ``"default"``. +""" +import time +from unittest import TestCase, mock + +from ovos_bus_client.apis.gui import GUIInterface +from ovos_bus_client.message import Message +from ovos_utils.fakebus import FakeBus + +from ovoscope import GUICaptureSession + +# The render service binds a websocket on construction; there is no QML backend +# in CI, so stub it. Everything else (handlers, namespace stacks, core-bus +# emissions) is the real NamespaceManager. +_PATCH_GUI_SERVICE = "ovos_gui.namespace.create_gui_service" + +GUI_PAGE_SHOW = "gui.page.show" +GUI_VALUE_SET = "gui.value.set" +GUI_CLEAR = "gui.clear.namespace" + + +def _show(skill_id, page_names, session_id=None, idle=True, index=0): + """A ``gui.page.show`` Message, optionally scoped to a ``session_id``.""" + data = {"__from": skill_id, "__idle": idle, + "page_names": page_names, "index": index} + context = {} + if session_id is not None: + context["session"] = {"session_id": session_id} + return Message(GUI_PAGE_SHOW, data=data, context=context) + + +def _clear(skill_id, session_id=None): + context = {} + if session_id is not None: + context["session"] = {"session_id": session_id} + return Message(GUI_CLEAR, data={"__from": skill_id}, context=context) + + +class GUI1ServiceE2E(TestCase): + """Boot the real NamespaceManager on a real bus once per test.""" + + def setUp(self): + self.bus = FakeBus() + with mock.patch(_PATCH_GUI_SERVICE): + from ovos_gui.namespace import NamespaceManager, DEFAULT_SESSION_ID + self.mgr = NamespaceManager(self.bus) + self.DEFAULT_SESSION_ID = DEFAULT_SESSION_ID + + def tearDown(self): + # `__idle=True` schedules a deployment-default auto-removal Timer per + # namespace; cancel them so no background thread fires after the test + # process tears down its streams. + for session in self.mgr.sessions.values(): + for timer in session.remove_namespace_timers.values(): + timer.cancel() + + def _settle(self): + # let the bus deliver and the synchronous handlers run + time.sleep(0.2) + + +class TestProducerWireRoundTrip(GUI1ServiceE2E): + """§2.3/§4.1/§4.2 - the real producer's wire protocol reaches the real + service over the bus, and the service activates the producing namespace.""" + + def test_producer_drives_service_over_the_bus(self): + """§2.3/§4.2: the installed ``GUIInterface`` producer emits + ``gui.value.set`` then ``gui.page.show`` on the bus (no render backend + attached), and the service consumes them and activates the namespace.""" + gui = GUIInterface("weather.openvoiceos", bus=self.bus) + with GUICaptureSession(self.bus) as cap: + gui["current_temp"] = 22 + gui.show_text("It is sunny", "Weather") + self._settle() + # §4.2 both wire Messages were emitted by the producer + self.assertIn(GUI_VALUE_SET, [m.msg_type for m in cap.messages]) + self.assertIn(GUI_PAGE_SHOW, [m.msg_type for m in cap.messages]) + + # §4.3: the service activated the producing namespace on the default + # (on-device) session + active = self.mgr.active_namespaces + self.assertEqual([n.skill_id for n in active], ["weather.openvoiceos"]) + # §4.1: __from selected the namespace; the content key is session data + ns = self.mgr.loaded_namespaces["weather.openvoiceos"] + self.assertEqual(ns.data.get("current_temp"), 22) + + def test_every_producer_message_carries_from(self): + """§4.1 MUST: every GUI Message the producer puts on the wire carries + ``__from`` naming the producing namespace. Scoped to the producer's + own wire topics: the service's ``gui.namespace.*`` announcements are + core-bus events, not producer messages, and carry no ``__from``.""" + producer_topics = {GUI_VALUE_SET, GUI_PAGE_SHOW, GUI_CLEAR} + gui = GUIInterface("skill.under.test", bus=self.bus) + with GUICaptureSession(self.bus) as cap: + gui["k"] = "v" + gui.show_text("hi", "T") + gui.clear() + self._settle() + wire = [m for m in cap.messages + if m.msg_type in producer_topics] + # all three producer wire topics were emitted + self.assertEqual({m.msg_type for m in wire}, producer_topics) + for m in wire: + self.assertEqual(m.data.get("__from"), "skill.under.test", + f"{m.msg_type} missing/!= __from") + + def test_reserved_keys_are_not_namespace_session_data(self): + """§4.1 MUST: the reserved ``__``-prefixed keys are protocol metadata, + not session data — they never land in the namespace's content map.""" + gui = GUIInterface("skill.reserved", bus=self.bus) + gui["temperature"] = 19 + gui.show_text("hi", "T") + self._settle() + ns = self.mgr.loaded_namespaces["skill.reserved"] + self.assertIn("temperature", ns.data) + self.assertNotIn("__from", ns.data) + self.assertNotIn("__idle", ns.data) + + +class TestSystemPrefixGate(GUI1ServiceE2E): + """§3.2/§4.2/§8.3 - the ``SYSTEM_`` prefix discriminates a template intent.""" + + def test_system_template_dispatched(self): + """§4.2: a ``gui.page.show`` whose first page is a ``SYSTEM_*`` template + is dispatched and activates the namespace.""" + self.mgr.core_bus.emit(_show("weather.sk", ["SYSTEM_weather"])) + self._settle() + active = self.mgr.active_namespaces + self.assertEqual([n.skill_id for n in active], ["weather.sk"]) + self.assertIn("SYSTEM_weather", active[0].page_names) + + def test_non_system_first_page_is_legacy_not_template(self): + """§3.2/§4.2 - a non-``SYSTEM_`` first page is not a template of this + spec. The service does not raise and recognises it as a non-template + (legacy custom-QML) page, tracked under its raw name.""" + from ovos_gui.templates import is_system_template + self.assertFalse(is_system_template("MyCustomPage")) + self.mgr.core_bus.emit(_show("legacy.skill", ["MyCustomPage"])) + self._settle() + active = self.mgr.active_namespaces + self.assertEqual([n.skill_id for n in active], ["legacy.skill"]) + self.assertIn("MyCustomPage", active[0].page_names) + + +class TestFrameVocabularyDualAccept(GUI1ServiceE2E): + """§3.1/§8.1 - the service accepts both legacy and spec frame names.""" + + def test_legacy_frame_name_accepted(self): + """§8.1 - a producer emitting the legacy ``SYSTEM_TextFrame`` renders + with the legacy resource (the QML backends ship it under that name).""" + self.mgr.core_bus.emit(_show("skill.legacy", ["SYSTEM_TextFrame"])) + self._settle() + self.assertIn("SYSTEM_TextFrame", + self.mgr.active_namespaces[0].page_names) + + def test_spec_name_resolves_to_legacy_render_resource(self): + """§3.1/§8.1 - a producer emitting the spec ``SYSTEM_text`` is accepted; + the service resolves it to the legacy QML resource so existing render + backends keep working.""" + self.mgr.core_bus.emit(_show("skill.spec", ["SYSTEM_text"])) + self._settle() + self.assertIn("SYSTEM_TextFrame", + self.mgr.active_namespaces[0].page_names) + + def test_real_producer_emits_legacy_and_service_accepts(self): + """§3.1/§8.1 end-to-end: the installed ``GUIInterface.show_text`` + emits the legacy ``SYSTEM_TextFrame`` on the wire and the service + dispatches it (the producer/service dual-accept handshake).""" + gui = GUIInterface("weather.openvoiceos", bus=self.bus) + with GUICaptureSession(self.bus) as cap: + gui.show_text("hi", "T") + self._settle() + page = next(m for m in cap.messages if m.msg_type == GUI_PAGE_SHOW) + self.assertTrue(page.data["page_names"][0].startswith("SYSTEM_")) + self.assertIn("weather.openvoiceos", + list(self.mgr.loaded_namespaces.keys())) + + +class TestPerSessionRouting(GUI1ServiceE2E): + """§4.3/§5.1/§8.3 - an independent namespace stack per ``session_id``.""" + + def test_absent_session_defaults_to_default(self): + """§5.1: an absent ``session`` routes to the reserved ``"default"``.""" + self.mgr.core_bus.emit(_show("skill.a", ["SYSTEM_text"])) + self._settle() + self.assertIn(self.DEFAULT_SESSION_ID, self.mgr.sessions) + self.assertEqual( + [n.skill_id for n in + self.mgr.sessions[self.DEFAULT_SESSION_ID].active_namespaces], + ["skill.a"]) + + def test_two_sessions_are_isolated(self): + """§4.3/§5.1/§8.3 MUST: a namespace shown in session A does not appear + on session B's stack, nor on the on-device ``"default"`` stack.""" + self.mgr.core_bus.emit(_show("skill.a", ["SYSTEM_text"], + session_id="roomA")) + self.mgr.core_bus.emit(_show("skill.b", ["SYSTEM_weather"], + session_id="roomB")) + self._settle() + self.assertEqual( + [n.skill_id for n in self.mgr.sessions["roomA"].active_namespaces], + ["skill.a"]) + self.assertEqual( + [n.skill_id for n in self.mgr.sessions["roomB"].active_namespaces], + ["skill.b"]) + # the on-device (default) session was never touched + self.assertEqual( + self.mgr.sessions[self.DEFAULT_SESSION_ID].active_namespaces, []) + + def test_clear_only_affects_its_session(self): + """§4.3/§5.1: clearing a namespace in one session leaves the same + namespace active in another session.""" + self.mgr.core_bus.emit(_show("skill.a", ["SYSTEM_text"], + session_id="roomA")) + self.mgr.core_bus.emit(_show("skill.a", ["SYSTEM_text"], + session_id="roomB")) + self._settle() + self.mgr.core_bus.emit(_clear("skill.a", session_id="roomA")) + self._settle() + self.assertEqual(self.mgr.sessions["roomA"].active_namespaces, []) + self.assertEqual( + [n.skill_id for n in self.mgr.sessions["roomB"].active_namespaces], + ["skill.a"]) + + +class TestNamespaceLifecycle(GUI1ServiceE2E): + """§4.3 - activate on show, remove on clear, observable on the core bus.""" + + def test_clear_emits_namespace_removed_on_core_bus(self): + """§4.3: ``gui.clear.namespace`` removes the namespace from the active + stack and the service announces it as ``gui.namespace.removed`` on the + core bus.""" + recs = [] + self.mgr.core_bus.on( + "message", + lambda m: recs.append( + Message.deserialize(m) if isinstance(m, str) else m)) + self.mgr.core_bus.emit(_show("sk.clearme", ["SYSTEM_text"])) + self._settle() + self.mgr.core_bus.emit(_clear("sk.clearme")) + self._settle() + self.assertIn("gui.namespace.removed", + [m.msg_type for m in recs]) + self.assertEqual(self.mgr.active_namespaces, []) diff --git a/test/unittests/test_gui1_conformance.py b/test/unittests/test_gui1_conformance.py new file mode 100644 index 0000000..fd8954e --- /dev/null +++ b/test/unittests/test_gui1_conformance.py @@ -0,0 +1,208 @@ +# Copyright 2024 OpenVoiceOS +# +# 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. +# +"""Service-side OVOS-GUI-1 conformance tests. + +Boots the real :class:`NamespaceManager` on a :class:`FakeBus` and drives +``gui.page.show`` / ``gui.value.set`` / ``gui.clear.namespace`` messages, +asserting: + +* the SYSTEM_ template prefix gate (§3.2 / §4.2 / §8.3); +* legacy ⇄ spec frame-name dual-accept (§3.1 / §8.1); +* per-session namespace isolation (§4.3 / §5.1 / §8.3). +""" +from unittest import TestCase, mock + +from ovos_bus_client.message import Message +from ovos_utils.fakebus import FakeBus + +from ovos_gui.namespace import NamespaceManager, DEFAULT_SESSION_ID + +PATCH_MODULE = "ovos_gui.namespace" + + +def _show(skill_id, page_names, session_id=None, idle=True, index=0): + """Build a gui.page.show Message, optionally session-scoped.""" + data = {"__from": skill_id, "__idle": idle, + "page_names": page_names, "index": index} + context = {} + if session_id is not None: + context["session"] = {"session_id": session_id} + return Message("gui.page.show", data=data, context=context) + + +def _set(skill_id, values, session_id=None): + data = {"__from": skill_id} + data.update(values) + context = {} + if session_id is not None: + context["session"] = {"session_id": session_id} + return Message("gui.value.set", data=data, context=context) + + +def _clear(skill_id, session_id=None): + context = {} + if session_id is not None: + context["session"] = {"session_id": session_id} + return Message("gui.clear.namespace", data={"__from": skill_id}, + context=context) + + +class GUI1ConformanceTestCase(TestCase): + def setUp(self): + with mock.patch(PATCH_MODULE + ".create_gui_service"): + self.mgr = NamespaceManager(FakeBus()) + + +class TestSystemPrefixGate(GUI1ConformanceTestCase): + """§3.2/§4.2/§8.3 - only SYSTEM_* page names are template intents.""" + + def test_system_template_is_dispatched(self): + self.mgr.handle_show_page(_show("weather.openvoiceos", ["SYSTEM_weather"])) + active = self.mgr.active_namespaces + self.assertEqual(len(active), 1) + self.assertEqual(active[0].skill_id, "weather.openvoiceos") + self.assertIn("SYSTEM_weather", active[0].page_names) + + def test_non_system_page_routed_to_legacy_not_template(self): + # A custom (non-SYSTEM_) page is the legacy custom-QML path. The + # service still tracks it (it does not crash), but it is recognised + # as NOT a template intent. + from ovos_gui.templates import is_system_template + self.assertFalse(is_system_template("MyCustomPage")) + # service must not raise on a legacy page name + self.mgr.handle_show_page(_show("skill.test", ["MyCustomPage"])) + # legacy page is tracked under the namespace by its raw name + active = self.mgr.active_namespaces + self.assertEqual(active[0].skill_id, "skill.test") + self.assertIn("MyCustomPage", active[0].page_names) + + +class TestFrameVocabularyDualAccept(GUI1ConformanceTestCase): + """§3.1/§8.1 - the service accepts both legacy and spec frame names.""" + + def test_legacy_frame_name_renders_with_legacy_resource(self): + self.mgr.handle_show_page(_show("skill.a", ["SYSTEM_TextFrame"])) + self.assertIn("SYSTEM_TextFrame", + self.mgr.active_namespaces[0].page_names) + + def test_spec_frame_name_resolves_to_legacy_render_resource(self): + # producer emits the GUI-1 spec name; service resolves it to the + # legacy QML resource so existing render backends keep working. + self.mgr.handle_show_page(_show("skill.b", ["SYSTEM_text"])) + self.assertIn("SYSTEM_TextFrame", + self.mgr.active_namespaces[0].page_names) + + def test_spec_name_without_legacy_resource_passes_through(self): + self.mgr.handle_show_page(_show("skill.c", ["SYSTEM_weather"])) + self.assertIn("SYSTEM_weather", + self.mgr.active_namespaces[0].page_names) + + +class TestOptionalIdleOmission(GUI1ConformanceTestCase): + """§3.3/§4.3 - producers may omit __idle entirely; an absent key means + "use the namespace default", not a crash.""" + + def test_absent_idle_key_does_not_raise(self): + msg = Message("gui.page.show", + data={"__from": "skill.no_idle", + "page_names": ["SYSTEM_weather"], "index": 0}, + context={}) + # must not raise KeyError + self.mgr.handle_show_page(msg) + active = self.mgr.active_namespaces + self.assertEqual(active[0].skill_id, "skill.no_idle") + self.assertIn("SYSTEM_weather", active[0].page_names) + + def test_absent_idle_key_uses_default_persistence(self): + msg = Message("gui.page.show", + data={"__from": "skill.no_idle2", + "page_names": ["SYSTEM_weather"], "index": 0}, + context={}) + self.mgr.handle_show_page(msg) + page = self.mgr.active_namespaces[0].pages[0] + # default behavior per _parse_persistence: not persistent, 30s + self.assertFalse(page.persistent) + self.assertEqual(page.duration, 30) + + +class TestSessionRouting(GUI1ConformanceTestCase): + """§4.3/§5.1/§8.3 - independent namespace stack per session_id.""" + + def test_absent_session_defaults_to_default(self): + self.mgr.handle_show_page(_show("skill.a", ["SYSTEM_text"])) + self.assertIn(DEFAULT_SESSION_ID, self.mgr.sessions) + self.assertEqual( + self.mgr.sessions[DEFAULT_SESSION_ID].active_namespaces[0].skill_id, + "skill.a") + + def test_empty_session_id_defaults_to_default(self): + msg = _show("skill.a", ["SYSTEM_text"]) + msg.context["session"] = {"session_id": ""} + self.mgr.handle_show_page(msg) + self.assertEqual( + self.mgr.sessions[DEFAULT_SESSION_ID].active_namespaces[0].skill_id, + "skill.a") + + def test_sessions_are_isolated(self): + # two distinct sessions show different namespaces; neither collides + self.mgr.handle_show_page(_show("skill.a", ["SYSTEM_text"], + session_id="roomA")) + self.mgr.handle_show_page(_show("skill.b", ["SYSTEM_weather"], + session_id="roomB")) + + self.assertIn("roomA", self.mgr.sessions) + self.assertIn("roomB", self.mgr.sessions) + + a = self.mgr.sessions["roomA"].active_namespaces + b = self.mgr.sessions["roomB"].active_namespaces + self.assertEqual([n.skill_id for n in a], ["skill.a"]) + self.assertEqual([n.skill_id for n in b], ["skill.b"]) + # the default (on-device) session was never touched + self.assertEqual(self.mgr.sessions[DEFAULT_SESSION_ID].active_namespaces, + []) + + def test_value_set_is_session_scoped(self): + self.mgr.handle_show_page(_show("weather.x", ["SYSTEM_weather"], + session_id="roomA")) + self.mgr.handle_set_value(_set("weather.x", {"current_temp": 22}, + session_id="roomA")) + self.mgr.handle_set_value(_set("weather.x", {"current_temp": 5}, + session_id="roomB")) + + ns_a = self.mgr.sessions["roomA"].loaded_namespaces["weather.x"] + ns_b = self.mgr.sessions["roomB"].loaded_namespaces["weather.x"] + self.assertEqual(ns_a.data.get("current_temp"), 22) + self.assertEqual(ns_b.data.get("current_temp"), 5) + + def test_clear_only_affects_its_session(self): + self.mgr.handle_show_page(_show("skill.a", ["SYSTEM_text"], + session_id="roomA")) + self.mgr.handle_show_page(_show("skill.a", ["SYSTEM_text"], + session_id="roomB")) + # clear in roomA only + self.mgr.handle_clear_namespace(_clear("skill.a", session_id="roomA")) + + self.assertEqual(self.mgr.sessions["roomA"].active_namespaces, []) + self.assertEqual( + [n.skill_id for n in self.mgr.sessions["roomB"].active_namespaces], + ["skill.a"]) + + def test_default_session_proxies(self): + # the legacy single-screen proxies map to the "default" session + self.mgr.handle_show_page(_show("skill.a", ["SYSTEM_text"])) + self.assertIs(self.mgr.active_namespaces, + self.mgr.sessions[DEFAULT_SESSION_ID].active_namespaces) + self.assertIs(self.mgr.loaded_namespaces, + self.mgr.sessions[DEFAULT_SESSION_ID].loaded_namespaces) diff --git a/test/unittests/test_namespace.py b/test/unittests/test_namespace.py index 52bfafa..8f8b646 100644 --- a/test/unittests/test_namespace.py +++ b/test/unittests/test_namespace.py @@ -473,12 +473,12 @@ def test_handle_show_page(self): "__idle": 10, "page_names": ["bar", "test/baz"]}) self.namespace_manager.handle_show_page(message) - self.namespace_manager._activate_namespace.assert_called_with("foo") + self.namespace_manager._activate_namespace.assert_called_with("foo", mock.ANY) self.namespace_manager._load_pages.assert_called_with( [GuiPage(name='bar', persistent=False, duration=10, namespace='foo'), - GuiPage(name='test/baz', persistent=False, duration=10, namespace='foo')], 0) + GuiPage(name='test/baz', persistent=False, duration=10, namespace='foo')], 0, mock.ANY) self.namespace_manager._update_namespace_persistence. \ - assert_called_with(10) + assert_called_with(10, mock.ANY) # With resource info self.namespace_manager._activate_namespace.reset_mock() @@ -494,12 +494,12 @@ def test_handle_show_page(self): self.namespace_manager.handle_show_page(message) expected_page1 = GuiPage("page_1", False, 0, "skill") expected_page2 = GuiPage("test/page_2", False, 0, "skill") - self.namespace_manager._activate_namespace.assert_called_with("skill") + self.namespace_manager._activate_namespace.assert_called_with("skill", mock.ANY) self.namespace_manager._load_pages.assert_called_with([expected_page1, expected_page2], - 1) + 1, mock.ANY) self.namespace_manager._update_namespace_persistence. \ - assert_called_with(False) + assert_called_with(False, mock.ANY) # System resources: SYSTEM_ pages are currently handled like any other # page (there is no special template routing in ovos_gui.namespace). @@ -513,13 +513,13 @@ def test_handle_show_page(self): "page": ["/gui/SYSTEM_TextFrame.qml"], "page_names": ["SYSTEM_TextFrame"]}) self.namespace_manager.handle_show_page(message) - self.namespace_manager._activate_namespace.assert_called_with("skill_no_res") + self.namespace_manager._activate_namespace.assert_called_with("skill_no_res", mock.ANY) # __idle=True -> persistent page (persistent=True, duration=0) self.namespace_manager._load_pages.assert_called_with( [GuiPage(name="SYSTEM_TextFrame", persistent=True, duration=0, - namespace="skill_no_res")], 2) + namespace="skill_no_res")], 2, mock.ANY) self.namespace_manager._update_namespace_persistence. \ - assert_called_with(True) + assert_called_with(True, mock.ANY) self.namespace_manager._activate_namespace = real_activate_namespace self.namespace_manager._load_pages = real_load_pages @@ -546,14 +546,16 @@ def test_activate_namespace(self): def test_ensure_namespace_exists(self): """Test ensuring namespace exists or is created.""" - ns = self.namespace_manager._ensure_namespace_exists("new_skill") + session = self.namespace_manager.get_session() + ns = self.namespace_manager._ensure_namespace_exists("new_skill", session) self.assertIsNotNone(ns) self.assertEqual(ns.skill_id, "new_skill") self.assertIn("new_skill", self.namespace_manager.loaded_namespaces) def test_load_pages(self): """Test loading pages into a namespace.""" - ns = self.namespace_manager._ensure_namespace_exists("test") + session = self.namespace_manager.get_session() + ns = self.namespace_manager._ensure_namespace_exists("test", session) self.assertIsNotNone(ns) def test_update_namespace_persistence(self): @@ -660,7 +662,8 @@ def test_activate_namespace_already_active(self): self.namespace_manager.loaded_namespaces["other"] = other_ns self.namespace_manager.active_namespaces = [other_ns, ns] # Activate the existing namespace (should move to position 0) - self.namespace_manager._activate_namespace("existing") + session = self.namespace_manager.get_session() + self.namespace_manager._activate_namespace("existing", session) # Verify it's now at position 0 self.assertEqual(self.namespace_manager.active_namespaces[0].skill_id, "existing") @@ -669,7 +672,8 @@ def test_activate_namespace_new(self): ns = Namespace("new_skill") self.namespace_manager.loaded_namespaces["new_skill"] = ns # Activate the new namespace - self.namespace_manager._activate_namespace("new_skill") + session = self.namespace_manager.get_session() + self.namespace_manager._activate_namespace("new_skill", session) # Verify it's now active self.assertIn(ns, self.namespace_manager.active_namespaces) self.assertEqual(self.namespace_manager.active_namespaces[0].skill_id, "new_skill") @@ -682,6 +686,7 @@ def test_remove_namespace_with_timer(self): # Add a mock timer for this namespace self.namespace_manager.remove_namespace_timers["test"] = mock.Mock() # Remove the namespace - self.namespace_manager._remove_namespace("test") + session = self.namespace_manager.get_session() + self.namespace_manager._remove_namespace("test", session) # Verify namespace is removed from active_namespaces self.assertNotIn(ns, self.namespace_manager.active_namespaces) diff --git a/test/unittests/test_templates.py b/test/unittests/test_templates.py new file mode 100644 index 0000000..fd19996 --- /dev/null +++ b/test/unittests/test_templates.py @@ -0,0 +1,91 @@ +# Copyright 2024 OpenVoiceOS +# +# 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. +# +"""Unit tests for the OVOS-GUI-1 closed template vocabulary.""" +from unittest import TestCase + +from ovos_gui.templates import ( + SYSTEM_PREFIX, + SYSTEM_TEMPLATES, + LEGACY_TO_SPEC, + SPEC_TO_LEGACY, + is_system_template, + normalize_template, + resolve_render_name, + is_known_template, +) + + +class TestSystemPrefixGate(TestCase): + """OVOS-GUI-1 §3.2/§8.3 - the SYSTEM_ prefix discriminates a template.""" + + def test_spec_templates_carry_prefix(self): + for tpl in SYSTEM_TEMPLATES: + self.assertTrue(tpl.startswith(SYSTEM_PREFIX), tpl) + self.assertTrue(is_system_template(tpl), tpl) + + def test_legacy_names_recognised_as_templates(self): + for legacy in LEGACY_TO_SPEC: + self.assertTrue(is_system_template(legacy), legacy) + + def test_non_system_names_rejected(self): + for name in ("Weather", "skill.openvoiceos.MyPage", "custom_qml", + "system_text", "", "idle"): + self.assertFalse(is_system_template(name), name) + + def test_non_string_is_not_template(self): + self.assertFalse(is_system_template(None)) + self.assertFalse(is_system_template(123)) + self.assertFalse(is_system_template(["SYSTEM_text"])) + + +class TestFrameVocabulary(TestCase): + """OVOS-GUI-1 §3.1/§8.1 - dual accept of legacy and spec frame names.""" + + def test_legacy_resolves_to_spec(self): + self.assertEqual(normalize_template("SYSTEM_TextFrame"), "SYSTEM_text") + self.assertEqual(normalize_template("SYSTEM_Status"), "SYSTEM_status") + self.assertEqual(normalize_template("SYSTEM_HtmlFrame"), "SYSTEM_html") + + def test_spec_name_passes_through(self): + self.assertEqual(normalize_template("SYSTEM_text"), "SYSTEM_text") + self.assertEqual(normalize_template("SYSTEM_weather"), "SYSTEM_weather") + + def test_unknown_system_name_passes_through(self): + self.assertEqual(normalize_template("SYSTEM_FutureThing"), + "SYSTEM_FutureThing") + + def test_spec_name_resolves_to_legacy_render_resource(self): + # producer may emit the spec name; render backends ship legacy QML + self.assertEqual(resolve_render_name("SYSTEM_text"), "SYSTEM_TextFrame") + self.assertEqual(resolve_render_name("SYSTEM_status"), "SYSTEM_Status") + + def test_legacy_render_resource_passes_through(self): + self.assertEqual(resolve_render_name("SYSTEM_TextFrame"), + "SYSTEM_TextFrame") + + def test_alias_maps_are_inverses(self): + for legacy, spec in LEGACY_TO_SPEC.items(): + self.assertEqual(SPEC_TO_LEGACY[spec], legacy) + + def test_every_legacy_target_is_a_known_template(self): + for spec in LEGACY_TO_SPEC.values(): + self.assertIn(spec, SYSTEM_TEMPLATES, spec) + + def test_is_known_template(self): + # both spec and legacy names resolve to the canonical spec name + self.assertEqual(is_known_template("SYSTEM_text"), "SYSTEM_text") + self.assertEqual(is_known_template("SYSTEM_TextFrame"), "SYSTEM_text") + self.assertIsNone(is_known_template("SYSTEM_FutureThing")) + self.assertIsNone(is_known_template("custom_page"))