mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-19 15:01:36 +02:00
Flet 1.0 keeps toasts in the same stack as dialogs and pop_dialog() closes the topmost open entry. An install link's job shows a "waiting for your answer" toast just as its question appears; when the toast came second, "Download and install" closed the toast, the download ran, and the question stayed on screen. pop_dialog now skips toasts (reproduced and checked with real clicks in the web build). Also a too-long test docstring line.
725 lines
32 KiB
Python
725 lines
32 KiB
Python
from frameport.ui.app import install_state
|
|
|
|
|
|
def game(sha=None, alt=None):
|
|
return {"package": "com.x", "build": {"sha256": sha, "alt_sha256": alt} if sha else {}}
|
|
|
|
|
|
def frame(*deps):
|
|
return {"installed": list(deps)}
|
|
|
|
|
|
def test_not_connected():
|
|
assert install_state(game("a"), None) is None
|
|
|
|
|
|
def test_missing():
|
|
assert install_state(game("a"), frame({"package": "com.other", "sha256": "a"})) == "missing"
|
|
|
|
|
|
def test_installed_same_build_or_alt():
|
|
assert install_state(game("a", "b"), frame({"package": "com.x", "sha256": "a"})) == "installed"
|
|
assert install_state(game("a", "b"), frame({"package": "com.x", "sha256": "b"})) == "installed"
|
|
|
|
|
|
def test_installed_without_local_build():
|
|
assert install_state(game(), frame({"package": "com.x", "sha256": "z"})) == "installed"
|
|
|
|
|
|
def test_outdated():
|
|
assert install_state(game("a"), frame({"package": "com.x", "sha256": "old"})) == "outdated"
|
|
|
|
|
|
|
|
def test_outdated_after_a_newer_apk_was_added():
|
|
"""GitHub #161: re-adding a newer APK of an installed game shows "Update on Frame", not "Reinstall"."""
|
|
g = game("a")
|
|
g["build"]["source_version"] = "1.0"
|
|
g["analysis"] = {"version": "1.0"}
|
|
assert install_state(g, frame({"package": "com.x", "sha256": "a"})) == "installed"
|
|
g["analysis"]["version"] = "1.1"
|
|
assert install_state(g, frame({"package": "com.x", "sha256": "a"})) == "outdated"
|
|
|
|
# ------------------------------------------------------------------------------------------ library filters / tags
|
|
def lib_games():
|
|
return [
|
|
{"package": "com.a", "title": "Alpha", "added": 1, "recipe": {"status": "works", "patches": {}},
|
|
"analysis": {"engine": "Unity", "xr": "OpenXR"}, "tags": ["Favorite"], "data_bytes": 5},
|
|
{"package": "rift.b", "kind": "rift", "title": "Bravo", "added": 3, "recipe": {"status": "unknown"},
|
|
"analysis": {"engine": "Unreal", "xr": "LibOVR", "extra": {"data_bytes": 50}},
|
|
"last_test": {"time": 100}},
|
|
{"package": "com.c", "title": "charlie", "added": 2, "recipe": {"status": "issues",
|
|
"patches": {"patch_force_passthrough": {}}},
|
|
"analysis": {"engine": "Unreal", "xr": "VrApi"}, "installs": {"frame": {"time": 200}}, "data_bytes": 20},
|
|
]
|
|
|
|
|
|
def test_filters_and_sort():
|
|
from frameport.ui.views.library import DEFAULT_FILTERS, filter_games
|
|
|
|
games = lib_games()
|
|
f = dict(DEFAULT_FILTERS)
|
|
names = lambda fl, **kw: [g["title"] for g in filter_games(games, {**f, **fl}, **kw)] # noqa: E731
|
|
assert names({}) == ["Alpha", "Bravo", "charlie"]
|
|
assert names({"platform": "pcvr"}) == ["Bravo"] and names({"platform": "quest"}) == ["Alpha", "charlie"]
|
|
assert names({"status": "issues"}) == ["charlie"]
|
|
assert names({"q": "unreal"}) == ["Bravo", "charlie"] # search covers tags
|
|
assert names({"tags": ["favorite"]}) == ["Alpha"] and names({"tags": ["Unreal", "Mixed reality"]}) == ["charlie"]
|
|
assert names({"sort": "recent"}) == ["Bravo", "charlie", "Alpha"]
|
|
assert names({"sort": "played"}) == ["charlie", "Bravo", "Alpha"]
|
|
assert names({"sort": "size"}) == ["Bravo", "charlie", "Alpha"]
|
|
assert names({"sort": "status"}) == ["Alpha", "charlie", "Bravo"]
|
|
frame = {"installed": [{"package": "com.c"}]}
|
|
assert names({"where": "frame"}, frame_info=frame) == ["charlie"]
|
|
assert names({"where": "pc"}, pc_installs={"rift.b"}) == ["Bravo"]
|
|
assert names({"where": "none"}, frame_info=frame, pc_installs={"rift.b"}) == ["Alpha"]
|
|
|
|
|
|
def test_tags():
|
|
from frameport.ui.views.library import all_tags, auto_tags, game_tags, normalize_tag
|
|
|
|
a, b, c = lib_games()
|
|
assert auto_tags(b) == ["PC VR", "Unreal", "LibOVR"]
|
|
assert "Mixed reality" in auto_tags(c)
|
|
assert game_tags(a)[0] == "Favorite"
|
|
assert all_tags([a, b, c])[0] == "Favorite" # the user's own tags first
|
|
assert normalize_tag(" my, tag ") == "my tag"
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------ job queue
|
|
def test_jobs_run_in_order_one_at_a_time():
|
|
import threading
|
|
import time
|
|
|
|
from frameport.ui.jobs import Job, JobManager
|
|
|
|
events, running = [], []
|
|
lock = threading.Lock()
|
|
|
|
def work(name):
|
|
def run(job):
|
|
with lock:
|
|
running.append(name)
|
|
assert len(running) == 1, "two jobs ran at once"
|
|
job.reporter.stage(f"{name} stage")
|
|
job.reporter.check("thing", True, "ok")
|
|
time.sleep(0.05)
|
|
with lock:
|
|
running.remove(name)
|
|
events.append(name)
|
|
return name
|
|
return run
|
|
m = JobManager(throttle=0)
|
|
jobs = [m.submit(Job(n, work(n), package="p" if n == "a" else None)) for n in "abc"]
|
|
assert m.wait_idle(5)
|
|
assert events == ["a", "b", "c"] and [j.state for j in jobs] == ["done"] * 3
|
|
assert jobs[0].stages == ["a stage"] and jobs[0].checks[0]["ok"] is True and jobs[0].result == "a"
|
|
assert m.busy_with("p") is None
|
|
|
|
|
|
def test_job_cancel_and_failure():
|
|
import time
|
|
|
|
from frameport.ui.jobs import Job, JobManager
|
|
|
|
def slow(job):
|
|
for _ in range(100):
|
|
job.reporter.check_cancel()
|
|
time.sleep(0.02)
|
|
|
|
def boom(job):
|
|
raise RuntimeError("no Frame")
|
|
m = JobManager(throttle=0)
|
|
first = m.submit(Job("slow", slow, package="x"))
|
|
queued = m.submit(Job("later", slow))
|
|
failing = m.submit(Job("boom", boom))
|
|
time.sleep(0.1)
|
|
assert m.busy_with("x") is first and first.state == "running"
|
|
m.cancel(queued) # still queued: never runs
|
|
m.cancel(first)
|
|
assert m.wait_idle(5)
|
|
assert (first.state, queued.state, failing.state) == ("cancelled", "cancelled", "failed")
|
|
assert failing.error == "no Frame"
|
|
m.clear_finished()
|
|
assert m.jobs == []
|
|
|
|
|
|
def test_files_tree():
|
|
from frameport.ui.views import files_dialog as fd
|
|
|
|
files = [["game/Bin/Game.exe", 4], ["game/Bin/Data.pak", 10], ["launch.sh", 1], ["game/Readme.txt", 2]]
|
|
root = fd.build_tree("Install folder", files)
|
|
assert (root.size, root.files) == (17, 4)
|
|
game = root.children["game"]
|
|
assert (game.size, game.files) == (16, 3)
|
|
# folders first, then files; only expanded folders show their children
|
|
assert [(d, n.name) for d, n in fd.visible_rows(root, set())] == [(0, "game"), (0, "launch.sh")]
|
|
rows = fd.visible_rows(root, {"game", "game/Bin"})
|
|
assert [(d, n.name) for d, n in rows] == [(0, "game"), (1, "Bin"), (2, "Data.pak"), (2, "Game.exe"),
|
|
(1, "Readme.txt"), (0, "launch.sh")]
|
|
assert fd.matches(files, ".PAK") == [["game/Bin/Data.pak", 10]]
|
|
assert fd.human(3 * 2**30) == "3.0 GiB" and fd.human(512) == "512 B"
|
|
|
|
|
|
def test_files_tree_caps_children():
|
|
from frameport.ui.views import files_dialog as fd
|
|
|
|
root = fd.build_tree("x", [[f"f{i:04}", 1] for i in range(fd.MAX_CHILDREN + 5)])
|
|
rows = fd.visible_rows(root, set())
|
|
assert len(rows) == fd.MAX_CHILDREN + 1 and rows[-1] == (0, 5)
|
|
|
|
|
|
def test_install_state_tracks_patch_settings():
|
|
from frameport.ui import components as C
|
|
|
|
g = {"package": "rift.g", "kind": "rift", "recipe": {"patches": {"pcvr.revive": {}, "pcvr.xr_timefix": {}}}}
|
|
fi = {"installed": [{"package": "rift.g", "recipe": {"patches": ["pcvr.revive", "pcvr.xr_timefix"]}}]}
|
|
assert C.install_state(g, fi) == "installed" and C.settings_diff(g, fi) is None
|
|
g["recipe"]["patches"].pop("pcvr.revive")
|
|
g["recipe"]["patches"]["pcvr.no_crash_reporter"] = {}
|
|
assert C.install_state(g, fi) == "outdated"
|
|
assert C.settings_diff(g, fi) == (["pcvr.no_crash_reporter"], ["pcvr.revive"])
|
|
old = {"installed": [{"package": "rift.g"}]} # installed before recipes were recorded: no false alarm
|
|
assert C.install_state(g, old) == "installed"
|
|
assert C.install_state(g, {"installed": []}) == "missing"
|
|
|
|
|
|
def test_game_on_a_missing_sd_card_is_still_installed():
|
|
"""GitHub #90: a game whose microSD card is out has no files to see, but it is installed (not "Not installed")."""
|
|
from frameport.ui import components as C
|
|
|
|
sd = {"internal": False, "path": "/run/media/steamos/SD", "label": "SD"}
|
|
gone = frame({"package": "com.x", "sha256": "a", "apk_present": False, "drive": sd, "drive_missing": True})
|
|
assert install_state(game("a"), gone) == "installed"
|
|
assert C.drive_note(C.frame_drive(game("a"), gone)) == " · SD not inserted"
|
|
here = frame({"package": "com.x", "sha256": "a", "apk_present": True, "drive": sd, "drive_missing": False})
|
|
assert C.drive_note(C.frame_drive(game("a"), here)) == " · on SD"
|
|
internal = frame({"package": "com.x", "sha256": "a", "drive": {"internal": True, "path": "/home/steamos",
|
|
"label": "Internal storage"}})
|
|
assert C.drive_note(C.frame_drive(game("a"), internal)) == ""
|
|
assert C.frame_drive(game("a"), frame({"package": "com.x"})) is None # an agent before v63
|
|
assert install_state(game("a"), frame({"package": "com.x", "sha256": "a", "apk_present": False})) == "missing"
|
|
|
|
|
|
def test_library_writes_from_many_threads_are_not_lost():
|
|
"""Every thread's change survives (library.json read-modify-write used to race between jobs and the UI)."""
|
|
import threading
|
|
|
|
from frameport.core import library
|
|
|
|
def work(i):
|
|
for j in range(20):
|
|
library.upsert_game(f"pkg.{i}", n=j)
|
|
library.update_setting("counter", lambda v: (v or 0) + 1)
|
|
threads = [threading.Thread(target=work, args=(i,)) for i in range(6)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
assert {g["package"] for g in library.games() if g["package"].startswith("pkg.")} == {f"pkg.{i}" for i in range(6)}
|
|
assert library.setting("counter") == 120
|
|
|
|
|
|
def test_refresh_keeps_a_working_connection_when_a_status_query_fails():
|
|
"""An agent error during the background refresh must not close the connection a running job is using."""
|
|
from types import SimpleNamespace
|
|
|
|
from frameport.frame.connection import AgentFailed
|
|
from frameport.ui.app import FramePortApp
|
|
|
|
closed = []
|
|
|
|
class Target:
|
|
def __init__(self, alive):
|
|
self.frame = SimpleNamespace(alive=lambda: alive)
|
|
|
|
def describe(self):
|
|
raise AgentFailed("agent info: boom")
|
|
|
|
def close(self):
|
|
closed.append(self)
|
|
|
|
def app_with(target):
|
|
app = object.__new__(FramePortApp)
|
|
app.target, app.frame_state, app.frame_info = target, "connected", {"installed": []}
|
|
app.route = ("settings",)
|
|
app._refresh_sidebar = lambda *a, **k: None
|
|
app.toast = lambda *a, **k: None
|
|
return app
|
|
|
|
up = app_with(Target(alive=True))
|
|
up.refresh_frame(quiet=True, background=False)
|
|
assert up.frame_state == "connected" and not closed
|
|
down = app_with(Target(alive=False))
|
|
down.refresh_frame(quiet=True, background=False)
|
|
assert down.frame_state == "offline" and closed == [down.target]
|
|
|
|
|
|
def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monkeypatch):
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from frameport.frame import pairing
|
|
|
|
monkeypatch.setattr(pairing, "local_ip_towards", lambda *a: "127.0.0.1")
|
|
monkeypatch.setattr(pairing, "MAX_FAILURES", 3)
|
|
|
|
def get(server, path):
|
|
try:
|
|
return urllib.request.urlopen(f"http://127.0.0.1:{server.port}{path}", timeout=5).status
|
|
except urllib.error.HTTPError as e:
|
|
return e.code
|
|
except OSError:
|
|
return None
|
|
|
|
seen = []
|
|
s = pairing.PairingServer(on_paired=seen.append).start()
|
|
other = pairing.PairingServer().start() # a second server (port taken) falls back to another port
|
|
assert other.port != s.port
|
|
other.stop()
|
|
assert len(s.code) == 8
|
|
assert s.one_liner == f"curl -fsS 127.0.0.1:{s.port}/{s.code} | bash" # typed by hand on the Frame: short
|
|
assert s.requests == 0
|
|
assert get(s, f"/{s.code}") == 200 and get(s, "/deadbeef") == 403 # the short form serves the script
|
|
assert s.requests == 2 # any request (even a wrong code) proves the Frame can reach us: no firewall hint
|
|
script = urllib.request.urlopen(f"http://127.0.0.1:{s.port}/{s.code}", timeout=5).read().decode()
|
|
assert f"http://127.0.0.1:{s.port}" in script and s.code in script and "__PAIR_CODE__" not in script
|
|
assert get(s, "/key?code=000000") == 403
|
|
assert get(s, f"/paired?code={s.code}&user=steamos&host=frame") == 200 and seen
|
|
for _ in range(50):
|
|
if not s.running:
|
|
break
|
|
time.sleep(0.05)
|
|
assert not s.running
|
|
flag = Path(tempfile.mkdtemp()) / "pairing.flag" # WSL: the temporary firewall rule lives while this exists
|
|
flag.write_text("x")
|
|
f = pairing.PairingServer().start()
|
|
f.flag = flag
|
|
f.stop()
|
|
assert not flag.exists()
|
|
g = pairing.PairingServer().start()
|
|
for _ in range(3):
|
|
get(g, "/key?code=guess")
|
|
for _ in range(50):
|
|
if not g.running:
|
|
break
|
|
time.sleep(0.05)
|
|
assert not g.running
|
|
|
|
|
|
def test_update_all_asks_each_question_once_for_all_games(monkeypatch):
|
|
"""Update all = one batch per target: the Oculus-on-Frame question is one dialog with a checkbox per game (it used
|
|
to come once per game, each with a single checkbox), "Can't run" games are updated too."""
|
|
from types import SimpleNamespace
|
|
|
|
from frameport.core import library
|
|
from frameport.ui.app import FramePortApp
|
|
|
|
games = {f"rift.g{i}": {"package": f"rift.g{i}", "kind": "rift", "title": f"G{i}", "analysis": {},
|
|
"recipe": {"patches": {"pcvr.revive": {}}, "status": "unsupported" if i == 2 else "works"}}
|
|
for i in range(3)}
|
|
games["com.quest"] = {"package": "com.quest", "title": "Q", "analysis": {}, "recipe": {"patches": {}}}
|
|
monkeypatch.setattr(library, "game", games.get)
|
|
dialogs, submitted = [], []
|
|
app = object.__new__(FramePortApp)
|
|
app.page = SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None)
|
|
app.jobs = SimpleNamespace(busy_with=lambda p: None)
|
|
app.library_view = None
|
|
app.toast = lambda *a, **k: None
|
|
app.show_activity = lambda *a: None
|
|
app._title = lambda p: games[p]["title"]
|
|
app._submit_install = lambda p, to: submitted.append((p, to))
|
|
app.updatable = lambda: [("rift.g0", "frame"), ("com.quest", "frame"), ("rift.g1", "frame"), ("rift.g2", "frame"),
|
|
("rift.g0", "pc")]
|
|
app.update_all()
|
|
assert len(dialogs) == 1 # one question for the three Oculus games on the Frame
|
|
boxes = [c for c in dialogs[0].content.content.controls if c.__class__.__name__ == "Checkbox"]
|
|
assert sorted(b.label for b in boxes) == ["G0", "G1", "G2"]
|
|
for b in boxes:
|
|
b.value = True
|
|
app.update_all() # a second click while the question is open starts nothing
|
|
assert len(dialogs) == 1
|
|
ok = dialogs[0].actions[-1]
|
|
ok.on_click(None)
|
|
ok.on_click(None) # double click while the dialog closes: acts once
|
|
dialogs[0].on_dismiss(None) # the close event after the button: ignored
|
|
assert sorted(p for p, to in submitted if to == "frame") == ["com.quest", "rift.g0", "rift.g1", "rift.g2"]
|
|
assert submitted.count(("rift.g0", "pc")) == 1 # the PC batch follows once the Frame batch is queued
|
|
assert not app._asking
|
|
|
|
|
|
def test_closing_an_install_question_without_a_button_cancels_it(monkeypatch):
|
|
from types import SimpleNamespace
|
|
|
|
from frameport.core import library
|
|
from frameport.ui.app import FramePortApp
|
|
|
|
g = {"package": "rift.g", "kind": "rift", "title": "G", "analysis": {}, "recipe": {"patches": {"pcvr.revive": {}}}}
|
|
monkeypatch.setattr(library, "game", {"rift.g": g}.get)
|
|
dialogs, submitted = [], []
|
|
app = object.__new__(FramePortApp)
|
|
app.page = SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None)
|
|
app.jobs = SimpleNamespace(busy_with=lambda p: None)
|
|
app.library_view, app.toast, app._title = None, lambda *a, **k: None, lambda p: "G"
|
|
app._submit_install = lambda p, to: submitted.append(p)
|
|
app.install_many(["rift.g"], "frame")
|
|
dialogs[0].on_dismiss(None) # Esc
|
|
assert not app._asking and not submitted
|
|
app.install_many(["rift.g"], "frame") # a later install can ask again
|
|
assert len(dialogs) == 2
|
|
|
|
|
|
def test_activity_pins_the_running_job_above_a_long_queue():
|
|
"""With 40 waiting installs the running one used to be listed after all of them (or cut off at 30)."""
|
|
from types import SimpleNamespace
|
|
|
|
from frameport.ui.jobs import Job, JobManager
|
|
from frameport.ui.views.activity import ActivityPanel
|
|
|
|
jm = JobManager(save_logs=False)
|
|
done = [Job(f"done {i}", run=lambda j: None, state="done", created=i, finished=100 + i) for i in range(25)]
|
|
running = Job("running", run=lambda j: None, state="running", created=50, started=50)
|
|
queued = [Job(f"queued {i}", run=lambda j: None, created=60 + i) for i in range(40)]
|
|
jm.jobs = [*done, running, *queued]
|
|
order = jm.recent(20)
|
|
assert order[0] is running and order[1:41] == queued and len(order) == 61
|
|
assert [j.title for j in order[41:43]] == ["done 24", "done 23"] # finished: newest first, at most 20
|
|
panel = ActivityPanel(SimpleNamespace(jobs=jm, job_followups=lambda j: [], copy=None, show_log_file=None,
|
|
show_activity=None, transit_info=lambda j: {"title": j.title}))
|
|
panel.root.width = 400 # open
|
|
panel.refresh(update=False)
|
|
assert len(panel.pinned.controls) == 1 # the running job, outside the scrolling list
|
|
assert panel.list.controls[0].controls[0].value == "Waiting (40)"
|
|
|
|
|
|
def test_queue_waits_for_the_frame_instead_of_failing_every_install():
|
|
"""A job that loses the Frame goes back to the front and the queue pauses; resume() continues it."""
|
|
import time
|
|
|
|
from paramiko.ssh_exception import NoValidConnectionsError
|
|
|
|
from frameport.ui.jobs import Job, JobManager
|
|
|
|
jm = JobManager(save_logs=False)
|
|
runs, online = [], {"up": False}
|
|
|
|
def install(name):
|
|
def run(job):
|
|
runs.append(name)
|
|
if not online["up"]:
|
|
raise NoValidConnectionsError({("10.0.0.9", 22): OSError("down")})
|
|
return "ok"
|
|
return run
|
|
|
|
a = jm.submit(Job("A", install("A"), "a", "install", needs_frame=True))
|
|
b = jm.submit(Job("B", install("B"), "b", "install", needs_frame=True))
|
|
for _ in range(200):
|
|
if jm.paused:
|
|
break
|
|
time.sleep(0.01)
|
|
assert jm.paused == "frame" and a.state == "queued" and b.state == "queued" and runs == ["A"]
|
|
assert jm.has_frame_work()
|
|
online["up"] = True
|
|
jm.resume()
|
|
assert jm.wait_idle(5) and a.state == b.state == "done" and runs == ["A", "A", "B"]
|
|
assert a.retries == 1 and not jm.has_frame_work()
|
|
pc = jm.submit(Job("PC", install("PC"), "p", "install")) # doesn't use the Frame: fails normally
|
|
online["up"] = False
|
|
jm.wait_idle(5)
|
|
assert pc.state in ("done", "failed") and not jm.paused
|
|
|
|
|
|
def test_free_space_is_checked_before_queueing(monkeypatch, tmp_path):
|
|
from types import SimpleNamespace
|
|
|
|
from frameport.core import library
|
|
from frameport.ui.app import FramePortApp
|
|
|
|
apk = tmp_path / "g.apk"
|
|
apk.write_bytes(b"x" * 1000)
|
|
games = {p: {"package": p, "title": p, "apk": str(apk), "data_bytes": 3 * 2**30, "analysis": {},
|
|
"recipe": {"patches": {}}} for p in ("com.a", "com.b")}
|
|
monkeypatch.setattr(library, "game", games.get)
|
|
dialogs, submitted = [], []
|
|
app = object.__new__(FramePortApp)
|
|
app.page = SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None)
|
|
app.jobs = SimpleNamespace(busy_with=lambda p: None)
|
|
app.library_view, app.toast, app._title = None, lambda *a, **k: None, lambda p: p
|
|
app._submit_install = lambda p, to: submitted.append(p)
|
|
app.frame_info = {"free_bytes": 5 * 2**30, "installed": [{"package": "com.b"}]} # com.b: update, no data
|
|
assert app.upload_estimate(["com.a", "com.b"]) == 3 * 2**30 + 2000
|
|
app.install_many(["com.a", "com.b"], "frame")
|
|
assert submitted == ["com.a", "com.b"] and not dialogs # fits (5 GiB free, 1 GiB spare)
|
|
app.frame_info["free_bytes"] = 3 * 2**30
|
|
submitted.clear()
|
|
app.install_many(["com.a", "com.b"], "frame")
|
|
assert not submitted and len(dialogs) == 1 # asks first
|
|
dialogs[0].actions[-1].on_click(None) # Install anyway
|
|
assert submitted == ["com.a", "com.b"] and not app._asking
|
|
|
|
|
|
def test_free_space_check_uses_the_chosen_drive(monkeypatch, tmp_path):
|
|
"""GitHub #205: with new games going to the SD card, internal storage's free space doesn't matter."""
|
|
from types import SimpleNamespace
|
|
|
|
from frameport.core import library
|
|
from frameport.install import drives
|
|
from frameport.ui.app import FramePortApp
|
|
|
|
apk = tmp_path / "g.apk"
|
|
apk.write_bytes(b"x" * 1000)
|
|
games = {"com.a": {"package": "com.a", "title": "A", "apk": str(apk), "data_bytes": 30 * 2**30, "analysis": {},
|
|
"recipe": {"patches": {}}}}
|
|
monkeypatch.setattr(library, "game", games.get)
|
|
monkeypatch.setattr(drives, "install_dest", lambda: "/run/media/steamos/SD/FramePort")
|
|
dialogs, submitted = [], []
|
|
app = object.__new__(FramePortApp)
|
|
app.page = SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None)
|
|
app.jobs = SimpleNamespace(busy_with=lambda p: None)
|
|
app.library_view, app.toast, app._title = None, lambda *a, **k: None, lambda p: p
|
|
app._submit_install = lambda p, to: submitted.append(p)
|
|
app.frame_info = {"free_bytes": 2 * 2**30, "installed": []} # internal storage is nearly full
|
|
app.frame_drives = [
|
|
{"internal": True, "path": "/home", "free_bytes": 2 * 2**30},
|
|
{"internal": False, "path": "/run/media/steamos/SD", "install_dir": "/run/media/steamos/SD/FramePort",
|
|
"label": "SD", "free_bytes": 400 * 2**30}]
|
|
app.install_many(["com.a"], "frame")
|
|
assert submitted == ["com.a"] and not dialogs # the card has room
|
|
app.frame_drives[1]["free_bytes"] = 20 * 2**30
|
|
submitted.clear()
|
|
app.install_many(["com.a"], "frame")
|
|
assert not submitted and len(dialogs) == 1 # the card is too small: asks first
|
|
|
|
|
|
def test_activity_progress_ticks_only_touch_the_progress_controls(monkeypatch):
|
|
"""Progress several times a second must not rebuild or re-diff the whole panel (that made the app sluggish)."""
|
|
from types import SimpleNamespace
|
|
|
|
from frameport.ui import components as C
|
|
from frameport.ui.jobs import Job, JobManager
|
|
from frameport.ui.views.activity import ActivityPanel
|
|
|
|
jm = JobManager(save_logs=False)
|
|
run = Job("running", run=lambda j: None, state="running", created=1, started=1, fraction=0.1,
|
|
stage="Upload APK", stages=["Patching the game", "Prepare Frame", "Upload APK"])
|
|
jm.jobs = [run, *[Job(f"q{i}", run=lambda j: None, created=2 + i) for i in range(30)]]
|
|
info = lambda j: {"title": "Game", "dest": "Frame", "art": None, "icon": None} # noqa: E731
|
|
panel = ActivityPanel(SimpleNamespace(jobs=jm, job_followups=lambda j: [], copy=None, show_log_file=None,
|
|
transit_info=info))
|
|
panel.root.width = 400
|
|
updated = []
|
|
monkeypatch.setattr(C, "update", lambda *controls: updated.append(controls))
|
|
panel.refresh()
|
|
first_list = list(panel.list.controls)
|
|
assert updated[-1] == (panel.root,) # the first time: everything
|
|
run.fraction = 0.5
|
|
panel.refresh()
|
|
transit = panel._live[run.id][0] # the install transit: (step 3 + half the upload) / 6 steps
|
|
assert transit.frac == 3.5 / 6 and panel.root not in updated[-1] and transit.control in updated[-1]
|
|
assert transit.detail.value == "50%"
|
|
assert panel.list.controls == first_list # nothing rebuilt
|
|
|
|
|
|
def test_flet_updates_are_serialized():
|
|
import threading
|
|
|
|
from flet.controls.value_types import Prop
|
|
from flet.messaging.session import Session
|
|
|
|
from frameport.ui.app import serialize_flet_updates
|
|
|
|
original, original_set = Session.patch_control, Prop.__set__
|
|
try:
|
|
active, peak = [0], [0]
|
|
guard = threading.Lock()
|
|
|
|
def fake(self, *a, **k):
|
|
with guard:
|
|
active[0] += 1
|
|
peak[0] = max(peak[0], active[0])
|
|
threading.Event().wait(0.01)
|
|
with guard:
|
|
active[0] -= 1
|
|
Session.patch_control = fake
|
|
serialize_flet_updates()
|
|
serialize_flet_updates() # idempotent
|
|
threads = [threading.Thread(target=Session.patch_control, args=(None,)) for _ in range(8)]
|
|
for th in threads:
|
|
th.start()
|
|
for th in threads:
|
|
th.join()
|
|
assert peak[0] == 1
|
|
finally:
|
|
Session.patch_control, Prop.__set__ = original, original_set
|
|
|
|
|
|
def test_serialize_survives_moved_flet_internals(monkeypatch, caplog):
|
|
"""A Flet version without value_types.Prop (or Session.patch_control) must not stop the GUI from starting: that
|
|
part is skipped and logged, the other still applies."""
|
|
import logging
|
|
|
|
import flet.controls.value_types as value_types
|
|
from flet.messaging.session import Session
|
|
|
|
from frameport.ui import app
|
|
|
|
original, original_set = Session.patch_control, value_types.Prop.__set__
|
|
try:
|
|
Session.patch_control = lambda self, *a, **k: None
|
|
monkeypatch.delattr(value_types, "Prop")
|
|
with caplog.at_level(logging.WARNING):
|
|
app.serialize_flet_updates()
|
|
assert getattr(Session.patch_control, "_serialized", False)
|
|
assert "property writes couldn't be serialized" in caplog.text
|
|
monkeypatch.undo()
|
|
monkeypatch.delattr(Session, "patch_control")
|
|
app.serialize_flet_updates() # nothing to wrap: no exception
|
|
finally:
|
|
monkeypatch.undo()
|
|
Session.patch_control, value_types.Prop.__set__ = original, original_set
|
|
|
|
|
|
def test_pop_dialog_closes_the_dialog_not_a_toast(monkeypatch):
|
|
"""An install link's question stayed open after "Download and install": its job's toast had appeared above it and
|
|
page.pop_dialog() closed the toast instead."""
|
|
from types import SimpleNamespace
|
|
|
|
import flet as ft
|
|
from flet.controls.base_page import BasePage
|
|
|
|
from frameport.ui import app
|
|
|
|
monkeypatch.setattr(BasePage, "pop_dialog", BasePage.pop_dialog) # restored after the test
|
|
app.pop_dialog_skips_toasts()
|
|
app.pop_dialog_skips_toasts() # idempotent
|
|
|
|
class Dialog(ft.AlertDialog):
|
|
def update(self): # (not on a page)
|
|
pass
|
|
|
|
class Toast(ft.SnackBar):
|
|
def update(self):
|
|
pass
|
|
dialog, toast = Dialog(open=True), Toast(ft.Text("waiting for your answer"), open=True)
|
|
page = SimpleNamespace(_dialogs=SimpleNamespace(controls=[dialog, toast]))
|
|
assert BasePage.pop_dialog(page) is dialog
|
|
assert not dialog.open and toast.open
|
|
assert BasePage.pop_dialog(page) is None # only a toast left: nothing to close
|
|
|
|
|
|
def test_property_writes_wait_for_a_patch_being_packed():
|
|
"""A property set on a background thread (the live Frame card, job progress) while the event loop packs a patch
|
|
changed the control's _values mid-iteration: "dictionary changed size during iteration" killed the update (a
|
|
sidebar click didn't change the page). Property writes take the patch lock now."""
|
|
import threading
|
|
import time
|
|
|
|
import flet as ft
|
|
import msgpack
|
|
from flet.controls.value_types import Prop
|
|
from flet.messaging.protocol import configure_encode_object_for_msgpack
|
|
from flet.messaging.session import Session
|
|
|
|
from frameport.ui import app
|
|
|
|
original, original_set = Session.patch_control, Prop.__set__
|
|
try:
|
|
Session.patch_control = lambda self, *a, **k: None # (a fresh, unwrapped one, so serialize applies here)
|
|
app.serialize_flet_updates()
|
|
# a write waits while a patch holds the lock, and lands right after
|
|
text = ft.Text("a")
|
|
with app.FLET_LOCK:
|
|
th = threading.Thread(target=setattr, args=(text, "tooltip", "tip"))
|
|
th.start()
|
|
th.join(0.2)
|
|
assert th.is_alive() and text.tooltip is None
|
|
th.join(2)
|
|
assert text.tooltip == "tip"
|
|
# the race itself: pack a page-sized tree with Flet's encoder while another thread flips properties
|
|
texts = [ft.Text(f"t{i}") for i in range(200)]
|
|
root = ft.Column([ft.Container(t) for t in texts])
|
|
encode = configure_encode_object_for_msgpack(ft.BaseControl)
|
|
stop, errors = threading.Event(), []
|
|
|
|
def writer():
|
|
i = 0
|
|
while not stop.is_set():
|
|
t = texts[i % len(texts)]
|
|
t.tooltip = None if t.tooltip else "tip" # adds / removes a key in _values
|
|
i += 1
|
|
th = threading.Thread(target=writer, daemon=True)
|
|
th.start()
|
|
end = time.monotonic() + 1.5
|
|
packs = 0
|
|
while time.monotonic() < end:
|
|
try:
|
|
with app.FLET_LOCK: # what the serialized patch_control holds
|
|
msgpack.packb(root, default=encode)
|
|
packs += 1
|
|
except RuntimeError as exc:
|
|
errors.append(str(exc))
|
|
stop.set()
|
|
th.join(2)
|
|
assert packs > 50 and errors == []
|
|
# the loop's own handlers set properties during a patch: re-entrant, no deadlock
|
|
with app.FLET_LOCK:
|
|
text.color = "red"
|
|
assert text.color == "red"
|
|
finally:
|
|
Session.patch_control, Prop.__set__ = original, original_set
|
|
|
|
|
|
def test_window_sessions_share_one_job_queue():
|
|
"""Every Flet session (a window reconnect builds a new app object) uses the same queue: a job started in an
|
|
old session is visible and the next one waits for it, instead of a second build of the same game at once."""
|
|
from frameport.ui import jobs
|
|
|
|
first, second = jobs.shared(), jobs.shared()
|
|
assert first is second
|
|
seen = []
|
|
listener = seen.append
|
|
first.subscribe(listener)
|
|
first.unsubscribe(listener)
|
|
first.unsubscribe(listener) # twice is harmless
|
|
assert listener not in first._listeners
|
|
|
|
|
|
def test_one_build_per_game_at_a_time():
|
|
from frameport import pipeline
|
|
|
|
assert pipeline._build_lock("com.x.y") is pipeline._build_lock("com.x.y")
|
|
assert pipeline._build_lock("com.x.y") is not pipeline._build_lock("com.x.z")
|
|
|
|
|
|
def test_window_geometry_restores_size_position_and_maximized():
|
|
from frameport.ui.app import window_geometry
|
|
|
|
first = window_geometry(None, 1.25)
|
|
assert first == {"width": 1600, "height": 975} # no position: the system places it
|
|
got = window_geometry({"size": [1400, 900], "pos": [320, 140], "maximized": True}, 1.0)
|
|
assert got == {"width": 1400, "height": 900, "left": 320, "top": 140, "maximized": True}
|
|
assert window_geometry({"size": [800, 500]}, 1.0) == {"width": 1000, "height": 680} # minimum size
|
|
# a monitor to the left (negative x) is fine; far off or malformed positions are ignored (GitHub #32)
|
|
assert window_geometry({"pos": [-1800, 50]}, 1.0)["left"] == -1800
|
|
for bad in ([-20000, 0], [100, -400], [99999, 0], ["a", 1], [1]):
|
|
assert "left" not in window_geometry({"pos": bad}, 1.0)
|
|
|
|
|
|
def test_superseded_patch_is_not_an_update():
|
|
"""A build that left haptic_fix out because the OVRPort runtime already fixes it (patches/upstream.py) showed
|
|
"Update on Frame" forever: the recipe has the patch, the Frame's recorded recipe doesn't (VR HOT, Vader)."""
|
|
from frameport.ui import components as C
|
|
|
|
g = {"package": "com.x.y", "recipe": {"patches": {"frame.adapter": {}, "adapter.haptic_fix": {"value": 1}}},
|
|
"build": {"sha256": "aa", "superseded": {"adapter.haptic_fix": "ovrport.haptic_envelope"}}}
|
|
from frameport.patches.base import recipe_fingerprint
|
|
|
|
g["build"]["recipe_fp"] = recipe_fingerprint(g["recipe"])
|
|
fi = {"installed": [{"package": "com.x.y", "sha256": "aa", "recipe": {"patches": ["frame.adapter"]}}]}
|
|
assert C.settings_diff(g, fi) is None and C.install_state(g, fi) == "installed"
|
|
g["build"]["superseded"] = {}
|
|
assert C.settings_diff(g, fi) == (["adapter.haptic_fix"], [])
|