diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6048d2b..f4ed9cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,4 +28,4 @@ jobs: if: ${{ matrix.dependencies != 'null' }} run: pip install ${{ matrix.dependencies }} - name: Run Tests - run: python -m unittest tests/pytmx/test_pytmx.py + run: python -m unittest discover -s tests/pytmx -p "test_*.py" diff --git a/pytmx/pytmx.py b/pytmx/pytmx.py index 7d40621..0be31e1 100644 --- a/pytmx/pytmx.py +++ b/pytmx/pytmx.py @@ -17,6 +17,7 @@ License along with pytmx. If not, see . """ + from __future__ import annotations import gzip @@ -54,6 +55,8 @@ "convert_to_bool", "resolve_to_class", "parse_properties", + "decode_gid", + "unpack_gids", ) logger = logging.getLogger(__name__) @@ -85,6 +88,7 @@ TiledLayer = Union[ "TiledTileLayer", "TiledImageLayer", "TiledGroupLayer", "TiledObjectGroup" ] +flag_cache: dict[int, TileFlags] = {} # need a more graceful way to handle annotations for optional dependencies if pygame: @@ -125,15 +129,19 @@ def decode_gid(raw_gid: int) -> tuple[int, TileFlags]: """ if raw_gid < GID_TRANS_ROT: return raw_gid, empty_flags - return ( - raw_gid & ~GID_MASK, - # TODO: cache all combinations of flags - TileFlags( - raw_gid & GID_TRANS_FLIPX == GID_TRANS_FLIPX, - raw_gid & GID_TRANS_FLIPY == GID_TRANS_FLIPY, - raw_gid & GID_TRANS_ROT == GID_TRANS_ROT, - ), + + # Check if the GID is already in the cache + if raw_gid in flag_cache: + return raw_gid & ~GID_MASK, flag_cache[raw_gid] + + # Calculate and cache the flags + flags = TileFlags( + raw_gid & GID_TRANS_FLIPX == GID_TRANS_FLIPX, + raw_gid & GID_TRANS_FLIPY == GID_TRANS_FLIPY, + raw_gid & GID_TRANS_ROT == GID_TRANS_ROT, ) + flag_cache[raw_gid] = flags + return raw_gid & ~GID_MASK, flags def reshape_data( @@ -180,6 +188,8 @@ def unpack_gids( fmt = "<%dL" % (len(data) // 4) return list(struct.unpack(fmt, data)) elif encoding == "csv": + if not text.strip(): + return [] return [int(i) for i in text.split(",")] elif encoding: raise ValueError(f"layer encoding {encoding} is not supported.") diff --git a/tests/pytmx/test_gid.py b/tests/pytmx/test_gid.py new file mode 100644 index 0000000..fc84129 --- /dev/null +++ b/tests/pytmx/test_gid.py @@ -0,0 +1,231 @@ +import base64 +import gzip +import struct +import unittest +import zlib + +from pytmx import TiledMap, TileFlags, decode_gid, unpack_gids + +# Tiled gid flags +GID_TRANS_FLIPX = 1 << 31 +GID_TRANS_FLIPY = 1 << 30 +GID_TRANS_ROT = 1 << 29 +GID_MASK = GID_TRANS_FLIPX | GID_TRANS_FLIPY | GID_TRANS_ROT + + +class TestDecodeGid(unittest.TestCase): + def test_no_flags(self): + raw_gid = 100 + expected_gid, expected_flags = 100, TileFlags(False, False, False) + self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags)) + + def test_individual_flags(self): + # Test for each flag individually + test_cases = [ + (GID_TRANS_FLIPX + 1, 1, TileFlags(True, False, False)), + (GID_TRANS_FLIPY + 1, 1, TileFlags(False, True, False)), + (GID_TRANS_ROT + 1, 1, TileFlags(False, False, True)), + ] + for raw_gid, expected_gid, expected_flags in test_cases: + self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags)) + + def test_combinations_of_flags(self): + # Test combinations of flags + test_cases = [ + (GID_TRANS_FLIPX + GID_TRANS_FLIPY + 1, 1, TileFlags(True, True, False)), + (GID_TRANS_FLIPX + GID_TRANS_ROT + 1, 1, TileFlags(True, False, True)), + (GID_TRANS_FLIPY + GID_TRANS_ROT + 1, 1, TileFlags(False, True, True)), + ( + GID_TRANS_FLIPX + GID_TRANS_FLIPY + GID_TRANS_ROT + 1, + 1, + TileFlags(True, True, True), + ), + ] + for raw_gid, expected_gid, expected_flags in test_cases: + self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags)) + + def test_edge_cases(self): + # Maximum GID + max_gid = 2**29 - 1 + self.assertEqual( + decode_gid(max_gid), (max_gid & ~GID_MASK, TileFlags(False, False, False)) + ) + + # Minimum GID + min_gid = 0 + self.assertEqual(decode_gid(min_gid), (min_gid, TileFlags(False, False, False))) + + # GID with all flags set + gid_all_flags = GID_TRANS_FLIPX + GID_TRANS_FLIPY + GID_TRANS_ROT + 1 + self.assertEqual(decode_gid(gid_all_flags), (1, TileFlags(True, True, True))) + + # GID with flags in different orders + test_cases = [ + (GID_TRANS_FLIPX + GID_TRANS_FLIPY + 1, 1, TileFlags(True, True, False)), + (GID_TRANS_FLIPY + GID_TRANS_FLIPX + 1, 1, TileFlags(True, True, False)), + (GID_TRANS_FLIPX + GID_TRANS_ROT + 1, 1, TileFlags(True, False, True)), + (GID_TRANS_ROT + GID_TRANS_FLIPX + 1, 1, TileFlags(True, False, True)), + ] + for raw_gid, expected_gid, expected_flags in test_cases: + self.assertEqual(decode_gid(raw_gid), (expected_gid, expected_flags)) + + def test_flag_cache_identity(self): + raw_gid = GID_TRANS_FLIPX + GID_TRANS_ROT + 1 + _, flags1 = decode_gid(raw_gid) + _, flags2 = decode_gid(raw_gid) + self.assertIs(flags1, flags2) + + def test_flag_cache_separation(self): + gid1 = GID_TRANS_FLIPX + 1 + gid2 = GID_TRANS_FLIPY + 1 + _, f1 = decode_gid(gid1) + _, f2 = decode_gid(gid2) + self.assertNotEqual(f1, f2) + + +class TestRegisterGid(unittest.TestCase): + def setUp(self): + self.tmx_map = TiledMap() + + def test_register_gid_with_valid_tiled_gid(self): + gid = self.tmx_map.register_gid(123) + self.assertIsNotNone(gid) + + def test_register_gid_with_flags(self): + flags = TileFlags(1, 0, 1) + gid = self.tmx_map.register_gid(456, flags) + self.assertIsNotNone(gid) + + def test_register_gid_zero(self): + gid = self.tmx_map.register_gid(0) + self.assertEqual(gid, 0) + + def test_register_gid_max_gid(self): + max_gid = self.tmx_map.maxgid + self.tmx_map.register_gid(max_gid) + self.assertEqual(self.tmx_map.maxgid, max_gid + 1) + + def test_register_gid_duplicate_gid(self): + gid1 = self.tmx_map.register_gid(123) + gid2 = self.tmx_map.register_gid(123) + self.assertEqual(gid1, gid2) + + def test_register_gid_duplicate_gid_different_flags(self): + gid1 = self.tmx_map.register_gid(123, TileFlags(1, 0, 0)) + gid2 = self.tmx_map.register_gid(123, TileFlags(0, 1, 0)) + self.assertNotEqual(gid1, gid2) + + def test_register_gid_empty_flags(self): + gid = self.tmx_map.register_gid(123, TileFlags(0, 0, 0)) + self.assertIsNotNone(gid) + + def test_register_gid_all_flags_set(self): + gid = self.tmx_map.register_gid(123, TileFlags(1, 1, 1)) + self.assertIsNotNone(gid) + + def test_register_gid_repeated_registration(self): + gid1 = self.tmx_map.register_gid(123) + gid2 = self.tmx_map.register_gid(123) + self.assertEqual(gid1, gid2) + + def test_register_gid_flag_equivalence(self): + gid1 = self.tmx_map.register_gid(42, TileFlags(True, False, False)) + gid2 = self.tmx_map.register_gid(42, TileFlags(1, 0, 0)) + self.assertEqual(gid1, gid2) + + def test_gid_mapping_growth(self): + initial_max = self.tmx_map.maxgid + for i in range(5): + self.tmx_map.register_gid(100 + i) + self.assertEqual(self.tmx_map.maxgid, initial_max + 5) + + +class TestUnpackGids(unittest.TestCase): + def test_base64_no_compression(self): + gids = [123, 456, 789] + data = struct.pack(" None: @@ -64,6 +71,15 @@ def test_non_boolean_number_raises_error(self) -> None: with self.assertRaises(ValueError): convert_to_bool("200") + def test_edge_cases(self): + # Whitespace + self.assertTrue(convert_to_bool(" t ")) + self.assertFalse(convert_to_bool(" f ")) + + # Numeric edge cases + self.assertTrue(convert_to_bool(1e-10)) # Very small positive number + self.assertFalse(convert_to_bool(-1e-10)) # Very small negative number + class TiledMapTest(unittest.TestCase): filename = "tests/resources/test01.tmx" @@ -138,10 +154,12 @@ def test_contains_reserved_property_name(self) -> None: specification. We check that new properties are not named same as existing attributes. """ + logging.disable(logging.CRITICAL) # disable logging self.element.name = "foo" items = {"name": None} result = self.element._contains_invalid_property_name(items.items()) self.assertTrue(result) + logging.disable(logging.NOTSET) # reset logging def test_not_contains_reserved_property_name(self) -> None: """Reserved names are checked from any attributes in the instance diff --git a/tests/scripts/bench_decode_gid.py b/tests/scripts/bench_decode_gid.py new file mode 100644 index 0000000..c7bad2f --- /dev/null +++ b/tests/scripts/bench_decode_gid.py @@ -0,0 +1,55 @@ +import timeit + +from pytmx import TileFlags, decode_gid + +# Constants +GID_TRANS_FLIPX = 1 << 31 +GID_TRANS_FLIPY = 1 << 30 +GID_TRANS_ROT = 1 << 29 +GID_MASK = GID_TRANS_FLIPX | GID_TRANS_FLIPY | GID_TRANS_ROT + + +def decode_no_cache(raw_gid: int) -> tuple[int, TileFlags]: + if raw_gid < GID_TRANS_ROT: + return raw_gid, TileFlags(False, False, False) + return ( + raw_gid & ~GID_MASK, + TileFlags( + raw_gid & GID_TRANS_FLIPX == GID_TRANS_FLIPX, + raw_gid & GID_TRANS_FLIPY == GID_TRANS_FLIPY, + raw_gid & GID_TRANS_ROT == GID_TRANS_ROT, + ), + ) + + +def run_benchmark(size: int) -> tuple[float, float]: + raw_gids = [GID_TRANS_FLIPX | GID_TRANS_FLIPY | (i % 1000 + 1) for i in range(size)] + + def run_no_cache(): + for gid in raw_gids: + decode_no_cache(gid) + + def run_with_cache(): + for gid in raw_gids: + decode_gid(gid) + + time_no_cache = timeit.timeit(run_no_cache, number=1) + time_cache = timeit.timeit(run_with_cache, number=1) + return time_no_cache, time_cache + + +def main(): + print("Running decode_gid benchmarks...\n") + gid_sizes = [1_000, 5_000, 10_000, 50_000, 100_000, 250_000, 500_000, 1_000_000] + + print(f"{'GID Count':>10} | {'No Cache':>10} | {'With Cache':>11} | {'Speedup':>8}") + print("-" * 50) + + for size in gid_sizes: + no_cache, cache = run_benchmark(size) + speedup = no_cache / cache if cache > 0 else float("inf") + print(f"{size:>10} | {no_cache:.4f}s | {cache:.4f}s | {speedup:>7.2f}x") + + +if __name__ == "__main__": + main() diff --git a/tests/scripts/bench_tmx_maps.py b/tests/scripts/bench_tmx_maps.py new file mode 100644 index 0000000..b2872bd --- /dev/null +++ b/tests/scripts/bench_tmx_maps.py @@ -0,0 +1,79 @@ +import time +from pathlib import Path + +from pytmx import TiledMap, decode_gid + +# Optional: enable tracking cache hits +flag_cache_stats = {"hits": 0, "misses": 0} +flag_cache = {} + +# TMX flag constants +GID_TRANS_FLIPX = 1 << 31 +GID_TRANS_FLIPY = 1 << 30 +GID_TRANS_ROT = 1 << 29 +GID_MASK = GID_TRANS_FLIPX | GID_TRANS_FLIPY | GID_TRANS_ROT + +DATA_DIR = Path("./apps/data/") +RUNS_PER_MAP = 3 + + +def decode_gid_tracked(raw_gid: int): + if raw_gid < GID_TRANS_ROT: + return raw_gid, decode_gid(raw_gid)[1] + if raw_gid in flag_cache: + flag_cache_stats["hits"] += 1 + return raw_gid & ~GID_MASK, flag_cache[raw_gid] + flag_cache_stats["misses"] += 1 + flags = decode_gid(raw_gid)[1] + flag_cache[raw_gid] = flags + return raw_gid & ~GID_MASK, flags + + +def benchmark_map(map_path: Path) -> float: + times = [] + for _ in range(RUNS_PER_MAP): + start = time.time() + TiledMap(str(map_path)) + elapsed = time.time() - start + times.append(elapsed) + return sum(times) / RUNS_PER_MAP + + +def summarize_map(map_path: Path): + try: + tmx = TiledMap(str(map_path)) + num_layers = len(tmx.layers) + num_tilesets = len(tmx.tilesets) + num_tiles = sum( + len(layer.data) for layer in tmx.layers if hasattr(layer, "data") + ) + return num_layers, num_tilesets, num_tiles + except Exception: + return None, None, None + + +def main(): + print("TMX Load Benchmark (real maps)\n") + print( + f"{'Map Name':<25} | {'Avg Time (s)':>12} | {'Layers':>6} | {'Tilesets':>9} | {'Tiles':>7}" + ) + print("-" * 70) + + for map_file in DATA_DIR.glob("*.tmx"): + try: + avg_time = benchmark_map(map_file) + layers, tilesets, tiles = summarize_map(map_file) + print( + f"{map_file.name:<25} | {avg_time:>12.4f} | {layers:>6} | {tilesets:>9} | {tiles:>7}" + ) + except Exception as e: + print(f"{map_file.name:<25} | Error: {str(e)}") + + if flag_cache_stats["hits"] or flag_cache_stats["misses"]: + print("\nGID Cache Stats") + print(f" Hits: {flag_cache_stats['hits']}") + print(f" Misses: {flag_cache_stats['misses']}") + + +if __name__ == "__main__": + main()