Conflicted only on the README feature list, where main's list_cargo() bullet and this branch's bridge bullet were added at the same spot. Kept both: list_cargo() with the other cargo queries, and the bridge one last, since it is about what all of them run against. list_cargo() landing also makes tests/test_gamescript.py's command check meaningful in the other direction -- the bridge has answered list_cargo all along with nothing sending it, which is exactly the asymmetry that check tolerates on purpose. Co-Authored-By: Claude <[email protected]>
90 lines
4.2 KiB
Python
90 lines
4.2 KiB
Python
"""Checks the checked-in AdminBridge GameScript against the client that talks to it.
|
|
|
|
The bridge is Squirrel, and CI has no Squirrel toolchain, so these are not a substitute for
|
|
running it (see gamescript/AdminBridge/README.md). They cover the one class of breakage that
|
|
is invisible until a server is in front of you: the two halves of the protocol drifting apart
|
|
-- a version bumped on one side only, an event kind or a command the client uses that the
|
|
bridge does not implement, or the docker setup no longer serving the tracked copy.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from openttd.protocol import GS_BRIDGE_VERSION, GameEventType
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
GS_DIR = ROOT / "gamescript" / "AdminBridge"
|
|
INFO_NUT = (GS_DIR / "info.nut").read_text()
|
|
MAIN_NUT = (GS_DIR / "main.nut").read_text()
|
|
|
|
|
|
def _block(source, opening, closing):
|
|
"""Return the text between `opening` and the next `closing`, e.g. an array or table body."""
|
|
start = source.index(opening) + len(opening)
|
|
return source[start:source.index(closing, start)]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_gamescript_version_matches_client():
|
|
"""info.nut, main.nut and the client must all name the same protocol version.
|
|
|
|
A GameScript cannot read its own info.nut at runtime, so main.nut duplicates the version;
|
|
this is what keeps the copy honest, and what makes a bump that misses a file fail here.
|
|
"""
|
|
info_version = int(re.search(r"function GetVersion\(\)\s*{\s*return (\d+);", INFO_NUT).group(1))
|
|
main_version = int(re.search(r"BRIDGE_VERSION = (\d+);", MAIN_NUT).group(1))
|
|
assert info_version == main_version == GS_BRIDGE_VERSION
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_gamescript_can_load_older_savegames():
|
|
"""The bridge must stay loadable by savegames that pinned an older version of it.
|
|
|
|
OpenTTD defaults MinVersionToLoad() to GetVersion(), so without an explicit override every
|
|
version bump orphans existing savegames: the engine finds no compatible script and falls
|
|
back with a warning. The bridge keeps no savegame state, so any version can take over.
|
|
"""
|
|
min_version = int(re.search(r"function MinVersionToLoad\(\)\s*{\s*return (\d+);", INFO_NUT).group(1))
|
|
assert min_version <= GS_BRIDGE_VERSION
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_gamescript_event_catalogue_matches_client_enum():
|
|
"""Every kind the bridge can push has a GameEventType, and vice versa.
|
|
|
|
GameEventType is what subscribe_events() validates against, so a kind in one list and not
|
|
the other is either an event nobody can subscribe to or a subscription the bridge rejects.
|
|
"""
|
|
catalogue = set(re.findall(r'"(\w+)"', _block(MAIN_NUT, "this.event_order = [", "];")))
|
|
# EventsDropped is emitted by the bridge itself and never subscribed to, so it is
|
|
# deliberately absent from the subscribable catalogue.
|
|
assert catalogue == {e.value for e in GameEventType} - {GameEventType.EventsDropped}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_gamescript_implements_every_command_the_client_sends():
|
|
"""Each command in a client payload must have a handler in the bridge's dispatch table.
|
|
|
|
A command the bridge does not know is silently dropped, so the caller only sees a timeout.
|
|
Checked one way round only: the bridge is allowed to implement more than the client wraps,
|
|
which is how list_cargo sat there answering nobody until a method was written for it.
|
|
"""
|
|
commands = set(re.findall(r"^\s*(\w+)\s*=\s*{ handler",
|
|
_block(MAIN_NUT, "COMMANDS = {", "\n\t};"), re.MULTILINE))
|
|
client_source = (ROOT / "lib" / "openttd" / "client.py").read_text()
|
|
sent = set(re.findall(r'{"command": "(\w+)"', client_source))
|
|
assert sent, "no GameScript commands found in the client -- has the payload spelling changed?"
|
|
assert sent <= commands
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_docker_serves_the_tracked_gamescript():
|
|
"""The container must run the copy in git, not an untracked one under docker/config/.
|
|
|
|
That mount is the whole reason the tracked copy stays honest: without it the server reads a
|
|
file nobody reviews, which is how the bridge went unversioned in the first place.
|
|
"""
|
|
compose = (ROOT / "docker" / "docker-compose.yml").read_text()
|
|
assert "../gamescript/AdminBridge:" in compose
|