"""The terminal must cover the real router contract, including form parameters.""" import ast import json import re import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[4] COMMANDS = ROOT / "devplacepy/static/js/GameCommands.js" def command_specs(): source = COMMANDS.read_text(encoding="utf-8") body = source.split("static API = Object.freeze(", 1)[1].split(");", 1)[0] return json.loads(body) def game_routes(): routes = {} for filename in ("index.py", "farm.py"): tree = ast.parse((ROOT / "devplacepy/routers/game" / filename).read_text(encoding="utf-8")) for node in tree.body: if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)): continue for decorator in node.decorator_list: if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute): continue if decorator.func.attr in ("get", "post"): routes[(decorator.func.attr.upper(), "/game" + ast.literal_eval(decorator.args[0]))] = node return routes def test_terminal_covers_every_current_game_route(): exposed = {(spec["method"], spec["path"]) for spec in command_specs()} assert exposed == set(game_routes()) def test_terminal_parameters_match_game_form_models(): models = ast.parse((ROOT / "devplacepy/models/game.py").read_text(encoding="utf-8")) fields = { node.name: {field.target.id for field in node.body if isinstance(field, ast.AnnAssign)} for node in models.body if isinstance(node, ast.ClassDef) } for spec in command_specs(): route = game_routes()[(spec["method"], spec["path"])] for argument in route.args.args: if argument.arg != "data": continue model = argument.annotation.slice.elts[0].id assert set(spec.get("params", {})) | set(spec.get("fixed", {})) == fields[model], spec for variable in re.findall(r"\{(\w+)\}", spec["path"]): assert variable in spec["args"] def test_terminal_parser_slots_permissions_and_completion(tmp_path): node = shutil.which("node") if not node: pytest.skip("Node is unavailable; command interactions are also covered in browser tests.") # Browsers treat this file as an ES module; Node 18 needs an explicit suffix. module = tmp_path / "GameCommands.mjs" module.write_bytes(COMMANDS.read_bytes()) script = r''' import assert from "node:assert/strict"; const { GameCommands: C } = await import(MODULE_URL); const farm = { is_owner: true, max_plots: 12, plots: Array.from({length:12},(_,slot)=>({slot,state:slot%2 ? "ready":"empty"})), crops:[{key:"python"},{key:"shell"}], perks:[{key:"growth"}], legacy:[{key:"speed"}], mastery:[{key:"analytics"}], infrastructure:[{key:"registry"}], cosmetics:[{key:"skin_neon"}], quests:[{kind:"plant",scope:"daily"}] }; const cases = [ ["plant 1 python","/game/plant",{slot:0,crop:"python"}], ["spinup 12 shell","/game/plant",{slot:11,crop:"shell"}], ["harvest 12","/game/harvest",{slot:11}], ["stop 2","/game/plot/stop",{slot:1,stop:"true"}], ["resume 2","/game/plot/stop",{slot:1,stop:"false"}], ["buy server","/game/buy-plot",{}], ["ci upgrade","/game/upgrade",{}], ["boost 3","/game/fertilize",{slot:2}], ["daily","/game/daily",{}], ["grant","/game/grant",{}], ["perk growth","/game/perk",{perk:"growth"}], ["refactor","/game/prestige",{}], ["legacy speed","/game/legacy",{key:"speed"}], ["mastery analytics","/game/mastery",{key:"analytics"}], ["claim plant","/game/quests/claim",{quest:"plant",scope:"daily"}], ["claim plant weekly","/game/quests/claim",{quest:"plant",scope:"weekly"}], ["defense upgrade","/game/defense/upgrade",{}], ["defense downgrade","/game/defense/downgrade",{}], ["infra registry","/game/infrastructure/buy",{key:"registry"}], ["cosmetic buy skin_neon","/game/cosmetics/buy",{key:"skin_neon"}], ["cosmetic equip skin_neon","/game/cosmetics/equip",{key:"skin_neon"}], ["water alice 1","/game/farm/alice/water",{slot:0}], ["steal alice 12","/game/farm/alice/steal",{slot:11}] ]; for (const [line,url,params] of cases) { const parsed=C.parse(line,farm); assert.equal(parsed.url,url,line); assert.deepEqual(parsed.params,params,line); } assert.equal(C.parse("leaderboard",farm).url,"/game/leaderboard?board=score"); assert.equal(C.parse("leaderboard raids",farm).url,"/game/leaderboard?board=raids"); assert.equal(C.parse("view alice",farm).url,"/game/farm/alice"); assert.equal(C.parse("state",farm).url,"/game/state"); assert.equal(C.parse("home",farm).url,"/game"); assert.equal(C.parse("harvest all",farm).bulk,true); assert.equal(C.parse("server 12",farm).slot,11); assert.equal(C.parse("legacy",farm).local,"legacy"); assert.deepEqual(C.completions("plant 1 py",farm),["python"]); assert.deepEqual(C.completions("claim plant w",farm),["weekly"]); for (const line of ["plant 0 python","harvest 13","harvest 1.5","water ../alice 1","water alice 0","claim plant monthly","leaderboard invalid","perk nonexistent","constructor","__proto__","plant 1 python; alert(1)","harvest 1 extra"]) { assert.throws(()=>C.parse(line,farm),undefined,line); } assert.throws(()=>C.parse("plant 1 python",{...farm,is_owner:false}),/own rack/); assert.equal(C.parse("water alice 1",{...farm,is_owner:false}).url,"/game/farm/alice/water"); console.log("Parser, API bindings, 1-based slots, permissions, invalid commands and completion passed."); '''.replace("MODULE_URL", json.dumps(module.as_uri())) result = subprocess.run([node, "--input-type=module"], input=script, text=True, capture_output=True, timeout=15) assert result.returncode == 0, result.stdout + result.stderr