Files
devplacepy/tests/unit/services/game/terminal.py
T

121 lines
5.7 KiB
Python

"""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