forked from retoor/devplacepy
Code Farm: add front-view server rack and API-backed terminal
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user