diff --git a/src/riftlift/auth.py b/src/riftlift/auth.py index ccd29c9..e4f822b 100644 --- a/src/riftlift/auth.py +++ b/src/riftlift/auth.py @@ -213,14 +213,16 @@ def runtime_access_token(paths: Paths) -> str: return account_tokens(paths)[0] -def owned_apps(paths: Paths) -> tuple[list[entitlements.OwnedApp], list[str]]: +def owned_apps(paths: Paths) -> tuple[entitlements.OwnedLibrary, list[str]]: """List Rift/PC VR apps owned by any signed-in account. Returns the merged apps and one message per account that could not be - read; raises only when no account could be read at all. + read; raises only when no account could be read at all. The list is + marked partial when Meta cut any account's list short. """ merged: dict[str, entitlements.OwnedApp] = {} failures = [] + partial = False signed_in = accounts(paths) if not signed_in: account_tokens(paths) # raises the signed-out error @@ -231,8 +233,10 @@ def owned_apps(paths: Paths) -> tuple[list[entitlements.OwnedApp], list[str]]: failures.append(f"{account.name or f'Meta account {number}'}: {error}") continue record_owned(paths, account.id, [app.app_id for app in owned]) + partial = partial or getattr(owned, "partial", False) for app in owned: merged.setdefault(app.app_id, app) if failures and len(failures) == len(signed_in): raise RiftLiftError(failures[0] if len(failures) == 1 else "; ".join(failures)) - return sorted(merged.values(), key=lambda app: app.name.lower()), failures + apps = sorted(merged.values(), key=lambda app: app.name.lower()) + return entitlements.OwnedLibrary(apps, partial=partial), failures diff --git a/src/riftlift/entitlements.py b/src/riftlift/entitlements.py index 1695459..9375ad1 100644 --- a/src/riftlift/entitlements.py +++ b/src/riftlift/entitlements.py @@ -22,10 +22,26 @@ class OwnedApp: @property def store_url(self) -> str: + # Delisted apps can come back without a canonical name; Meta's store + # also resolves the link without one. + if not self.slug: + return f"https://www.meta.com/experiences/pcvr/{self.app_id}/" return f"https://www.meta.com/experiences/pcvr/{self.slug}/{self.app_id}/" -def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: +class OwnedLibrary(list): + """Owned apps, plus whether Meta said more results exist than it returned. + + The entitlements query is a persisted one without known paging variables, + so a list Meta cut short can only be reported, not completed. + """ + + def __init__(self, apps=(), *, partial: bool = False): + super().__init__(apps) + self.partial = partial + + +def list_owned_pcvr_apps(token: str) -> OwnedLibrary: body = urllib.parse.urlencode( { "access_token": token, @@ -52,9 +68,11 @@ def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: nodes = entitlements["nodes"] except (KeyError, TypeError) as error: raise RiftLiftError("Meta returned no entitlement data") from error + page_info = entitlements.get("page_info") or {} + partial = bool(isinstance(page_info, dict) and page_info.get("has_next_page")) print( f"Meta entitlements: {len(nodes)} node(s) returned" - f"{'; a page_info field is present (results may be paginated and truncated)' if 'page_info' in entitlements else ''}." + f"{'; Meta reports more pages, so this list is incomplete' if partial else ''}." ) apps = [] skipped_platforms: dict[str, int] = {} @@ -68,8 +86,8 @@ def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: ) continue app_id, name = item.get("id"), item.get("display_name") - slug = item.get("canonical_name") - if app_id and name and slug: + slug = item.get("canonical_name") or "" + if app_id and name: apps.append(OwnedApp(app_id=str(app_id), name=str(name), slug=str(slug))) else: skipped_incomplete += 1 @@ -78,7 +96,7 @@ def list_owned_pcvr_apps(token: str) -> list[OwnedApp]: f"Meta entitlements: skipped {skipped_incomplete} incomplete node(s); " f"skipped by platform: {skipped_platforms}" ) - return sorted(apps, key=lambda app: app.name.lower()) + return OwnedLibrary(sorted(apps, key=lambda app: app.name.lower()), partial=partial) def fetch_account_identity(token: str) -> tuple[str, str] | None: diff --git a/src/riftlift/i18n/en.py b/src/riftlift/i18n/en.py index 6f4f284..81c0d24 100644 --- a/src/riftlift/i18n/en.py +++ b/src/riftlift/i18n/en.py @@ -57,6 +57,9 @@ "not_installed": "Not installed", "version": "Version {version}", "refresh_tooltip": "Refresh installed games and your Meta library", + "partial_list": ( + "Meta returned only part of your library. Add a missing game with Add Game." + ), }, "empty": { "title": "No Rift games yet", diff --git a/src/riftlift/i18n/fr.py b/src/riftlift/i18n/fr.py index 9441ff2..c585f35 100644 --- a/src/riftlift/i18n/fr.py +++ b/src/riftlift/i18n/fr.py @@ -58,6 +58,10 @@ "not_installed": "Non installés", "version": "Version {version}", "refresh_tooltip": "Rafraîchir les jeux installés et ta bibliothèque Meta", + "partial_list": ( + "Meta n'a renvoyé qu'une partie de ta bibliothèque. Ajoute un jeu " + "manquant avec Ajouter un jeu." + ), }, "empty": { "title": "Aucun jeu Rift pour l'instant", diff --git a/src/riftlift/main_window.py b/src/riftlift/main_window.py index b962ef0..575f9e5 100644 --- a/src/riftlift/main_window.py +++ b/src/riftlift/main_window.py @@ -121,6 +121,7 @@ class Events(QtCore.QObject): class OwnedEvents(QtCore.QObject): complete = QtCore.Signal(object, object) + icon = QtCore.Signal(int, str, str) class OwnedDetailEvents(QtCore.QObject): @@ -140,13 +141,25 @@ class SystemStatusEvents(QtCore.QObject): complete = QtCore.Signal(bool, str) +def _deliver(signal, *args) -> None: + """Hand a worker thread's result to the window, unless it is gone. + + Closing the window or quitting deletes its event objects while a worker + may still be fetching; the result then has nowhere to go. + """ + with contextlib.suppress(RuntimeError): # "Signal source has been deleted" + signal.emit(*args) + + class Output(io.TextIOBase): def __init__(self, emit: Callable[[str], None]): self.emit = emit def write(self, value: str) -> int: if value: - self.emit(value) + # Workers print through this too; see _deliver. + with contextlib.suppress(RuntimeError): + self.emit(value) return len(value) def flush(self) -> None: @@ -167,6 +180,7 @@ def __init__( set_language(language_preference(self.paths)) self.installed: list[Game] = [] self.owned: list[OwnedApp] = [] + self._owned_icons: dict[str, str] = {} self.selected_owned: OwnedApp | None = None self.slug: str | None = initial_slug self._pending_owned_app_id = initial_owned_app_id @@ -182,6 +196,7 @@ def __init__( self.events.complete.connect(self._finish) self.owned_events = OwnedEvents() self.owned_events.complete.connect(self._finish_owned_scan) + self.owned_events.icon.connect(self._finish_owned_icon) self.owned_detail_events = OwnedDetailEvents() self.owned_detail_events.complete.connect(self._finish_owned_detail) self.game_metadata_events = GameMetadataEvents() @@ -238,9 +253,9 @@ def _load_owned_detail(self, app, *, refresh=False): def _check_setup_status(self) -> None: def worker(): try: - self.setup_status_events.complete.emit(needs_setup(self.paths)) + _deliver(self.setup_status_events.complete, needs_setup(self.paths)) except (OSError, RiftLiftError) as error: - self.setup_status_events.failed.emit(str(error)) + _deliver(self.setup_status_events.failed, str(error)) threading.Thread( target=worker, daemon=True, name="riftlift-setup-check" @@ -262,21 +277,25 @@ def worker(): try: missing = needs_setup(self.paths) except (OSError, RiftLiftError) as error: - self.system_status_events.complete.emit(False, str(error)) + _deliver(self.system_status_events.complete, False, str(error)) return if missing: - self.system_status_events.complete.emit( - False, SETUP("status_needs_setup") + _deliver( + self.system_status_events.complete, + False, + SETUP("status_needs_setup"), ) return try: active_runtime_json() except RiftLiftError: - self.system_status_events.complete.emit( - False, SETUP("status_no_openxr_runtime") + _deliver( + self.system_status_events.complete, + False, + SETUP("status_no_openxr_runtime"), ) return - self.system_status_events.complete.emit(True, SETUP("status_ok")) + _deliver(self.system_status_events.complete, True, SETUP("status_ok")) threading.Thread( target=worker, daemon=True, name="riftlift-status-check" @@ -392,8 +411,11 @@ def show_auth(self): if dialog.changed and signed_in: self.refresh_owned() elif dialog.changed: - # Nobody is signed in any more, so nothing is owned. + # Nobody is signed in any more, so nothing is owned; icons a scan + # is still fetching belong to the old list. + self._owned_generation = getattr(self, "_owned_generation", 0) + 1 self.owned = [] + self.owned_hint.hide() self._render_tree() self._update_signin_label() @@ -604,11 +626,13 @@ def worker(): if is_steam else fetch_catalog_metadata(app_id) ) - self.game_metadata_events.complete.emit( - token, game.slug, metadata, None + _deliver( + self.game_metadata_events.complete, token, game.slug, metadata, None ) except Exception as error: - self.game_metadata_events.complete.emit(token, game.slug, None, error) + _deliver( + self.game_metadata_events.complete, token, game.slug, None, error + ) threading.Thread( target=worker, daemon=True, name="riftlift-description-refresh" @@ -665,11 +689,17 @@ def show_owned(self, app: OwnedApp, *, refresh: bool = False): def worker(): try: metadata, hero = self._load_owned_detail(app, refresh=refresh) - self.owned_detail_events.complete.emit( - token, app.app_id, (metadata, hero), None + _deliver( + self.owned_detail_events.complete, + token, + app.app_id, + (metadata, hero), + None, ) except Exception as error: - self.owned_detail_events.complete.emit(token, app.app_id, None, error) + _deliver( + self.owned_detail_events.complete, token, app.app_id, None, error + ) threading.Thread( target=worker, daemon=True, name="riftlift-owned-detail" @@ -904,19 +934,30 @@ def open_store(self): QtGui.QDesktopServices.openUrl(QtCore.QUrl(self.selected_owned.store_url)) def refresh_owned(self): + token = self._owned_generation = getattr(self, "_owned_generation", 0) + 1 + def worker(): - try: - with contextlib.redirect_stdout(Output(self.events.output.emit)): + with contextlib.redirect_stdout(Output(self.events.output.emit)): + try: owned, failures = owned_apps(self.paths) - for failure in failures: - print(f"Could not read a Meta account's games: {failure}") - icons = { - app.app_id: fetch_owned_icon(self.paths, app.app_id) - for app in owned - } - self.owned_events.complete.emit((owned, icons), None) - except Exception as error: - self.owned_events.complete.emit(([], {}), error) + except Exception as error: + _deliver(self.owned_events.complete, ([], {}), error) + return + for failure in failures: + print(f"Could not read a Meta account's games: {failure}") + # Show the list now; each icon may need a catalog lookup and a + # download, so they follow one by one instead of holding it back. + _deliver(self.owned_events.complete, (owned, {}), None) + for app in owned: + if token != self._owned_generation: + return + try: + icon = fetch_owned_icon(self.paths, app.app_id) + except Exception as error: + print(f"warning: no icon for {app.name}: {error}") + continue + if icon: + _deliver(self.owned_events.icon, token, app.app_id, icon) threading.Thread( target=worker, daemon=True, name="riftlift-owned-refresh" @@ -933,9 +974,31 @@ def _finish_owned_scan(self, result, error): return owned, icons = result self.owned = owned - self._owned_icons = icons + # Keep icons a previous scan found until this one replaces them. + app_ids = {app.app_id for app in owned} + self._owned_icons = { + **{ + app_id: path + for app_id, path in self._owned_icons.items() + if app_id in app_ids + }, + **icons, + } + self.owned_hint.setVisible(bool(getattr(owned, "partial", False))) self._render_tree() + def _finish_owned_icon(self, token, app_id, path): + if token != getattr(self, "_owned_generation", 0): + return + self._owned_icons[app_id] = path + item = self._find_item( + self._owned_category, + lambda app: isinstance(app, OwnedApp) and app.app_id == app_id, + ) + icon = rounded_icon(path, 34, 8) + if item is not None and not icon.isNull(): + item.setIcon(0, icon) + def install_owned(self): if self.selected_owned is not None: self.add_dialog( @@ -997,9 +1060,9 @@ def worker(): contextlib.redirect_stderr(Output(self.events.output.emit)), ): result = operation() - self.events.complete.emit(success, result, refresh, None) + _deliver(self.events.complete, success, result, refresh, None) except Exception as error: - self.events.complete.emit("", None, False, error) + _deliver(self.events.complete, "", None, False, error) threading.Thread(target=worker, daemon=True, name="riftlift-operation").start() diff --git a/src/riftlift/native_shell.py b/src/riftlift/native_shell.py index 44b2289..b47f253 100644 --- a/src/riftlift/native_shell.py +++ b/src/riftlift/native_shell.py @@ -6,6 +6,7 @@ from __future__ import annotations +import unicodedata from importlib.resources import files from PySide6 import QtCore, QtGui, QtWidgets @@ -17,6 +18,24 @@ GAME = namespace("game") LIBRARY = namespace("library") SHELL = namespace("shell") +APOSTROPHES = "'\u2018\u2019\u02bc" +# Letters that Unicode doesn't decompose into a base letter and an accent. +_FOLDED_LETTERS = str.maketrans( + {"\u00f8": "o", "\u00e6": "ae", "\u0153": "oe", "\u0142": "l", "\u0111": "d"} +) + + +def search_words(text: str) -> list[str]: + """Split text into comparable words, ignoring case, accents and punctuation.""" + folded = text.casefold().translate(_FOLDED_LETTERS) + decomposed = unicodedata.normalize("NFKD", folded) + plain = "".join( + char if char.isalnum() else " " + for char in decomposed + # An apostrophe belongs to its word: "Lucky's" is found as "luckys" too. + if not unicodedata.combining(char) and char not in APOSTROPHES + ) + return plain.split() def brand_icon() -> QtGui.QIcon: @@ -292,6 +311,10 @@ def _build(self): self.search_empty.setWordWrap(True) self.search_empty.hide() left.addWidget(self.search_empty) + self.owned_hint = self.label(LIBRARY("partial_list"), "muted") + self.owned_hint.setWordWrap(True) + self.owned_hint.hide() + left.addWidget(self.owned_hint) self.steam_games = self.button(NAV("steam_games"), self.steam_dialog) self.addbtn = self.button(NAV("add_game"), lambda: self.add_dialog()) left.addWidget(self.steam_games) @@ -499,13 +522,15 @@ def action(text, callback): return page def _filter_library(self, text=""): - query = text.strip().casefold() + # Every typed word must start or occur in the title, in any order. + query = search_words(text) matches = 0 for category, _key in self._categories: visible = 0 for index in range(category.childCount()): item = category.child(index) - match = query in item.text(0).casefold() + title = " ".join(search_words(item.text(0))) + match = all(word in title for word in query) item.setHidden(not match) visible += int(match) category.setHidden(visible == 0) diff --git a/tests/test_entitlements.py b/tests/test_entitlements.py new file mode 100644 index 0000000..c8366e7 --- /dev/null +++ b/tests/test_entitlements.py @@ -0,0 +1,88 @@ +import io +import json + +from riftlift import entitlements +from riftlift.entitlements import OwnedApp, list_owned_pcvr_apps + + +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +def _serve(monkeypatch, nodes, page_info=None) -> None: + connection = {"nodes": nodes} + if page_info is not None: + connection["page_info"] = page_info + payload = {"data": {"viewer": {"user": {"active_entitlements": connection}}}} + monkeypatch.setattr( + entitlements.urllib.request, + "urlopen", + lambda *_args, **_kwargs: _Response(json.dumps(payload).encode()), + ) + + +def _item(app_id, name, slug, platform="PC"): + return { + "item": { + "id": app_id, + "display_name": name, + "canonical_name": slug, + "platform": platform, + } + } + + +def test_owned_apps_without_a_canonical_name_are_kept(monkeypatch) -> None: + _serve( + monkeypatch, + [_item("111", "Echo VR", None), _item("222", "Lone Echo", "lone-echo")], + ) + + owned = list_owned_pcvr_apps("token") + + assert [app.name for app in owned] == ["Echo VR", "Lone Echo"] + assert owned[0].store_url == "https://www.meta.com/experiences/pcvr/111/" + assert owned[1].store_url == ( + "https://www.meta.com/experiences/pcvr/lone-echo/222/" + ) + + +def test_owned_apps_skip_other_platforms_and_nameless_items(monkeypatch) -> None: + _serve( + monkeypatch, + [ + _item("1", "Quest Game", "quest", platform="ANDROID_6DOF"), + _item("2", None, "nameless"), + _item("3", "Stormland", "stormland"), + ], + ) + + assert list_owned_pcvr_apps("token") == [ + OwnedApp(app_id="3", name="Stormland", slug="stormland") + ] + + +def test_owned_apps_report_a_list_meta_cut_short(monkeypatch) -> None: + _serve( + monkeypatch, + [_item("1", "Stormland", "stormland")], + page_info={"has_next_page": True, "end_cursor": "abc"}, + ) + + assert list_owned_pcvr_apps("token").partial is True + + +def test_owned_apps_are_complete_without_a_next_page(monkeypatch) -> None: + _serve( + monkeypatch, + [_item("1", "Stormland", "stormland")], + page_info={"has_next_page": False}, + ) + assert list_owned_pcvr_apps("token").partial is False + + _serve(monkeypatch, [_item("1", "Stormland", "stormland")]) + assert list_owned_pcvr_apps("token").partial is False diff --git a/tests/test_gui.py b/tests/test_gui.py index f131fbf..c13d7f5 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1957,6 +1957,47 @@ def test_refresh_button_forces_owned_detail_refresh( app.processEvents() +def test_a_detail_fetch_finishing_after_close_is_dropped( + tmp_path: Path, monkeypatch +) -> None: + import shiboken6 + + paths = Paths( + tmp_path / "data", + tmp_path / "cache", + tmp_path / "config", + tmp_path / "games", + tmp_path / "prefix", + tmp_path / "tools", + ) + paths.create() + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + window = Window(paths) + started, release = threading.Event(), threading.Event() + + def slow_detail(_app, refresh=False): + started.set() + release.wait(5) + return None, None + + monkeypatch.setattr(window, "_load_owned_detail", slow_detail) + crashes = [] + monkeypatch.setattr(threading, "excepthook", crashes.append) + + window.show_owned(OwnedApp(app_id="222", name="Lone Echo", slug="lone-echo")) + assert started.wait(5) + worker = next(t for t in threading.enumerate() if t.name == "riftlift-owned-detail") + # Closing the window deletes its event objects while the worker still runs. + shiboken6.delete(window.owned_detail_events) + release.set() + worker.join(5) + + assert not worker.is_alive() + assert crashes == [] + window.close() + app.processEvents() + + def test_owned_game_meta_line_keeps_saying_not_installed( tmp_path: Path, monkeypatch ) -> None: diff --git a/tests/test_owned_library.py b/tests/test_owned_library.py new file mode 100644 index 0000000..c64e5bd --- /dev/null +++ b/tests/test_owned_library.py @@ -0,0 +1,190 @@ +import os +import threading +import time +from pathlib import Path + +if os.name != "nt": + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6 import QtWidgets + +from riftlift import auth +from riftlift.auth import owned_apps, save_access_token +from riftlift.config import Paths +from riftlift.entitlements import OwnedApp, OwnedLibrary +from riftlift.main_window import Window +from riftlift.native_shell import search_words + +ICON = str(Path(__file__).parents[1] / "src/riftlift/assets/mark.svg") + + +def _wait_until(app, condition, timeout: float = 2) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if condition(): + return True + time.sleep(0.01) + return condition() + + +def _paths(tmp_path: Path) -> Paths: + paths = Paths( + *( + tmp_path / name + for name in ("data", "cache", "config", "games", "prefix", "tools") + ) + ) + paths.create() + return paths + + +def _owned_window(tmp_path, monkeypatch, owned, fetch_icon): + monkeypatch.setattr("riftlift.main_window.owned_apps", lambda _paths: (owned, [])) + monkeypatch.setattr("riftlift.main_window.fetch_owned_icon", fetch_icon) + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + window = Window(_paths(tmp_path), start_services=False) + window.refresh_owned() + return app, window + + +def _visible_titles(window) -> list[str]: + category = window._owned_category + return [ + category.child(index).text(0) + for index in range(category.childCount()) + if not category.child(index).isHidden() + ] + + +def test_owned_games_show_before_their_icons_arrive(tmp_path, monkeypatch) -> None: + release = threading.Event() + + def slow_icon(_paths, _app_id): + release.wait(5) + return ICON + + owned = [ + OwnedApp("1", "Lone Echo", "lone-echo"), + OwnedApp("2", "Stormland", "stormland"), + ] + app, window = _owned_window(tmp_path, monkeypatch, owned, slow_icon) + + assert _wait_until(app, lambda: window._owned_category.childCount() == 2) + placeholders = [ + window._owned_category.child(index).icon(0).cacheKey() for index in range(2) + ] + assert window._owned_icons == {} + + release.set() + assert _wait_until(app, lambda: set(window._owned_icons) == {"1", "2"}) + assert all( + window._owned_category.child(index).icon(0).cacheKey() != placeholders[index] + for index in range(2) + ) + window.close() + app.processEvents() + + +def test_a_failed_icon_keeps_the_owned_game_listed(tmp_path, monkeypatch) -> None: + def broken_icon(_paths, app_id): + if app_id == "1": + raise OSError("no catalog page") + return ICON + + owned = [OwnedApp("1", "Echo VR", ""), OwnedApp("2", "Stormland", "stormland")] + app, window = _owned_window(tmp_path, monkeypatch, owned, broken_icon) + + assert _wait_until(app, lambda: "2" in window._owned_icons) + assert _visible_titles(window) == ["Echo VR", "Stormland"] + assert "1" not in window._owned_icons + window.close() + app.processEvents() + + +def test_a_partial_owned_list_says_so(tmp_path, monkeypatch) -> None: + owned = OwnedLibrary([OwnedApp("1", "Stormland", "stormland")], partial=True) + app, window = _owned_window(tmp_path, monkeypatch, owned, lambda *_: None) + + assert _wait_until(app, lambda: window.owned) + assert not window.owned_hint.isHidden() + assert "Add Game" in window.owned_hint.text() + + window.owned = [] + monkeypatch.setattr( + "riftlift.main_window.owned_apps", + lambda _paths: (OwnedLibrary([OwnedApp("1", "Stormland", "stormland")]), []), + ) + window.refresh_owned() + assert _wait_until(app, lambda: window.owned_hint.isHidden()) + window.close() + app.processEvents() + + +def test_owned_apps_stays_partial_when_any_account_was_cut_short( + tmp_path, monkeypatch +) -> None: + paths = _paths(tmp_path) + save_access_token(paths, "FRL" + "a" * 176, ("meta-1", "Alpha")) + save_access_token(paths, "FRL" + "b" * 176, ("meta-2", "Beta")) + libraries = { + "a": OwnedLibrary([OwnedApp("1", "Lone Echo", "lone-echo")]), + "b": OwnedLibrary([OwnedApp("2", "Stormland", "stormland")], partial=True), + } + monkeypatch.setattr( + auth.entitlements, "list_owned_pcvr_apps", lambda token: libraries[token[3]] + ) + + owned, failures = owned_apps(paths) + assert [app.name for app in owned] == ["Lone Echo", "Stormland"] + assert owned.partial and failures == [] + + libraries["b"].partial = False + assert not owned_apps(paths)[0].partial + + +def test_library_search_ignores_word_order_accents_and_punctuation( + tmp_path, monkeypatch +) -> None: + owned = [ + OwnedApp("1", "Lone Echo", "lone-echo"), + OwnedApp("2", "Vader Immortal: Episode I", "vader-immortal-episode-i"), + OwnedApp("3", "Pokémon Café", "pokemon-cafe"), + OwnedApp("4", "Stormland", "stormland"), + ] + app, window = _owned_window(tmp_path, monkeypatch, owned, lambda *_: None) + assert _wait_until(app, lambda: window._owned_category.childCount() == 4) + + for query, expected in ( + ("echo lone", ["Lone Echo"]), + ("vader episode", ["Vader Immortal: Episode I"]), + ("immortal:", ["Vader Immortal: Episode I"]), + ("pokemon cafe", ["Pokémon Café"]), + ("storm", ["Stormland"]), + ("", ["Lone Echo", "Vader Immortal: Episode I", "Pokémon Café", "Stormland"]), + ): + window.search.setText(query) + assert _visible_titles(window) == expected, query + window.search.setText("nothing like this") + assert window.search_empty.isVisibleTo(window) + window.close() + app.processEvents() + + +def test_search_words_normalise_case_accents_and_punctuation() -> None: + assert search_words(" Pokémon: CAFÉ-Racer ") == ["pokemon", "cafe", "racer"] + assert search_words("Lucky\u2019s Tale") == search_words("lucky's tale") + assert search_words("Lucky\u2019s Tale") == ["luckys", "tale"] + assert search_words("") == [] + + +def test_search_words_fold_letters_without_an_accent_mark() -> None: + # These letters don't decompose, so stripping accents alone keeps them. + assert search_words("Ørsted Æble Łódź Œuvre Đakovo") == [ + "orsted", + "aeble", + "lodz", + "oeuvre", + "dakovo", + ] + assert search_words("Lucky\u2018s Tale") == ["luckys", "tale"]