forked from retoor/devplacepy
121 lines
5.7 KiB
Python
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
|