diff --git a/README.md b/README.md index 2156246..3796ae0 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ python main.py 100 --exit-after-create Created environments are recorded in `environments.json`, keyed by grid count and grid size (for example `1x50`; the grid count is currently fixed at `1`). A key that is already present in that file is re-loaded from Viron rather than re-created, so the file should be deleted to force re-creation. +The render loop is capped at 60 frames per second via `RenderWindow.tick()`. Since each location is re-coloured at random on every frame, that cap is also what sets the rate at which the visualization re-randomizes. + +`main.py` guards its entry point with `if __name__ == "__main__":`, so the module can be imported — by the test suite, or by another program wanting to call `main(gridSize, exitAfterCreate)` directly — without launching a window. `main()` also accepts `locationService` and `environmentService` arguments, which default to services pointed at `http://localhost:9999`. + ### Batch environment creation On Windows, `create_environments.bat` deletes `environments.json` and then invokes `python main.py --exit-after-create` once per grid size, from `1` up to the maximum size given as its first argument (defaulting to `100`). Standard output is appended to `output.txt` and errors to `error_log.txt`. @@ -146,12 +150,14 @@ create_environments.bat 25 ### Running the tests -Unit tests live in `tests/` and use only the standard library's `unittest`. They mock Pygame, so no display and no running Viron server are required: +Unit tests live in `tests/` and use only the standard library's `unittest`. They mock Pygame and stand in for Viron's service modules with stubs registered in `sys.modules`, so no display, no running Viron server, and not even a populated `Viron/` submodule are required. Because Viron is stubbed rather than imported, the suite also runs on Python versions older than the 3.10 that `main.py` itself needs: ```bash python -m unittest discover -s tests ``` +Run the command from the repository root, so that `main.py` and `render_window.py` are importable. + ## Use Cases - Visualization of entity grids and spatial data from Viron diff --git a/main.py b/main.py index a53216c..2b1bffa 100644 --- a/main.py +++ b/main.py @@ -16,27 +16,49 @@ displayWidth = 800 displayHeight = 800 -def log(message): - print(message) +targetFramesPerSecond = 60 numGrids = 1 -if len(sys.argv) > 1: - try: - gridSize = int(sys.argv[1]) - except ValueError: - log("Invalid grid size argument, using default of 50.") - gridSize = 50 -else: - gridSize = 50 +defaultGridSize = 50 url = "http://localhost" port = 9999 -locationService = LocationService(url, port) -environmentService = EnvironmentService(url, port) -exit_after_create = False -if len(sys.argv) > 2 and sys.argv[2] == "--exit-after-create": - exit_after_create = True +def log(message): + print(message) + +def parseArgs(argv): + """ + Parse the command line arguments. + + Args: + argv (list): The argument vector, including the program name at index 0 + + Returns: + tuple: The grid size to use, and whether to exit after creating the environment + """ + gridSize = defaultGridSize + if len(argv) > 1: + try: + gridSize = int(argv[1]) + except ValueError: + log("Invalid grid size argument, using default of " + str(defaultGridSize) + ".") + gridSize = defaultGridSize + exitAfterCreate = len(argv) > 2 and argv[2] == "--exit-after-create" + return gridSize, exitAfterCreate + +def getEnvironmentKey(gridSize): + """ + Build the key under which an environment of the given size is recorded in the cache file. + + Args: + gridSize (int): The size of one side of the grid + + Returns: + str: The cache key, for example "1x50" + """ + return f"{numGrids}x{gridSize}" + def drawEnvironment(locations, graphik, locationWidth, locationHeight): for location in locations: red = random.randrange(50, 200) @@ -46,7 +68,22 @@ def drawEnvironment(locations, graphik, locationWidth, locationHeight): y = location.get_y() * locationHeight graphik.drawRectangle(x - 1, y - 1, locationWidth * 1.5, locationHeight * 1.5, (red,green,blue)) -def main(): +def main(gridSize=defaultGridSize, exitAfterCreate=False, locationService=None, environmentService=None): + """ + Render an environment of the requested size, creating it through Viron if it is not + already recorded in the cache file. + + Args: + gridSize (int): The size of one side of the grid + exitAfterCreate (bool): Whether to render a newly created environment once and exit + locationService (LocationService): The location service to use, or None to build one + environmentService (EnvironmentService): The environment service to use, or None to build one + """ + if locationService is None: + locationService = LocationService(url, port) + if environmentService is None: + environmentService = EnvironmentService(url, port) + window = RenderWindow("Visualizing Environment With Random Colors", displayWidth, displayHeight) gameDisplay = window.get_surface() graphik = Graphik(gameDisplay) @@ -61,14 +98,15 @@ def main(): environments = json.load(f) # Create a unique key for the environment based on grid size and numGrids - env_key = f"{numGrids}x{gridSize}" + env_key = getEnvironmentKey(gridSize) if env_key in environments: graphik.drawText("Loading existing environment, please wait...", displayWidth/2, displayHeight/2, 20, "white") + pygame.display.update() env_id = environments[env_key]["environment_id"] try: - environment = environmentService.get_environment_by_id(env_id) - log(f"Loaded existing environment with id {env_id} and size {gridSize}x{gridSize} with {numGrids} grid(s).") + environment = environmentService.get_environment_by_id(env_id) + log(f"Loaded existing environment with id {env_id} and size {gridSize}x{gridSize} with {numGrids} grid(s).") except Exception as e: log(f"Error loading existing environment: {e}") graphik.drawText("Error loading environment, please check logs.", displayWidth/2, displayHeight/2 + 30, 20, "red") @@ -77,7 +115,7 @@ def main(): window.close() return else: - graphik.drawText("Creating environment, please wait...", 400, 400, 20,"white") + graphik.drawText("Creating environment, please wait...", displayWidth/2, displayHeight/2, 20,"white") pygame.display.update() log("Creating environment with " + str(numGrids) + " grid(s) of size " + str(gridSize) + "x" + str(gridSize)) start_time = time.time() @@ -92,8 +130,8 @@ def main(): with open(env_file, "w") as f: json.dump(environments, f, indent=2) log(f"Created new environment with id {environment.getEnvironmentId()} in {end_time - start_time:.2f} seconds.") - - if exit_after_create: + + if exitAfterCreate: log("Exiting after environment creation.") locations = locationService.get_locations_in_environment(environment.getEnvironmentId()) drawEnvironment(locations, graphik, displayWidth/gridSize, displayHeight/gridSize) @@ -104,7 +142,7 @@ def main(): locationWidth = displayWidth/gridSize locationHeight = displayHeight/gridSize - + locationsCache = {} while window.should_continue(): @@ -115,7 +153,10 @@ def main(): gameDisplay.fill(white) drawEnvironment(locationsCache, graphik, locationWidth, locationHeight) pygame.display.update() + window.tick(targetFramesPerSecond) window.close() -main() \ No newline at end of file +if __name__ == "__main__": + gridSize, exitAfterCreate = parseArgs(sys.argv) + main(gridSize, exitAfterCreate) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..a5310df --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,268 @@ +import json +import os +import sys +import tempfile +import types +import unittest +from unittest.mock import MagicMock, patch + +import pygame + + +def installVironStubs(): + """ + Register stand-in modules for the Viron submodule so that main.py can be imported + without the submodule being populated, and without any test reaching a real server. + Viron's own service modules use PEP 585 annotations, so they are also unimportable + on the Python versions below 3.9 that this suite is expected to run on. + """ + servicesPath = "Viron.src.main.python.preponderous.viron.services" + parts = servicesPath.split(".") + for depth in range(1, len(parts) + 1): + packageName = ".".join(parts[:depth]) + sys.modules.setdefault(packageName, types.ModuleType(packageName)) + for moduleName, className in (("environmentService", "EnvironmentService"), + ("locationService", "LocationService")): + qualifiedName = servicesPath + "." + moduleName + module = types.ModuleType(qualifiedName) + setattr(module, className, MagicMock(name=className)) + sys.modules[qualifiedName] = module + + +installVironStubs() + +import main + +# Captured immediately after the import above, so that the import-time side effects the +# module used to have are observable even once later tests have driven main() themselves. +pygameInitialisedAtImport = pygame.get_init() +environmentServiceCallsAtImport = main.EnvironmentService.call_count +locationServiceCallsAtImport = main.LocationService.call_count + + +class TestParseArgs(unittest.TestCase): + def test_defaults_when_only_the_program_name_is_given(self): + self.assertEqual(main.parseArgs(["main.py"]), (50, False)) + + def test_reads_the_grid_size_from_the_first_argument(self): + self.assertEqual(main.parseArgs(["main.py", "100"]), (100, False)) + + def test_falls_back_to_the_default_on_an_unparseable_grid_size(self): + self.assertEqual(main.parseArgs(["main.py", "wide"]), (50, False)) + + def test_detects_the_exit_after_create_flag(self): + self.assertEqual(main.parseArgs(["main.py", "100", "--exit-after-create"]), (100, True)) + + def test_ignores_an_unrecognized_second_argument(self): + self.assertEqual(main.parseArgs(["main.py", "100", "--render-forever"]), (100, False)) + + +class TestGetEnvironmentKey(unittest.TestCase): + def test_key_combines_the_grid_count_and_the_grid_size(self): + self.assertEqual(main.getEnvironmentKey(50), "1x50") + + def test_key_tracks_the_requested_grid_size(self): + self.assertEqual(main.getEnvironmentKey(7), "1x7") + + +class TestImportSideEffects(unittest.TestCase): + def test_importing_main_does_not_construct_the_viron_services(self): + self.assertEqual(environmentServiceCallsAtImport, 0) + self.assertEqual(locationServiceCallsAtImport, 0) + + def test_importing_main_does_not_initialise_pygame(self): + self.assertFalse(pygameInitialisedAtImport) + + +class MainTestCase(unittest.TestCase): + """ + Base fixture for the tests that drive main() itself. Pygame, the window, the drawing + helper and the clock are all replaced, and the working directory is a fresh temporary + one so that the environments.json cache file written by main() is real but disposable. + """ + + def setUp(self): + self.mockPygame = self.startPatch("main.pygame") + self.mockRenderWindow = self.startPatch("main.RenderWindow") + self.mockGraphik = self.startPatch("main.Graphik") + self.mockTime = self.startPatch("main.time") + self.mockTime.time.side_effect = [1.0, 3.5] + + self.window = self.mockRenderWindow.return_value + self.graphik = self.mockGraphik.return_value + self.setFrames(0) + + self.environmentService = MagicMock(name="EnvironmentService") + self.environmentService.create_environment.return_value = self.makeEnvironment(7) + self.environmentService.get_environment_by_id.return_value = self.makeEnvironment(42) + self.locationService = MagicMock(name="LocationService") + self.locationService.get_locations_in_environment.return_value = [self.makeLocation(0, 0)] + + workingDirectory = tempfile.TemporaryDirectory() + self.addCleanup(workingDirectory.cleanup) + previousDirectory = os.getcwd() + self.addCleanup(os.chdir, previousDirectory) + os.chdir(workingDirectory.name) + + def startPatch(self, target): + patcher = patch(target) + self.addCleanup(patcher.stop) + return patcher.start() + + def setFrames(self, frames): + """Let the render loop run for the given number of iterations, then stop.""" + self.window.should_continue.side_effect = [True] * frames + [False] + + def makeEnvironment(self, environmentId): + environment = MagicMock(name=f"Environment({environmentId})") + environment.getEnvironmentId.return_value = environmentId + return environment + + def makeLocation(self, x, y): + location = MagicMock(name=f"Location({x},{y})") + location.get_x.return_value = x + location.get_y.return_value = y + return location + + def writeCacheFile(self, contents): + with open("environments.json", "w") as cacheFile: + json.dump(contents, cacheFile) + + def readCacheFile(self): + with open("environments.json", "r") as cacheFile: + return json.load(cacheFile) + + def runMain(self, gridSize=10, exitAfterCreate=False): + main.main(gridSize, exitAfterCreate, + locationService=self.locationService, + environmentService=self.environmentService) + + +class TestEnvironmentCreation(MainTestCase): + def test_creates_an_environment_when_the_cache_file_is_absent(self): + self.runMain(gridSize=10) + + self.environmentService.create_environment.assert_called_once_with("Test", 1, 10) + + def test_records_the_created_environment_in_the_cache_file(self): + self.runMain(gridSize=10) + + self.assertEqual(self.readCacheFile(), { + "1x10": { + "environment_id": 7, + "grid_size": 10, + "num_grids": 1, + "creation_time_seconds": 2.5, + } + }) + + def test_leaves_unrelated_cache_entries_intact(self): + self.writeCacheFile({"1x99": {"environment_id": 99}}) + + self.runMain(gridSize=10) + + self.assertEqual(self.readCacheFile()["1x99"], {"environment_id": 99}) + + def test_reuses_a_cached_environment_instead_of_creating_a_new_one(self): + self.writeCacheFile({"1x10": {"environment_id": 42}}) + + self.runMain(gridSize=10) + + self.environmentService.create_environment.assert_not_called() + self.environmentService.get_environment_by_id.assert_called_once_with(42) + + def test_exit_after_create_skips_the_render_loop(self): + self.runMain(gridSize=10, exitAfterCreate=True) + + self.window.should_continue.assert_not_called() + self.window.close.assert_called_once() + + def test_exit_after_create_is_ignored_for_a_cached_environment(self): + self.writeCacheFile({"1x10": {"environment_id": 42}}) + + self.runMain(gridSize=10, exitAfterCreate=True) + + self.window.should_continue.assert_called() + + +class TestLoadingMessage(MainTestCase): + """Regression coverage for the cached-environment progress message (#21).""" + + def recordOrderedCalls(self): + calls = [] + self.graphik.drawText.side_effect = lambda text, *rest: calls.append(("drawText", text)) + self.mockPygame.display.update.side_effect = lambda: calls.append(("update", None)) + self.environmentService.get_environment_by_id.side_effect = \ + lambda environmentId: calls.append(("get_environment_by_id", environmentId)) + return calls + + def test_loading_message_is_flushed_before_the_blocking_fetch(self): + self.writeCacheFile({"1x10": {"environment_id": 42}}) + calls = self.recordOrderedCalls() + + self.runMain(gridSize=10) + + self.assertEqual(calls[:3], [ + ("drawText", "Loading existing environment, please wait..."), + ("update", None), + ("get_environment_by_id", 42), + ]) + + def test_creation_message_is_centred_on_the_display(self): + self.runMain(gridSize=10) + + self.graphik.drawText.assert_any_call( + "Creating environment, please wait...", + main.displayWidth / 2, main.displayHeight / 2, 20, "white") + + def test_a_failed_load_reports_the_error_and_closes_the_window(self): + self.writeCacheFile({"1x10": {"environment_id": 42}}) + self.environmentService.get_environment_by_id.side_effect = RuntimeError("unreachable") + + self.runMain(gridSize=10) + + self.graphik.drawText.assert_any_call( + "Error loading environment, please check logs.", + main.displayWidth / 2, main.displayHeight / 2 + 30, 20, "red") + self.window.should_continue.assert_not_called() + self.window.close.assert_called_once() + + +class TestRenderLoop(MainTestCase): + def test_every_frame_is_capped_at_the_target_frame_rate(self): + self.setFrames(3) + + self.runMain(gridSize=10) + + self.assertEqual(self.window.tick.call_count, 3) + self.window.tick.assert_called_with(main.targetFramesPerSecond) + + def test_target_frame_rate_is_positive(self): + self.assertGreater(main.targetFramesPerSecond, 0) + + def test_locations_are_fetched_once_and_then_reused(self): + self.setFrames(3) + + self.runMain(gridSize=10) + + self.locationService.get_locations_in_environment.assert_called_once_with(7) + + def test_the_window_is_closed_once_the_loop_stops(self): + self.setFrames(2) + + self.runMain(gridSize=10) + + self.window.close.assert_called_once() + + def test_each_frame_clears_the_surface_before_drawing(self): + self.setFrames(2) + + self.runMain(gridSize=10) + + surface = self.window.get_surface.return_value + self.assertEqual(surface.fill.call_count, 2) + surface.fill.assert_called_with(main.white) + + +if __name__ == "__main__": + unittest.main()