Files
spoopyghosty0--frameport/tests/test_art.py
T
spoopyghosty0 fc8443fc56 Store details: refetch after Rename, reject adult Steam matches (#377)
An import named after its code name ("Lollipop") matched an adult Steam game by exact title, and the
screenshots stayed after the user renamed it. Details now record whether they were matched by package or
by title; a rename (GUI, CLI, --reset) drops title-matched details and screenshots and fetches them again
under the new name, keeping package-matched details and custom art. Steam matches whose
content_descriptors mark sexual content are treated as no match, for details and artwork.
2026-10-17 14:36:26 -04:00

415 lines
20 KiB
Python

"""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 <b>robots</b>.<br/>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)