"""The container `sds play` builds, and the watchdogs it has to turn off.""" from __future__ import annotations import contextlib import io import subprocess import tempfile import unittest from pathlib import Path from unittest import mock from sds import cli from sds import play as play_module from sds.play import PlaySpec def _invocation(spec: PlaySpec) -> tuple[list[str], str]: """The `docker run` argv and the script it hands the container. Docker, the display probe and the settings directory are all faked: the thing under test is the command line, and a real one wants an X server and a MegaMek install. """ seen: list[list[str]] = [] def fake_run(command, *popenargs, **kwargs): if command[:2] == ["docker", "run"]: seen.append(command) return subprocess.CompletedProcess(command, 0) with ( tempfile.TemporaryDirectory(dir=play_module.REPO) as tmp, mock.patch.object(play_module, "preflight", lambda _spec: None), mock.patch.object( play_module, "display_environment", lambda: (":7", "/run/user/1000/cookie") ), mock.patch.object(play_module, "_seed_config", lambda _home: Path("/tmp/mmconf")), mock.patch.object(play_module.subprocess, "run", fake_run), # `play` narrates itself at a person; a test suite is not the audience. contextlib.redirect_stdout(io.StringIO()), ): play_module.play(spec, Path(tmp)) command = seen[0] return command, command[-1] def _spec(**overrides) -> PlaySpec: fields = { "scenario": play_module.REPO / "scenarios" / "mirror-lance.mms", "human": "North", "sds": "South", "bot": "/work/target/release/sds-bot", } fields.update(overrides) return PlaySpec(**fields) def _flag(script: str, flag: str) -> str: """The value SdsHost is passed for one flag. Every host argument is single-quoted into the script, so the quotes come off before either side is compared. """ parts = [word.strip("'") for word in script.split()] return parts[parts.index(flag) + 1] class CommandLine(unittest.TestCase): def test_every_watchdog_is_off(self): """A person takes minutes per turn; all three of these would end the game.""" _, script = _invocation(_spec()) self.assertEqual(_flag(script, "--stall-seconds"), "0") self.assertEqual(_flag(script, "--wall-clock"), "0") self.assertEqual(_flag(script, "--max-rounds"), "0") def test_the_seats_say_who_is_who(self): _, script = _invocation(_spec(human="South", sds="North")) self.assertIn("'South=human'", script) self.assertIn("'North=sds'", script) self.assertIn('-player_name "human South"', script) def test_the_bot_is_named_and_passed_through(self): _, script = _invocation(_spec(bot="python3 /work/bots/random_bot.py")) self.assertIn("'python3 /work/bots/random_bot.py'", script) def test_the_client_is_not_headless(self): """The host stays headless; the seat a person sits in cannot be.""" _, script = _invocation(_spec()) host, client = script.split("megamek.MegaMek") self.assertIn("-Djava.awt.headless=true", host) self.assertNotIn("-Djava.awt.headless=true", client) def test_the_display_is_mounted_not_published(self): command, _ = _invocation(_spec()) self.assertIn("DISPLAY=:7", command) self.assertIn("/tmp/.X11-unix:/tmp/.X11-unix", command) self.assertIn("/run/user/1000/cookie:/tmp/.sds-xauth:ro", command) # Nothing is published: the server and the client share the container's # own loopback, which is why two of these cannot collide. self.assertNotIn("-p", command) self.assertNotIn("--publish", command) def test_the_container_is_not_named_sds(self): """`sds clean` kills every `sds-*` container on the machine.""" command, _ = _invocation(_spec()) name = command[command.index("--name") + 1] self.assertTrue(name.startswith("sdsplay-"), name) self.assertFalse(name.startswith("sds-"), name) def test_the_server_outlives_the_result(self): """So MegaMek's end-of-game screen is readable rather than a disconnect.""" _, script = _invocation(_spec()) self.assertGreater(int(_flag(script, "--linger-seconds")), 0) if __name__ == "__main__": unittest.main() class ASeatThatAnsweredNothing(unittest.TestCase): """A run whose bot never started must stop, not play on. `_check_bot` covers the default binary only. A wrong `--bot` - a relative path, where the container mounts the repo at `/work` - fails the same way and nothing caught it: the host disabled the seat, every decision took an inert default, and six matches recorded a bot that was never running. """ def seat(self, decisions: int, answered: int) -> dict: return { "players": [ { "name": "sds North", "kind": "sds", "sds": {"decisions": decisions, "answered": answered}, }, {"name": "princess South", "kind": "princess", "sds": {}}, ] } def test_a_seat_with_decisions_and_no_answers_is_named(self): self.assertEqual(cli._seat_answered_nothing(self.seat(93, 0)), "sds North") def test_a_seat_that_answered_is_not_flagged(self): self.assertIsNone(cli._seat_answered_nothing(self.seat(93, 90))) def test_one_answer_is_enough(self): # The guard is about a bot that never ran, not about a bot playing # badly. Anything above zero means the process was there. self.assertIsNone(cli._seat_answered_nothing(self.seat(93, 1))) def test_a_seat_asked_for_nothing_is_not_flagged(self): # Princess keeps no tally, and a match where our seat was never asked # is not evidence of anything. self.assertIsNone(cli._seat_answered_nothing(self.seat(0, 0)))