"""Artwork for Rift games (mocked network), thumbnails, Quest/Rift twin titles, uninstall."""
import io
import zipfile
import pytest
from frameport.artwork import fetch, sources, thumbs
from frameport.core import library
def png(w=800, h=1200, color=(40, 80, 160)) -> bytes:
from PIL import Image
b = io.BytesIO()
Image.new("RGB", (w, h), color).save(b, "PNG")
return b.getvalue()
class Resp:
def __init__(self, content=b"", status=200, data=None):
self.content, self.status_code, self._data = content, status, data
def json(self):
return self._data
@pytest.fixture
def net(monkeypatch):
"""Fake OculusDB / Steam / Meta art: search results by URL substring, images by URL."""
db = {"rift": [], "all": [], "steam": [], "images": {}, "meta": {}}
def cached_json(name, url, max_age=0, fallback=None):
if "oculusdb" in url:
term = url.split("/search/")[1].split("?")[0]
rows = db["rift"] if "headsets=RIFT" in url else db["all"]
return [r for r in rows if r.get("_term", "").lower() in term.lower().replace("%20", " ")]
if "appdetails" in url:
appid = url.split("appids=")[1].split("&")[0]
return {appid: {"success": True, "data": {"steam_appid": int(appid)}}}
if "storesearch" in url:
return {"items": db["steam"]}
return fallback
def http_get(url, timeout=30):
if "by_package" in url:
pkg = url.split("package=")[1]
if pkg in db["meta"]:
return Resp(data={"status": "ok", "displayName": "Meta name", "images": [
{"image_type": "APP_IMG_COVER_PORTRAIT", "uri": __import__("base64").b64encode(png()).decode()}]})
return Resp(data={"status": "not found"})
if url in db["images"]:
return Resp(db["images"][url])
return Resp(b"", 404)
monkeypatch.setattr(sources.cache, "cached_json", cached_json)
monkeypatch.setattr(sources.cache, "http_get", http_get)
monkeypatch.setattr(fetch.cache, "http_get", http_get)
return db
def test_norm_and_terms():
assert sources.norm("Asgard's Wrath") == sources.norm("Asgards Wrath")
assert sources.norm("Lone Echo II") == sources.norm("Lone Echo 2")
assert sources.norm("Vader Immortal: Episode III") == sources.norm("Vader Immortal - Episode III")
assert "Asgard" in sources.search_terms("Asgards Wrath")
def test_quest_version_rules(net):
net["all"] = [{"_term": "robo recall", "appName": "Robo Recall: Unplugged", "packageName": "com.YC.RoboRecall"},
{"_term": "the climb", "appName": "The Climb 2", "packageName": "com.crytek.climb2"},
{"_term": "chronos", "appName": "Chrono Strike", "packageName": "com.greensky.chronostrike"}]
assert sources.match_quest("Robo Recall") == "com.YC.RoboRecall"
assert sources.match_quest("The Climb") is None # a sequel isn't the Quest version
assert sources.match_quest("Chronos") is None
def test_rift_art_chain(net):
# 1. OculusDB square cover, exact (apostrophe-insensitive) match found through a shorter search term
net["rift"] = [{"_term": "asgard", "appName": "Asgard's Wrath", "id": "111", "imageLink": "/cdn/images/111",
"canonicalName": "sanzaru-asgards-wrath"}]
net["images"][sources.OCULUSDB + "/cdn/images/111"] = png(720, 720)
found = sources.fetch_rift("rift.asgards_wrath", "Asgards Wrath")
assert found["source"] == "oculusdb" and found["title"] == "Asgard's Wrath"
assert {p.stem for p in fetch.files("rift.asgards_wrath")} == {"square"}
# 2. the Quest version's full Meta art wins when there is one
net["all"] = [{"_term": "robo recall", "appName": "Robo Recall: Unplugged", "packageName": "com.YC.RoboRecall"}]
net["meta"]["com.YC.RoboRecall"] = True
found = sources.fetch_rift("rift.robo_recall", "Robo Recall")
assert found["source"] == "meta" and found["quest_package"] == "com.YC.RoboRecall"
# 3. Steam, exact names only ("Stormlander" isn't "Stormland")
net["steam"] = [{"id": 1, "name": "Stormlander"}, {"id": 469610, "name": "Rick and Morty: Virtual Rick-ality"}]
for f in sources.STEAM_FILES.values():
net["images"][sources.STEAM_CDN.format(id=469610, file=f)] = png()
assert sources.fetch_rift("rift.stormland", "Stormland").get("source") is None
found = sources.fetch_rift("rift.rm", "Rick and Morty - Virtual Rick-ality")
assert found["source"] == "steam" and {"portrait", "hero", "landscape", "logo"} <= \
{p.stem for p in fetch.files("rift.rm")}
def test_exe_icon_fallback(net, tmp_path, monkeypatch):
exe = tmp_path / "g.exe"
exe.write_bytes(b"MZ")
monkeypatch.setattr("frameport.analysis.rift.exe_icon", lambda p: png(64, 64))
assert sources.fetch_rift("rift.none", "Totally Unknown Game", exe=exe)["source"] == "icon"
def test_thumbnails_and_urls():
d = fetch.artwork_dir("com.x")
(d / "portrait.png").write_bytes(png(1000, 1500))
url = thumbs.url("com.x", ("portrait",), 400)
assert url.startswith("/artwork/com.x/t_portrait_400_") and url.endswith(".jpg")
from PIL import Image
t = next(d.glob("t_portrait_400_*.jpg"))
assert Image.open(t).size == (400, 600)
assert thumbs.url("com.x", ("portrait",), 400) == url # cached
assert "t_portrait" not in "".join(p.name for p in fetch.files("com.x")) # thumbnails aren't artwork kinds
def test_twin_titles():
from frameport.core.titles import counterparts, display_title, twins
games = [{"package": "com.YC.RoboRecall", "title": "Robo Recall"},
{"package": "rift.robo_recall", "kind": "rift", "title": "Robo Recall", "quest_package": None},
{"package": "rift.lone_echo", "kind": "rift", "title": "Lone Echo"},
{"package": "com.x.vader", "title": "Vader Immortal: Episode I"},
{"package": "rift.vader1", "kind": "rift", "title": "Vader Immortal - Episode I",
"quest_package": "com.x.vader"}]
tw = twins(games)
assert tw == {"com.YC.RoboRecall", "rift.robo_recall", "com.x.vader", "rift.vader1"}
assert display_title(games[0], tw) == "Robo Recall (Quest)" and display_title(games[1], tw) == "Robo Recall (Rift)"
assert display_title(games[2], tw) == "Lone Echo"
assert [g["package"] for g in counterparts(games[3], games)] == ["rift.vader1"]
def test_steam_title_uses_twin_suffix():
from frameport import pipeline
library.upsert_game("com.YC.RoboRecall", title="Robo Recall")
library.upsert_game("rift.robo_recall", kind="rift", title="Robo Recall")
assert pipeline.steam_title(library.game("rift.robo_recall")) == "Robo Recall (Rift)"
# ------------------------------------------------------------------------------------------ uninstall
def test_uninstall_backs_up_keys_and_removes_data(tmp_path, monkeypatch):
from frameport import uninstall
from frameport.core.events import Reporter
from frameport.core.paths import user_data_dir
data = user_data_dir()
ks = data / "overport-workspace" / "signatures"
ks.mkdir(parents=True)
(ks / "com.x.keystore").write_bytes(b"key")
(data / "ssh").mkdir()
(data / "ssh" / "id_ed25519").write_text("secret")
library.upsert_game("com.x", title="X")
monkeypatch.setattr(uninstall, "wsl_revive_copy", lambda: None)
pl = uninstall.plan()
assert pl.keys and pl.items[0].path == str(data)
out = uninstall.run(Reporter(), backup_dir=tmp_path / "backup")
z = zipfile.ZipFile(out["backup"])
assert {"overport-workspace/signatures/com.x.keystore", "ssh/id_ed25519", "README.txt"} <= set(z.namelist())
assert not data.exists()
# what still runs after the uninstall (the job's log, a late setting, the poll) must not bring the folder back
from frameport.core import applog
applog.save_job_log("uninstall-app", None, "done", "log")
library.set_setting("ui.scale", 1.0)
user_data_dir()
assert not data.exists()
def test_uninstall_keeps_foreign_files_in_a_custom_data_folder(tmp_path):
from frameport import uninstall
from frameport.core.paths import user_data_dir
data = user_data_dir() # tests run with FRAMEPORT_HOME: a folder the user chose, maybe shared
library.upsert_game("com.x", title="X")
(data / "tools").mkdir()
(data / "my-notes.txt").write_text("not FramePort's")
assert uninstall.remove_data_dir(data) == ["my-notes.txt"]
assert (data / "my-notes.txt").exists() and not (data / "library.json").exists()
# ------------------------------------------------------------------------------------------ details / Steam art
def test_details_from_oculusdb_and_steam(net, monkeypatch):
from frameport import pipeline
from frameport.artwork import details
net["all"] = [{"_term": "com.x.game", "appName": "Game", "packageName": "com.x.game", "id": "9",
"display_long_description": "Fight robots.
Save the day.", "genre_names": ["Action"],
"publisher_name": "Pub", "website_url": "https://game.example"}]
net["steam"] = [{"id": 5, "name": "Game"}]
steam = {"5": {"success": True, "data": {"steam_appid": 5, "short_description": "Robots!",
"genres": [{"description": "Action"}, {"description": "Indie"}],
"developers": ["Dev"], "publishers": ["Pub"],
"release_date": {"date": "Apr 20, 2017"},
"screenshots": [{"path_full": "https://img/ss1.jpg"},
{"path_full": "https://img/ss2.jpg"}]}}}
real = sources.cache.cached_json
monkeypatch.setattr(details.cache, "cached_json",
lambda name, url, **k: steam if "appdetails" in url else real(name, url, **k))
net["images"]["https://img/ss1.jpg"] = png(1920, 1080)
net["images"]["https://img/ss2.jpg"] = png(1920, 1080)
monkeypatch.setattr(details.cache, "http_get", sources.cache.http_get)
library.upsert_game("com.x.game", title="Game")
d = pipeline.fetch_details("com.x.game")
assert d["description"] == "Fight robots.\nSave the day." and d["genres"] == ["Action", "Indie"]
assert (d["developer"], d["publisher"], d["release_date"]) == ("Dev", "Pub", "Apr 20, 2017")
assert d["screenshots"] == ["shot_1.jpg", "shot_2.jpg"] and sorted(d["sources"]) == ["oculusdb", "steam"]
assert [x[0] for x in details.store_links(d)] == ["Meta store", "Steam store", "game.example"]
from frameport.ui.views.library import auto_tags
assert "Indie" in auto_tags(library.game("com.x.game"))
def test_steam_set_from_square_cover_and_screenshot():
from PIL import Image
from frameport.artwork import steam
d = fetch.artwork_dir("rift.sq")
(d / "square.webp").write_bytes(png(720, 720))
(d / "shot_1.jpg").write_bytes(png(1920, 1080))
art = steam.steam_set("rift.sq")
assert set(art) == {"portrait", "landscape", "hero", "icon"}
assert Image.open(art["portrait"]).size == (600, 900) and Image.open(art["hero"]).size == (1920, 620)
assert Image.open(art["landscape"]).size == (920, 430) and Image.open(art["icon"]).size == (256, 256)
assert steam.steam_set("rift.sq") == art # cached
def test_steam_tags():
from frameport.artwork.steam import steam_tags
rift = {"kind": "rift", "details": {"genres": ["Action", "Shooter"]}, "tags": ["Favorite"],
"analysis": {"extra": {"needs_revive": True}}}
assert steam_tags(rift) == ["PC VR on Frame", "Oculus Rift", "Action", "Shooter", "Favorite"]
assert steam_tags(rift, "pc")[:2] == ["FramePort PC VR", "Oculus Rift"]
assert steam_tags({"kind": "rift"})[:2] == ["PC VR on Frame", "PC VR"] # an OpenXR/SteamVR game
assert steam_tags({"package": "com.x"})[:2] == ["Quest on Frame", "Meta Quest"]
assert steam_tags({"package": "org.flat", "analysis": {"extra": {"vr_kind": "none"}}})[:2] == \
["Android on Frame", "Android"]
def test_plain_description_drops_store_markup():
from frameport.artwork.details import plain_description
text = "[media]\n\n# Become The Knight.\n\nIt's **bold** and [a link](https://x.invalid).\n\n\n\n[media]\n\n**Hard**"
assert plain_description(text) == "Become The Knight.\n\nIt's bold and a link.\n\nHard"
def test_steam_placeholder_for_apps_without_artwork(tmp_path):
"""No store art: a name-on-colour set (with the APK's launcher icon), so Steam shows the name, not a blank tile."""
import zipfile
from PIL import Image
from frameport.artwork import fetch, steam
pkg = "org.example.flat"
assert steam.steam_set(pkg) == {} # no art and no title: nothing to make
icon = tmp_path / "icon.png"
Image.new("RGBA", (96, 96), (200, 30, 30, 255)).save(icon)
apk = tmp_path / "app.apk"
with zipfile.ZipFile(apk, "w") as z:
z.write(icon, "res/mipmap-xxxhdpi-v4/ic_launcher.png")
art = steam.steam_set(pkg, title="A Flat Little App", apk=apk)
assert set(art) == {"portrait", "landscape", "hero", "logo", "icon"}
with Image.open(art["portrait"]) as im:
assert im.size == (600, 900)
with Image.open(art["icon"]) as im: # the APK's icon is used
assert im.convert("RGB").getpixel((128, 128)) == (200, 30, 30)
with Image.open(art["logo"]) as im:
assert im.mode == "RGBA" and im.getbbox()
assert steam.steam_set(pkg, title="A Flat Little App", apk=apk) == art # cached
(fetch.artwork_dir(pkg) / "portrait.png").write_bytes(icon.read_bytes()) # real art arrives: no placeholder
assert "logo" not in steam.steam_set(pkg, title="A Flat Little App", apk=apk)
def test_no_store_details_for_android_apps_without_vr(monkeypatch):
from frameport.artwork import details
monkeypatch.setattr(details, "steam_app", lambda title: (_ for _ in ()).throw(AssertionError("looked up")))
entry = {"package": "org.example.flat", "title": "2048", "analysis": {"extra": {"vr_kind": "none"}}}
assert details.fetch_details(entry)["sources"] == []
def test_picked_art_survives_installs(monkeypatch):
"""A pick that lacks a kind (Steam: no icon) used to be overwritten by the store art at the next install."""
import base64
import io
from types import SimpleNamespace
from PIL import Image
from frameport.artwork import fetch
def png(color):
b = io.BytesIO()
Image.new("RGB", (8, 8), color).save(b, "PNG")
return b.getvalue()
store = {"status": "ok", "displayName": "G", "images": [
{"image_type": t, "uri": base64.b64encode(png("red")).decode()}
for t in ("APP_IMG_COVER_PORTRAIT", "APP_IMG_COVER_LANDSCAPE", "APP_IMG_HERO", "APP_IMG_ICON")]}
monkeypatch.setattr(fetch.cache, "http_get", lambda url, timeout=30: SimpleNamespace(json=lambda: store))
d = fetch.artwork_dir("com.picked")
(d / "portrait.png").write_bytes(png("blue")) # the user's pick (no icon)
(d / fetch.PICKED).write_text("Steam")
fetch.fetch("com.picked") # what an install does
assert (d / "portrait.png").read_bytes() == png("blue") and not (d / "icon.png").exists()
(d / fetch.PICKED).unlink() # without a pick: only missing kinds are filled, nothing is replaced
fetch.fetch("com.picked")
assert (d / "portrait.png").read_bytes() == png("blue") and (d / "icon.png").exists()
fetch.fetch("com.picked", refresh=True) # "Find automatically": the store art replaces everything
assert (d / "portrait.png").read_bytes() == png("red")
def test_generated_cover_still_gets_a_steam_placeholder():
"""The library's generated cover/banner (apps without store art, e.g. WiiCompiled) aren't store art: Steam still
gets the placeholder set (it used to get nothing at all, a blank tile in the Frame's library)."""
from PIL import Image
from frameport.artwork import fetch, steam
pkg = "org.example.generated"
d = fetch.artwork_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (600, 900)).save(d / "cover.jpg")
Image.new("RGB", (920, 430)).save(d / "banner.jpg")
art = steam.steam_set(pkg, title="Generated Cover App")
assert {"portrait", "landscape", "hero"} <= set(art)
def test_apps_without_store_art_get_a_cover(tmp_path):
"""FramePort's library shows a cover (name + APK icon) for apps no store knows; store art always wins."""
import zipfile
from PIL import Image
from frameport.artwork import fetch, steam, thumbs
from frameport.core import library
icon = tmp_path / "icon.png"
Image.new("RGBA", (96, 96), (10, 200, 30, 255)).save(icon)
apk = tmp_path / "app.apk"
with zipfile.ZipFile(apk, "w") as z:
z.write(icon, "res/mipmap-xxxhdpi-v4/ic_launcher.png")
library.upsert_game("org.example.flat", title="Flat App", apk=str(apk))
cover = steam.ensure_cover("org.example.flat")
assert cover and cover.name == "cover.jpg" and (fetch.artwork_dir("org.example.flat") / "icon.png").exists()
assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "cover.jpg"
Image.new("RGB", (60, 90)).save(fetch.artwork_dir("org.example.flat") / "portrait.jpg") # store art arrives
assert steam.ensure_cover("org.example.flat") is None
assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "portrait.jpg"
def test_converted_copies_are_removed_after_installing(tmp_path, monkeypatch):
"""Converted APKs live in FramePort's output folder only until they're on the Frame; the user's files stay."""
from frameport import pipeline
from frameport.core import library
from frameport.core.paths import output_dir
own = tmp_path / "mine.apk"
own.write_bytes(b"x")
out = output_dir() / "Game"
out.mkdir(parents=True)
(out / "com.g.apk").write_bytes(b"y" * 10)
(out / "com.g.alt-noforcequit.apk").write_bytes(b"z" * 5)
library.upsert_game("com.g", build={"apk": str(out / "com.g.apk"),
"alt_apk": str(out / "com.g.alt-noforcequit.apk")})
library.upsert_game("com.mine", build={"apk": str(own)}) # "install as is": the build is the user's own file
assert pipeline.remove_converted_copies("com.mine") == 0 and own.exists()
library.set_setting("build.keep_copies", True)
assert pipeline.remove_converted_copies("com.g") == 0
library.set_setting("build.keep_copies", False)
assert pipeline.remove_converted_copies("com.g") == 15 and not out.exists()
def test_custom_art_replaces_one_kind_and_is_kept(tmp_path):
from PIL import Image
pkg = "com.custom.art"
d = fetch.artwork_dir(pkg)
(d / "portrait.jpg").write_bytes(png(600, 900))
(d / "icon.png").write_bytes(png(128, 128))
(d / "cover.jpg").write_bytes(png(600, 900)) # FramePort's generated cover (no store art)
src = tmp_path / "mine.png"
Image.new("RGB", (1000, 1500), (200, 10, 10)).save(src)
out = sources.apply_custom(pkg, "portrait", src)
assert out.name == "portrait.jpg" and Image.open(out).size == (1000, 1500) # no alpha → JPEG
assert (d / "icon.png").exists() and not (d / "cover.jpg").exists() # only that kind is replaced
assert (d / fetch.PICKED).read_text() == "custom" # automatic fetches skip it
logo = tmp_path / "logo.webp"
Image.new("RGBA", (5000, 1000), (0, 0, 0, 0)).save(logo)
out = sources.apply_custom(pkg, "logo", logo)
assert out.name == "logo.png" and max(Image.open(out).size) == sources.CUSTOM_MAX_PX # scaled down, PNG
thumbs.thumb(d / "icon.png", 96)
sources.remove_custom(pkg, "icon")
assert not list(d.glob("icon.*")) and not list(d.glob("t_icon_*"))
@pytest.mark.parametrize("make, why", [
(lambda p: p.write_bytes(b"not an image"), "isn't an image"),
(lambda p: __import__("PIL.Image").Image.new("RGB", (20, 20)).save(p, "PNG"), "too small"),
])
def test_custom_art_rejects_bad_files(tmp_path, make, why):
bad = tmp_path / "bad.png"
make(bad)
with pytest.raises(sources.ArtError, match=why):
sources.apply_custom("com.custom.bad", "portrait", bad)
with pytest.raises(sources.ArtError):
sources.apply_custom("com.custom.bad", "screenshot", bad)