diff --git a/src/ui/baseUserInterface.py b/src/ui/baseUserInterface.py index d9805ac..9603185 100644 --- a/src/ui/baseUserInterface.py +++ b/src/ui/baseUserInterface.py @@ -27,8 +27,12 @@ class BaseUserInterface(KitBaseUserInterface): - def __init__(self, currentPrompt: Prompt, timeService: TimeService, player: Player): - super().__init__(currentPrompt, header=self._buildHeader) + def __init__( + self, currentPrompt: Prompt, timeService: TimeService, player: Player, **kwargs + ): + # kwargs travel on to the next kit class in the MRO: the web front-end + # takes its title, address and server switches through here. + super().__init__(currentPrompt, header=self._buildHeader, **kwargs) self.timeService = timeService self.player = player diff --git a/src/ui/pyodideUserInterface.py b/src/ui/pyodideUserInterface.py index 11c6949..de6b161 100644 --- a/src/ui/pyodideUserInterface.py +++ b/src/ui/pyodideUserInterface.py @@ -1,128 +1,21 @@ # @author Daniel McCoy Stephenson -"""FishE's browser-native front-end: the game itself runs in the player's tab. - -WebUserInterface puts the game on a server and the browser polls it, which means -one server-side game and one set of server-side save files shared by everyone -who opens the page. This front-end instead runs the whole Python game inside a -Web Worker under Pyodide, so every tab has its own game and its own saves — the -latter kept in the browser's IndexedDB (see browserSaveSync and -web/game-worker.js). - -Only the transport differs from WebUserInterface, which this class subclasses: -every screen the player sees is still built by WebUserInterface's primitives, so -a new screen type is written once and appears in both. Here a screen is posted -to the main thread as a JSON string, and the player's response is read from the -SharedArrayBuffer ring buffer that web/index.html writes into. - -Why a ring buffer rather than Worker messages: the game loop is synchronous, so -it blocks the Worker while waiting for input, and a blocked Worker can never run -an onmessage handler. Shared memory is readable without the event loop, so the -main thread can hand input to a blocked game. +"""The browser-native front-end: the whole game runs in the player's tab under +Pyodide, with saves in that browser's IndexedDB. The Worker bridge, the input +ring and the screens now live in tak (tak.ui.pyodide); this module is FishE's +adapter with the (currentPrompt, timeService, player) constructor. """ -import json -import time - -from browserSaveSync import getJsModule -from ui.webUserInterface import WebUserInterface - - -# How long to sleep between checks of the input ring while waiting on the -# player. Short enough to feel instant, long enough that a player reading a -# screen isn't spun on. time.sleep() in Pyodide yields via Atomics.wait, so -# this genuinely idles the Worker rather than burning the tab's CPU. -INPUT_POLL_INTERVAL_SECONDS = 0.02 - -_MESSAGE_TERMINATOR = 10 # b"\n" — the ring's message boundary - - -class SharedArrayBufferBridge: - """Talks to the JavaScript side of the Pyodide front-end. - - web/game-worker.js installs the globals used here before starting the game: - ``sendToMain`` (post a message to the main thread), and ``sabMeta`` / - ``sabData`` / ``sabRingSize`` / ``Atomics`` for the input ring buffer. - """ +from tak.ui import pyodide as kitPyodide +from tak.ui.pyodide import ( # noqa: F401 - re-exported + INPUT_POLL_INTERVAL_SECONDS, + SharedArrayBufferBridge, +) - def __init__(self, js=None): - if js is None: - js = getJsModule() - if js is None: - raise RuntimeError( - "The Pyodide front-end can only run inside a browser: the 'js' " - "module it needs exists only under Pyodide. To play in a " - "browser, serve the game with 'python3 web/serve.py' and open " - "the page it prints. To play from a terminal or a desktop " - "window instead, use UIType.CONSOLE or UIType.PYGAME." - ) - missing = [ - name - for name in ("sendToMain", "Atomics", "sabMeta", "sabData", "sabRingSize") - if getattr(js, name, None) is None - ] - if missing: - raise RuntimeError( - "The Pyodide front-end is missing the JavaScript globals its " - f"Worker is supposed to install: {', '.join(missing)}. This " - "happens when the game is started by something other than " - "web/game-worker.js — start it from that Worker (web/index.html " - "does), or use UIType.CONSOLE outside a browser." - ) - self._js = js - self._ringSize = int(js.sabRingSize) +from ui.baseUserInterface import BaseUserInterface +from ui.webUserInterface import TAGLINE, TIP, TITLE - def postScreen(self, screen): - """Send a screen to the main thread for rendering. - - Sent as a JSON string rather than a converted JS object: the screen is - a plain nested dict/list structure, and a string crosses the Worker - boundary without any Pyodide FFI conversion to get wrong. - """ - self._js.sendToMain(json.dumps({"type": "screen", "screen": screen})) - - def readInput(self): - """Return the player's next response, or None if none has arrived yet.""" - line = self._readLine() - if not line: - return None - try: - message = json.loads(line) - except ValueError: - # Not something this front-end sent; ignoring it keeps a stray - # write from ending the wait with a bogus response. - return None - if message.get("type") != "input": - return None - return message.get("value", "") - - def _readLine(self): - """Pull one newline-terminated message out of the ring buffer.""" - Atomics = self._js.Atomics - meta = self._js.sabMeta - data = self._js.sabData - - writeIndex = int(Atomics.load(meta, 0)) - readIndex = int(Atomics.load(meta, 1)) - if writeIndex == readIndex: - return "" - - raw = bytearray() - while readIndex != writeIndex: - byte = int(data[readIndex % self._ringSize]) - readIndex += 1 - if byte == _MESSAGE_TERMINATOR: - break - raw.append(byte) - Atomics.store(meta, 1, readIndex) - # Player-entered text (a business name, say) can be any UTF-8; a partial - # sequence is dropped rather than raising in the middle of the game. - return raw.decode("utf-8", errors="ignore") - - -# @author Daniel McCoy Stephenson -class PyodideUserInterface(WebUserInterface): - """WebUserInterface's screens, delivered over the Pyodide Worker bridge.""" +class PyodideUserInterface(BaseUserInterface, kitPyodide.PyodideUserInterface): def __init__( self, currentPrompt, @@ -131,28 +24,13 @@ def __init__( bridge=None, pollIntervalSeconds=INPUT_POLL_INTERVAL_SECONDS, ): - # start_server=False: there is no server here, and no sockets to bind - # in the browser. get_state()/submit_input() still work, which keeps the - # inherited screen bookkeeping (and its tests) intact. - super().__init__(currentPrompt, timeService, player, start_server=False) - self._bridge = bridge if bridge is not None else SharedArrayBufferBridge() - self._pollIntervalSeconds = pollIntervalSeconds - - def _present(self, screen): - # Record it the way WebUserInterface does (version bookkeeping, and so a - # late-attaching reader can still ask for the current screen), then push - # it to the browser instead of waiting to be polled for it. - super()._present(screen) - self._bridge.postScreen(screen) - - def _awaitInput(self): - # The queue is still honoured so submit_input() works for tests and for - # anything driving the interface directly; the ring buffer is what the - # browser actually writes to. - while True: - if not self._inputQueue.empty(): - return self._inputQueue.get() - value = self._bridge.readInput() - if value is not None: - return value - time.sleep(self._pollIntervalSeconds) + super().__init__( + currentPrompt, + timeService, + player, + title=TITLE, + tagline=TAGLINE, + tip=TIP, + bridge=bridge, + pollIntervalSeconds=pollIntervalSeconds, + ) diff --git a/src/ui/webUserInterface.py b/src/ui/webUserInterface.py index ab923ba..b1e83cd 100644 --- a/src/ui/webUserInterface.py +++ b/src/ui/webUserInterface.py @@ -1,428 +1,62 @@ -import errno -import json -import os -import queue -import threading -import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -from ui.baseUserInterface import BaseUserInterface -from prompt.prompt import Prompt -from player.player import Player -from world.timeService import TimeService -from housing import housing - - -# The browser client (renderer + styles) is shared verbatim with the Pyodide -# front-end, which serves the same two files from web/. Keeping one copy is what -# stops a screen type from being handled in one web front-end but not the other. -# They are inlined into the page below rather than served as separate routes so -# the server stays a two-route affair and the page needs a single request. -WEB_ASSET_DIRECTORY = os.path.normpath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "web") -) - -# How long cleanup() gives the browser to collect the ended screen before it -# closes the server, and how often it checks whether that has happened. -# -# The client only discovers a new screen on its next poll, so shutting the -# socket the instant the ended screen is published closes it before the screen -# can be fetched: a player who had just retired was shown "Lost connection" -# instead of the end of their run. The wait ends as soon as the screen has -# actually gone out (typically within one poll interval), so the timeout is -# only ever paid when nobody is listening - a closed tab, or a game driven by -# something other than a browser. -ENDED_SCREEN_DELIVERY_TIMEOUT_SECONDS = 2.0 -ENDED_SCREEN_DELIVERY_POLL_SECONDS = 0.02 - +# @author Daniel McCoy Stephenson +"""The server-backed browser front-end: the game runs here, the browser is a +terminal for it. The server, the rendezvous and every screen now live in tak +(tak.ui.web.WebUserInterface); this module is FishE's adapter - the +(currentPrompt, timeService, player) constructor, FishE's page copy, and the +FISHE_WEB_HOST / FISHE_WEB_PORT variables. +""" -def _readWebAsset(name): - """Read a shared browser-client file from web/, or explain why it could not.""" - path = os.path.join(WEB_ASSET_DIRECTORY, name) - try: - with open(path, "r", encoding="utf-8") as assetFile: - return assetFile.read() - except OSError as e: - raise RuntimeError( - f"FishE's web front-end could not read its browser client " - f"'{name}' (looked in {path}): {e}. That file ships in the " - f"repository's web/ directory, alongside src/. Run the game from a " - f"complete checkout, or — if this is a container image — make sure " - f"the image copies web/ in as well as src/." - ) from e +from tak.ui import web as kitWeb +from tak.ui.web import DEFAULT_HOST, DEFAULT_PORT # noqa: F401 - re-exported +from tak.ui.web import ENDED_SCREEN_DELIVERY_TIMEOUT_SECONDS +from ui.baseUserInterface import BaseUserInterface -# Read on first use rather than at import, and cached after. This module is -# imported by the Pyodide front-end too (PyodideUserInterface subclasses the -# class below), and there these files are NOT on the filesystem: the browser -# fetches them over HTTP, so only the server-backed front-end has any reason to -# read them. Reading them at import time made merely importing this module fail -# inside the Worker. -_clientAssetCache = {} -_pageCache = {} +TITLE = "FishE" +TAGLINE = "fish a seaside village and build a fortune of $10,000" +TIP = "Tip: click an option or press its number key (1-9). Enter or Space continues." +ENV_PREFIX = "FISHE" -def _clientAsset(name): - if name not in _clientAssetCache: - _clientAssetCache[name] = _readWebAsset(name) - return _clientAssetCache[name] +def resolveAddressFromEnvironment(): + """The (host, port) to serve on, from FISHE_WEB_HOST / FISHE_WEB_PORT, or a + ValueError naming the variable that is wrong.""" + return kitWeb.resolveAddressFromEnvironment(ENV_PREFIX) def htmlPage(): - """The single-page client, built on first use. - - It polls /state and renders whatever screen the game is currently waiting - on, posting the player's response to /input. Served as-is (no templating); - it talks to the server via relative URLs. - """ - if "page" in _pageCache: - return _pageCache["page"] - page = ( - """ - -
- - -Tip: click an option or press its number key (1-9). Enter or Space continues.
- - - -""" - ) - _pageCache["page"] = page - return page + """The single-page client with FishE's heading, built on first use.""" + return kitWeb.htmlPage(TITLE, TAGLINE, TIP) def __getattr__(name): - """Keep HTML_PAGE readable as a module attribute; the page is built lazily - so a missing web/ directory doesn't break import.""" + """Keep HTML_PAGE readable as a module attribute.""" if name == "HTML_PAGE": return htmlPage() raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -# The address this front-end serves on when nothing says otherwise. Stated -# here, beside the server that binds it, so the factory branch that reads the -# environment and the constructor below share one copy of the default rather -# than each carrying their own - a copy elsewhere would keep announcing the old -# value if this one ever moved, with nothing failing to say so. (web/serve.py -# deliberately keeps a different default of its own; see the comment there.) -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 8000 - - -def resolveAddressFromEnvironment(): - """The (host, port) to serve on, or a ValueError naming the variable that is - wrong. - - FISHE_WEB_HOST/FISHE_WEB_PORT are how a player moves the game off the - default loopback address - e.g. FISHE_WEB_HOST=0.0.0.0 so a container's - port mapping or reverse proxy can reach it.""" - host = os.environ.get("FISHE_WEB_HOST", DEFAULT_HOST) - portText = os.environ.get("FISHE_WEB_PORT", str(DEFAULT_PORT)) - try: - port = int(portText) - except ValueError: - raise ValueError(f"FISHE_WEB_PORT must be an integer, got: {portText!r}") - return host, port - - -def _bindServer(host, port, handler): - """Start the game's HTTP server, turning a refused address into a sentence. - - The front-end does not exist yet at this point, so there is no showDialogue - to say it through: a port that is already listening - a second copy of the - game, most often - would otherwise reach the player as an errno raised from - inside http.server, naming neither FishE nor FISHE_WEB_PORT, which is what - they would have to change.""" - try: - return ThreadingHTTPServer((host, port), handler) - except OSError as e: - if e.errno == errno.EADDRINUSE: - reason = "something else is already listening there" - elif e.errno == errno.EACCES: - reason = "this process is not allowed to use that port" - else: - reason = str(e) - raise OSError( - f"FishE's web front-end could not be served at http://{host}:{port}/: " - f"{reason}. Set FISHE_WEB_PORT to a free port (or FISHE_WEB_HOST to " - f"an address this machine can bind) and start the game again." - ) from e - - -def _makeRequestHandler(ui): - """Build a request handler bound to a specific WebUserInterface instance.""" - - class _Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path in ("/", "/index.html"): - self._send(200, "text/html; charset=utf-8", htmlPage().encode("utf-8")) - elif self.path.startswith("/state"): - state = ui.get_state() - self._send(200, "application/json", json.dumps(state).encode("utf-8")) - # Recorded only once the bytes are on the wire, so cleanup() - # cannot close the server out from under a response it is - # still writing (see record_state_delivered). - ui.record_state_delivered(state["version"]) - else: - self._send(404, "text/plain", b"Not found") - - def do_POST(self): - if self.path.startswith("/input"): - length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(length) if length else b"{}" - try: - value = json.loads(raw or b"{}").get("value", "") - except (ValueError, TypeError): - value = "" - ui.submit_input(value) - self._send(200, "application/json", b"{}") - else: - self._send(404, "text/plain", b"Not found") - - def _send(self, status, contentType, body): - self.send_response(status) - self.send_header("Content-Type", contentType) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass # keep the game's stdout clean - - return _Handler - - -# @author Daniel McCoy Stephenson -class WebUserInterface(BaseUserInterface): - """A browser-based front-end for FishE. - - The synchronous game loop is unchanged: each input primitive publishes the - current screen and blocks until the browser submits a response, coordinated - through a thread-safe rendezvous. A small stdlib HTTP server (run in a daemon - thread) serves the screen state (GET /state) and the page (GET /), and - accepts the player's response (POST /input).""" - +class WebUserInterface(BaseUserInterface, kitWeb.WebUserInterface): def __init__( self, - currentPrompt: Prompt, - timeService: TimeService, - player: Player, + currentPrompt, + timeService, + player, host=DEFAULT_HOST, port=DEFAULT_PORT, start_server=True, endedScreenTimeoutSeconds=ENDED_SCREEN_DELIVERY_TIMEOUT_SECONDS, ): - super().__init__(currentPrompt, timeService, player) - self._lock = threading.Lock() - self._screen = {"type": "loading"} - self._version = 0 - # The newest screen version the browser has been handed. Starts below - # the first version so "nothing has been collected yet" is a state - # cleanup() can tell apart from "the current screen has been seen". - self._deliveredVersion = -1 - self._endedScreenTimeoutSeconds = endedScreenTimeoutSeconds - self._inputQueue = queue.Queue() - self._server = None - if start_server: - self._server = _bindServer(host, port, _makeRequestHandler(self)) - self._server.daemon_threads = True - threading.Thread(target=self._server.serve_forever, daemon=True).start() - self._announceAddress() - - @property - def address(self): - """The (host, port) the server is bound to, or None if not started.""" - return self._server.server_address if self._server else None - - def _announceAddress(self): - """Say where the game can be played, once it is actually being served. - - Said from here rather than from the entry point that started the game - (examples/web_app.py) because building FishE starts this server and - then blocks in the save-file manager, so nothing gets control back - there to announce a bound address - and announcing one beforehand names - an address that a misspelled or already-taken port means will never - answer. The address named is the socket's own, so a caller that asked - for port 0 is told the port it actually got.""" - boundHost, boundPort = self._server.server_address[:2] - print(f"FishE is being served at http://{boundHost}:{boundPort}/") - print("Open that URL in your browser to play. Press Ctrl+C here to stop.") - - # --- web rendezvous --------------------------------------------------- - def get_state(self): - """Snapshot of the current screen for the browser to render.""" - with self._lock: - return {"version": self._version, "screen": self._screen} - - def submit_input(self, value): - """Deliver the player's browser response to the waiting game thread.""" - self._inputQueue.put(value) - - def record_state_delivered(self, version): - """Note that the browser has been sent the screen at this version. - - Only cleanup() reads this, to know the ended screen reached the page - before the server is closed. Kept as the highest version seen so an - overlapping poll that finishes late cannot walk it backwards.""" - with self._lock: - self._deliveredVersion = max(self._deliveredVersion, version) - - def _awaitScreenDelivery(self, timeout): - """Block until the current screen has been sent to the browser. - - Returns True if it went out, False if the timeout ran out first - - which is the ordinary outcome when no page is polling.""" - deadline = time.monotonic() + timeout - while True: - with self._lock: - if self._deliveredVersion >= self._version: - return True - if time.monotonic() >= deadline: - return False - time.sleep(ENDED_SCREEN_DELIVERY_POLL_SECONDS) - - # --- transport seams --------------------------------------------------- - # Every screen below is built once and shared by both web front-ends; only - # how a screen reaches the browser (_present) and how the response comes - # back (_awaitInput) differ. PyodideUserInterface overrides just these two, - # so a new screen type never has to be written twice. - def _present(self, screen): - with self._lock: - self._screen = screen - self._version += 1 - - def _awaitInput(self): - """Block until the browser submits a response, and return it.""" - return self._inputQueue.get() - - def _header(self): - return { - "day": self.timeService.day, - "time": self.times[self.timeService.time], - "money": float(self.player.money), - "fish": self.player.fishCount, - "energy": self.player.energy, - "maxEnergy": housing.maxEnergy(self.player), - "location": self.currentLocationName, - "goal": self.goalProgress, - "operator": self.player.operatorMode, - } - - # --- BaseUserInterface primitives ------------------------------------ - def lotsOfSpace(self): - # The browser renders a fresh screen each time; nothing to clear. - pass - - def divider(self): - pass - - def showOptions(self, descriptor, optionList, unavailableOptions=None): - # "unavailable" is a list parallel to "options" - the reason each one - # can't be picked, or null. The browser greys those buttons out and - # shows the reason on the row; sending the reason as data rather than - # baked into the label is what lets it be styled apart from the option. - reasons = self.unavailableReasons(optionList, unavailableOptions) - self._present( - { - "type": "options", - "descriptor": descriptor, - "prompt": self.currentPrompt.text, - "options": list(optionList), - "unavailable": reasons, - "header": self._header(), - } + super().__init__( + currentPrompt, + timeService, + player, + title=TITLE, + tagline=TAGLINE, + tip=TIP, + envPrefix=ENV_PREFIX, + host=host, + port=port, + start_server=start_server, + endedScreenTimeoutSeconds=endedScreenTimeoutSeconds, ) - valid = self.selectableNumbers(reasons) - while True: - choice = str(self._awaitInput()) - if choice in valid: - return choice - # ignore anything that isn't a selectable option and keep waiting - - def showDialogue(self, text): - self._present({"type": "dialogue", "text": text}) - self._awaitInput() - self.currentPrompt.text = "What would you like to do?" - - def promptForText(self, promptText): - self._present({"type": "prompt", "text": promptText}) - return str(self._awaitInput()) - - def promptForNumber(self, promptText): - # Flag the prompt as numeric so the browser can offer a numeric keyboard - # and block submission of non-numbers (the base default can't say so). - self._present({"type": "prompt", "text": promptText, "numeric": True}) - try: - return float(self._awaitInput()) - except (ValueError, TypeError): - return None - - def showBusy(self, message, seconds=1.0): - # Published as its own screen type so the browser shows the message - # instead of sitting on the previous screen. No input is consumed — - # whatever the game presents next supersedes it. - self._present({"type": "busy", "message": message}) - time.sleep(seconds) - - def timedKeyPress(self, message): - self._present({"type": "timed", "message": message}) - startTime = time.time() - self._awaitInput() - return time.time() - startTime - - def cleanup(self): - self._present({"type": "ended"}) - if self._server is not None: - # Hold the server open until the page has the ended screen; - # otherwise the run's last screen is never fetched and the browser - # reports a lost connection instead. - self._awaitScreenDelivery(self._endedScreenTimeoutSeconds) - self._server.shutdown() - self._server.server_close() - self._server = None diff --git a/tests/ui/test_pyodideUserInterface.py b/tests/ui/test_pyodideUserInterface.py index 2c78bc7..6e23e5e 100644 --- a/tests/ui/test_pyodideUserInterface.py +++ b/tests/ui/test_pyodideUserInterface.py @@ -122,9 +122,12 @@ def lastScreen(js): def test_pyodide_ui_implements_interface(fakeJs): ui = makePyodideUI(fakeJs) assert isinstance(ui, BaseUserInterface) - # It shares WebUserInterface's screens rather than redefining them, which - # is what keeps the two web front-ends from drifting apart. - assert isinstance(ui, WebUserInterface) + # It shares the kit's WebUserInterface screens rather than redefining + # them, which is what keeps the two web front-ends from drifting apart. + # (FishE's WebUserInterface is a sibling adapter, not an ancestor.) + from tak.ui.web import WebUserInterface as KitWebUserInterface + + assert isinstance(ui, KitWebUserInterface) def test_no_http_server_is_started(fakeJs): @@ -154,8 +157,9 @@ def test_posted_screens_carry_the_shared_header(fakeJs): ui.showOptions("The Docks", ["Fish"]) header = lastScreen(fakeJs)["header"] - assert header["day"] == ui.timeService.day - assert header["maxEnergy"] >= header["energy"] + assert header["chips"][0]["text"] == "Day %d" % ui.timeService.day + assert any(chip["text"].startswith("Energy: ") for chip in header["chips"]) + assert header["title"].startswith("FishE - Day") def test_present_still_updates_the_state_snapshot(fakeJs): @@ -242,39 +246,23 @@ def test_ring_wraps_around_without_losing_a_message(fakeJs): # --- independence from the server-backed front-end's files --------------- -def test_the_front_end_works_with_no_web_directory_on_disk(fakeJs, monkeypatch): - """Regression: the Worker's filesystem has src/ but not web/client.*. - - web/client.js and web/client.css reach the browser over HTTP, so they are - not on the filesystem the Python game sees. WebUserInterface reads them to - build its own page, and PyodideUserInterface subclasses it — so reading - them at import time made the game fail to start in a real browser with - FileNotFoundError on /game/web/client.css. Nothing on this front-end's path - may touch them. - """ - from ui import webUserInterface +def test_the_front_end_never_builds_the_server_backed_page(fakeJs, monkeypatch): + """Regression: the Worker's filesystem has the game but not the page + assets (the browser fetches those over HTTP from /tak/). Nothing on this + front-end's path may read them - the kit guards the read itself; here we + pin that FishE's adapter does not trigger it.""" + import tak.web + from tak.ui import web as kitWeb - monkeypatch.setattr(webUserInterface, "WEB_ASSET_DIRECTORY", "/no/such/directory") - monkeypatch.setattr(webUserInterface, "_clientAssetCache", {}) - monkeypatch.setattr(webUserInterface, "_pageCache", {}) + monkeypatch.setattr(tak.web, "ASSET_DIRECTORY", "/no/such/directory") + monkeypatch.setattr(kitWeb, "_clientAssetCache", {}) ui = makePyodideUI(fakeJs) fakeJs.writePlayerInput("1") assert ui.showOptions("The Docks", ["Fish", "Leave"]) == "1" assert lastScreen(fakeJs)["options"] == ["Fish", "Leave"] - - -def test_the_server_backed_page_is_not_built_unless_it_is_asked_for(fakeJs): - # The page is what needs those files; building it eagerly is what put the - # read on every importer's path. - from ui import webUserInterface - - webUserInterface._pageCache.clear() - - makePyodideUI(fakeJs) - - assert webUserInterface._pageCache == {} + assert ui._pageCache is None # --- construction errors ------------------------------------------------- diff --git a/tests/ui/test_webUserInterface.py b/tests/ui/test_webUserInterface.py index b276315..a7d8590 100644 --- a/tests/ui/test_webUserInterface.py +++ b/tests/ui/test_webUserInterface.py @@ -42,7 +42,9 @@ def makeWebUI(start_server=False, port=0, endedScreenTimeoutSeconds=0.1): def runInThread(fn): box = {} - thread = threading.Thread(target=lambda: box.__setitem__("result", fn())) + thread = threading.Thread( + target=lambda: box.__setitem__("result", fn()), daemon=True + ) thread.start() return thread, box @@ -64,12 +66,19 @@ def test_web_ui_implements_interface(): def test_header_includes_max_energy(): - # check - the header exposes the current tier's cap alongside the raw - # energy value, so the client can always show "X/Y" instead of just "X" + # check - the header shows the current tier's cap alongside the raw + # energy value, so the client can always show "X/Y" instead of just "X". + # The header is chips now (tak's contract): text the console prints, plus + # a class the browser styles - "low" once the player is too tired to fish. ui = makeWebUI() - header = ui._header() - assert header["energy"] == ui.player.energy - assert header["maxEnergy"] == housing.maxEnergy(ui.player) + chips = ui.header()["chips"] + energy = "Energy: %d/%d" % (ui.player.energy, housing.maxEnergy(ui.player)) + assert {"text": energy, "class": ""} in chips + ui.player.energy = 3 + assert { + "text": "Energy: 3/%d" % housing.maxEnergy(ui.player), + "class": "low", + } in ui.header()["chips"] def test_showOptions_round_trips_a_choice(): @@ -79,7 +88,7 @@ def test_showOptions_round_trips_a_choice(): waitForScreen(ui, "options") screen = ui.get_state()["screen"] assert screen["options"] == ["Apple", "Banana"] - assert "header" in screen and "day" in screen["header"] + assert screen["header"]["chips"][0]["text"] == "Day %d" % ui.timeService.day ui.submit_input("2") thread.join(timeout=2) diff --git a/tests/web/test_buildZip.py b/tests/web/test_buildZip.py index 082a4ca..525a987 100644 --- a/tests/web/test_buildZip.py +++ b/tests/web/test_buildZip.py @@ -1,70 +1,62 @@ +"""web/build_zip.py is a thin call into tak.web.bundle; these tests pin what +FishE puts in the bundle.""" + import os import zipfile -from web.build_zip import build +from tak.web.bundle import build -REPOSITORY_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +from web.build_zip import REPOSITORY_ROOT -def buildBundle(tmp_path, monkeypatch): - # The bundle stores repo-relative paths, so it has to be built from the - # repository root - that is what makes the unpacked tree in the browser - # match a checkout. - monkeypatch.chdir(REPOSITORY_ROOT) +def buildBundle(tmp_path): outputPath = tmp_path / "game.zip" - build(outputPath=str(outputPath)) + build( + REPOSITORY_ROOT, + outputPath=str(outputPath), + extraFiles=("version.txt", "web/pyodide_main.py"), + ) with zipfile.ZipFile(outputPath) as bundle: - return bundle.namelist() - - -def test_bundle_carries_the_game(tmp_path, monkeypatch): - names = buildBundle(tmp_path, monkeypatch) + return set(bundle.namelist()) - assert os.path.join("src", "fishE.py") in names - assert os.path.join("src", "ui", "pyodideUserInterface.py") in names - assert os.path.join("src", "browserSaveSync.py") in names +def test_bundle_carries_the_game(tmp_path): + names = buildBundle(tmp_path) + assert "src/fishE.py" in names + assert "src/location/docks.py" in names -def test_bundle_carries_the_usage_reporting_modules(tmp_path, monkeypatch): - # fishE.py imports both at module level, so a bundle without them fails - # to start at all - even though the browser build never reports. - names = buildBundle(tmp_path, monkeypatch) - - assert os.path.join("src", "usageReporting.py") in names - assert os.path.join("src", "trace_client.py") in names - assert "version.txt" in names +def test_bundle_carries_the_usage_reporting_modules(tmp_path): + names = buildBundle(tmp_path) + assert "src/usageReporting.py" in names + assert "src/trace_client.py" in names -def test_bundle_carries_the_schemas_the_save_readers_validate_against( - tmp_path, monkeypatch -): - # The *JsonReaderWriter modules resolve these paths relative to the cwd the - # Worker chdir's into, so a bundle without them fails every save load. - names = buildBundle(tmp_path, monkeypatch) +def test_bundle_carries_the_schemas_the_save_readers_validate_against(tmp_path): + names = buildBundle(tmp_path) for schema in ("player.json", "stats.json", "timeService.json"): - assert os.path.join("schemas", schema) in names + assert "schemas/" + schema in names, schema -def test_bundle_carries_the_worker_entry_point(tmp_path, monkeypatch): - names = buildBundle(tmp_path, monkeypatch) - - assert os.path.join("web", "pyodide_main.py") in names - - -def test_bundle_carries_the_shared_browser_client(tmp_path, monkeypatch): - # The page fetches these over HTTP, so the browser does not need them from - # the bundle - but webUserInterface reads them from the filesystem, and the - # Pyodide front-end subclasses it. Shipping them means that read can never - # be the thing that fails inside the Worker. - names = buildBundle(tmp_path, monkeypatch) +def test_bundle_carries_the_worker_entry_point_and_version(tmp_path): + names = buildBundle(tmp_path) + assert "web/pyodide_main.py" in names + assert "version.txt" in names - assert os.path.join("web", "client.js") in names - assert os.path.join("web", "client.css") in names +def test_bundle_carries_the_kit_under_src(tmp_path): + # One sys.path entry (/game/src) covers the game and the kit it imports. + names = buildBundle(tmp_path) + for module in ( + "__init__.py", + "ui/pyodide.py", + "saves/manager.py", + "progression.py", + ): + assert "src/tak/" + module in names, module -def test_bundle_excludes_build_artifacts(tmp_path, monkeypatch): - names = buildBundle(tmp_path, monkeypatch) - assert not [name for name in names if "__pycache__" in name] - assert not [name for name in names if name.endswith(".pyc")] +def test_bundle_excludes_build_artifacts(tmp_path): + names = buildBundle(tmp_path) + assert not any(name.endswith(".pyc") or "__pycache__" in name for name in names) + assert "web/game.zip" not in names diff --git a/tests/web/test_clientParity.py b/tests/web/test_clientParity.py deleted file mode 100644 index 524af82..0000000 --- a/tests/web/test_clientParity.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Both web front-ends must render from the same browser client. - -FishE has three front-ends behind one contract, and the two browser ones are -the easiest pair to let drift: a screen type handled in the server-backed page -but not in the Pyodide one looks fine in local testing and blank in production. -Neither front-end owning its own copy of the renderer is what prevents that, so -these tests assert the sharing itself rather than any particular screen. -""" - -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - -from ui import webUserInterface # noqa: E402 - -REPOSITORY_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) -WEB_DIRECTORY = os.path.join(REPOSITORY_ROOT, "web") - - -def readWebFile(name): - with open(os.path.join(WEB_DIRECTORY, name), encoding="utf-8") as webFile: - return webFile.read() - - -def test_server_backed_page_inlines_the_shared_client(): - page = webUserInterface.HTML_PAGE - - assert readWebFile("client.js") in page - assert readWebFile("client.css") in page - - -def test_pyodide_page_links_the_shared_client(): - page = readWebFile("index.html") - - assert "/web/client.js" in page - assert "/web/client.css" in page - - -def test_both_front_ends_hand_the_client_a_transport(): - # FisheClient.init(sendFn) is the seam: the renderer is shared, how a - # response gets back to the game is not. - assert "FisheClient.init(" in webUserInterface.HTML_PAGE - assert "FisheClient.init(" in readWebFile("index.html") - - -def test_a_missing_client_file_explains_where_it_should_be(): - try: - webUserInterface._readWebAsset("no-such-client.js") - raised = None - except RuntimeError as e: - raised = e - - assert raised is not None - message = str(raised) - assert "web/" in message - assert "no-such-client.js" in message - - -def test_the_shared_client_consumes_the_unavailable_contract(): - # WebUserInterface.showOptions publishes a reason per option; a contract - # the server sends and the client ignores would render every option as - # pickable, which is exactly what the greying-out is for. Both browser - # front-ends load this one file, so checking it covers both. - client = readWebFile("client.js") - - assert "screen.unavailable" in client - assert "disabled = true" in client - assert "unavailable" in readWebFile("client.css") diff --git a/tests/web/test_serve.py b/tests/web/test_serve.py index d471a69..280e0e1 100644 --- a/tests/web/test_serve.py +++ b/tests/web/test_serve.py @@ -1,141 +1,97 @@ +"""web/serve.py is a thin call into tak.web.serve; these tests pin what FishE +asks of it - its page, its title in errors, its variables, its port default - +and that the kit's assets reach the browser from this repository's checkout. +The server's own behaviour is tested in tak.""" + +import os +import socket import threading import urllib.error import urllib.request import pytest -from web.serve import _bindServer, _Handler, _resolvePort, _Server - - -def startServer(): - server = _Server(("127.0.0.1", 0), _Handler) - threading.Thread(target=server.serve_forever, daemon=True).start() - host, port = server.server_address[0], server.server_address[1] - return server, f"http://{host}:{port}" - +from tak.web import serve as kitServe -def get(url): - return urllib.request.urlopen(url, timeout=5) +from web.serve import REPOSITORY_ROOT -def test_serves_the_game_page(): - server, base = startServer() +@pytest.fixture +def server(): + httpd = kitServe.bindServer(REPOSITORY_ROOT, "FishE", "FISHE", "127.0.0.1", 0) + threading.Thread(target=httpd.serve_forever, daemon=True).start() try: - response = get(base + "/") - body = response.read().decode("utf-8") + yield "http://127.0.0.1:%d" % httpd.server_address[1] finally: - server.shutdown() - server.server_close() + httpd.shutdown() + httpd.server_close() - assert response.status == 200 - assert "game-worker.js" in body +def get(base, path): + return urllib.request.urlopen(base + path, timeout=5) -def test_cross_origin_isolation_headers_are_present(): - # Without these the page is not cross-origin isolated, SharedArrayBuffer is - # undefined, and the player's input can never reach the blocked game Worker. - server, base = startServer() - try: - response = get(base + "/") - finally: - server.shutdown() - server.server_close() +@pytest.mark.parametrize("path", ["/", "/play", "/index.html"]) +def test_serves_fishes_page_with_isolation_headers(server, path): + response = get(server, path) + body = response.read().decode("utf-8") + assert "Tip: click an option or press its number key (1-9). Enter or Space continues. Your saved games live in this browser.
- + +