-- ===================================================================== -- lib-core.maps-test — Reference test-lib (P.3.1) -- Validates lib-core.maps API contracts via engine.test.* assertions. -- See: meta/docs/superpowers/specs/2026-05-10-test-module-pattern-design.md -- ===================================================================== local maps = require("lib-core.maps") local M = {} function M.run_tests(ctx) -- Asset-aliases for texture-loading (test-libs lack manifest asset_aliases; -- hard-code the test-lib itself as the asset provider for its own atlases). local aliases = { demo_tilemap = "lib-core.maps-test", walls_tilemap = "lib-core.maps-test", } -- ---------- Task 1: GID encode/decode ---------- engine.test.equals(maps.encode_gid(0, 0, 0), 0, "encode_gid(0,0,0) = 0 (empty sentinel)") engine.test.equals(maps.encode_gid(0, 12, 0), 192, "encode_gid(0,12,0) = 192 (= 12<<4)") engine.test.equals(maps.encode_gid(0, 12, 1), 196, "encode_gid(0,12,1) = 196 (= 12<<4 | 1<<2)") engine.test.equals(maps.encode_gid(1, 5, 0), 16777296, "encode_gid(1,5,0) = 16777296 (= 1<<24 | 5<<4)") engine.test.equals(maps.encode_gid(255, 1048575, 3), 0xFFFFFFFC, "encode_gid max-values fills 32 bits except reserved") local a, t, r = maps.decode_gid(196) engine.test.equals(a, 0, "decode_gid(196) atlas = 0") engine.test.equals(t, 12, "decode_gid(196) tile = 12") engine.test.equals(r, 1, "decode_gid(196) rot = 1") -- Round-trip for atlas = 0, 3 do for tile = 0, 100, 17 do for rot = 0, 3 do local gid = maps.encode_gid(atlas, tile, rot) local a2, t2, r2 = maps.decode_gid(gid) engine.test.equals(a2, atlas, "round-trip atlas") engine.test.equals(t2, tile, "round-trip tile") engine.test.equals(r2, rot, "round-trip rot") end end end -- ---------- Task 2: Schema-v2 + v1→v2 migration ---------- -- Build a v1-style map table in-memory local v1_table = { id = "test_v1", tilemap = "demo_tilemap", size = { w = 2, h = 2 }, tiles = { 1, 2, 2, 1 }, tile_rotations = { 0, 90, 180, 270 }, } local v2 = maps.upgrade_v1_to_v2(v1_table) engine.test.equals(v2.schema_version, 2, "upgraded schema_version = 2") engine.test.equals(v2.id, "test_v1", "upgraded id preserved") engine.test.equals(v2.size.w, 2, "upgraded size.w preserved") engine.test.equals(#v2.atlases, 1, "upgraded atlases[] has 1 entry") engine.test.equals(v2.atlases[1], "demo_tilemap", "upgraded atlases[0] = v1 tilemap") engine.test.assert(v2.layers.surface ~= nil, "upgraded has surface layer") engine.test.equals(#v2.layers.surface.tiles, 4, "upgraded surface tiles count") -- Check first cell: tile_id=1, rotation=0 → encode_gid(0, 1, 0) = 16 engine.test.equals(v2.layers.surface.tiles[1], 16, "upgraded cell 0 = (atlas=0, tile=1, rot=0)") -- Check second cell: tile_id=2, rotation=90 → quadrant 1 → encode_gid(0, 2, 1) = (2<<4)|(1<<2) = 36 engine.test.equals(v2.layers.surface.tiles[2], 36, "upgraded cell 1 with rot 90° → quadrant 1") -- Fourth cell: rotation 270 → quadrant 3 → encode_gid(0, 1, 3) = (1<<4)|(3<<2) = 28 engine.test.equals(v2.layers.surface.tiles[4], 28, "upgraded cell 3 with rot 270° → quadrant 3") -- ---------- Task 3: Schema-v2 validation ---------- -- Valid minimal v2 map should NOT raise local v2_minimal = { schema_version = 2, id = "v2_min", size = { w = 2, h = 2 }, atlases = { "demo_tilemap" }, layers = { surface = { tiles = { 16, 16, 16, 16 } } } } local ok = pcall(function() maps.validate_map_table_v2(v2_minimal, "in-memory") end) engine.test.assert(ok, "valid v2 map validates without error") -- Bad schema_version local bad_ver = { schema_version = 99, id = "x", size = {w=1,h=1}, atlases = {"a"}, layers = {} } local ok2, err2 = pcall(function() maps.validate_map_table_v2(bad_ver, "in-memory") end) engine.test.assert(not ok2, "rejected schema_version 99") -- Missing atlases local no_atlas = { schema_version = 2, id = "x", size = {w=1,h=1}, layers = {} } local ok3 = pcall(function() maps.validate_map_table_v2(no_atlas, "in-memory") end) engine.test.assert(not ok3, "rejected missing atlases[]") -- Wrong tile-array length local wrong_len = { schema_version = 2, id = "x", size = {w=2,h=2}, atlases = {"demo_tilemap"}, layers = { surface = { tiles = {16, 16, 16} } } -- only 3, expected 4 } local ok4 = pcall(function() maps.validate_map_table_v2(wrong_len, "in-memory") end) engine.test.assert(not ok4, "rejected wrong tile array length") -- Unknown layer name → warning logged but accepted (graceful) local unknown_layer = { schema_version = 2, id = "x", size = {w=1,h=1}, atlases = {"demo_tilemap"}, layers = { surface = { tiles = {0} }, made_up = { tiles = {0} } } } local ok5 = pcall(function() maps.validate_map_table_v2(unknown_layer, "in-memory") end) engine.test.assert(ok5, "unknown layer name accepted (logged warn)") -- ---------- Task 4: Load v2 map with single atlas, multi-layer ---------- local v2_id = maps.load("maps/demo_v2.map.json") engine.test.equals(v2_id, "demo_v2", "v2 map loaded with id 'demo_v2'") maps.set_current(v2_id) maps.load_textures(aliases) local v2_size = maps.size() engine.test.equals(v2_size.w, 4, "v2 map width = 4") engine.test.equals(v2_size.h, 4, "v2 map height = 4") -- Atlas count (demo_v2 has 2 atlases as of Task 5 fixture) engine.test.equals(maps.atlas_count(), 2, "v2 map has 2 atlases") engine.test.equals(maps.atlas_id_at(0), "demo_tilemap", "v2 atlas[0] = demo_tilemap") -- Layer existence engine.test.assert(maps.has_layer("surface"), "v2 has surface layer") engine.test.assert(maps.has_layer("topsurface"), "v2 has topsurface layer") engine.test.assert(maps.has_layer("wall"), "v2 has wall layer") -- Roof engine.test.assert(maps.is_indoor(1, 1), "v2 cell (1,1) is indoor") engine.test.assert(not maps.is_indoor(0, 0), "v2 cell (0,0) is outdoor") -- ---------- Task 5: Multi-atlas-per-map ---------- -- Re-load (demo_v2 already loaded; reset state by closing & re-opening) -- For the test, we trust the existing loaded state if the fixture file was updated engine.test.equals(maps.atlas_count(), 2, "demo_v2 now has 2 atlases") engine.test.equals(maps.atlas_id_at(0), "demo_tilemap", "atlas[0] alias") engine.test.equals(maps.atlas_id_at(1), "walls_tilemap", "atlas[1] alias") -- Per-layer atlas resolution: wall layer cell (0,0) should be atlas-1, tile-1 local wall_gid = maps.cell_gid("wall", 0, 0) local a, t, r = maps.decode_gid(wall_gid) engine.test.equals(a, 1, "wall cell (0,0) atlas = 1 (walls_tilemap)") engine.test.equals(t, 1, "wall cell (0,0) tile = 1 (wall_brick)") -- Empty cell in wall layer local center_gid = maps.cell_gid("wall", 1, 1) engine.test.equals(center_gid, 0, "wall cell (1,1) empty (inside roof)") -- ---------- Task 6: Per-layer tile_at ---------- -- tile_at_layer returns tile-record from the correct atlas local surface_tile = maps.tile_at_layer("surface", 1, 1) engine.test.assert(surface_tile ~= nil, "surface (1,1) returns tile") engine.test.equals(surface_tile.name, "grass", "surface (1,1) name = grass") local wall_tile = maps.tile_at_layer("wall", 0, 0) engine.test.assert(wall_tile ~= nil, "wall (0,0) returns tile") engine.test.equals(wall_tile.name, "wall_brick", "wall (0,0) name = wall_brick (from walls_tilemap)") -- Empty cell returns nil local empty_top = maps.tile_at_layer("topsurface", 0, 0) engine.test.equals(empty_top, nil, "empty topsurface cell returns nil") -- OOB returns nil engine.test.equals(maps.tile_at_layer("surface", -1, 0), nil, "OOB tile_at_layer returns nil") -- Legacy tile_at backwards-compat: queries surface layer local legacy = maps.tile_at(1, 1) engine.test.equals(legacy.name, "grass", "legacy tile_at(x,y) queries surface layer") -- ---------- Task 7: Gameplay queries ---------- -- Surface-only cells (inside, no wall) → walkable engine.test.assert(maps.is_walkable(1, 1), "inner (1,1) walkable (surface=grass, no wall)") engine.test.assert(maps.is_walkable(2, 2), "inner (2,2) walkable") -- Wall cells block walk engine.test.assert(not maps.is_walkable(0, 0), "(0,0) not walkable (wall present)") engine.test.assert(not maps.is_walkable(3, 3), "(3,3) not walkable (wall present)") -- blocks_walk direct query engine.test.assert(maps.blocks_walk(0, 0), "(0,0) blocks_walk") engine.test.assert(not maps.blocks_walk(1, 1), "(1,1) does not block_walk") -- blocks_sight: wall layer cell blocks sight; surface-only cell does not engine.test.assert(maps.blocks_sight(0, 0), "(0,0) wall blocks sight") engine.test.assert(not maps.blocks_sight(1, 1), "(1,1) no wall does not block sight") -- OOB → not walkable, blocks_walk false (out-of-map) engine.test.assert(not maps.is_walkable(-1, 0), "OOB not walkable") engine.test.assert(not maps.blocks_walk(-1, 0), "OOB does not block_walk (outside)") -- ---------- Task 8: Layer iteration + draw ---------- -- iterate_layers_pre_entities should visit foundation/subsurface/surface/topsurface in order local pre_visited = {} maps.iterate_layers_pre_entities(function(layer_name) pre_visited[#pre_visited + 1] = layer_name end) -- Only layers that exist in the map are visited; surface + topsurface here engine.test.equals(#pre_visited, 2, "pre_entities visits 2 layers (surface, topsurface)") engine.test.equals(pre_visited[1], "surface", "pre_entities[0] = surface") engine.test.equals(pre_visited[2], "topsurface", "pre_entities[1] = topsurface") -- iterate_layers_post_entities should visit wall layers if present local post_visited = {} maps.iterate_layers_post_entities(function(layer_name) post_visited[#post_visited + 1] = layer_name end) engine.test.equals(#post_visited, 1, "post_entities visits 1 layer (wall)") engine.test.equals(post_visited[1], "wall", "post_entities[0] = wall") -- draw_map should not raise (no actual render here; just call-through) -- We're not in a render-phase, so this will be a no-op or error-safe. -- Skip actual draw_map test; integration test happens in spine-prototype manual run. -- ---------- Task 9: v1 map auto-migration on load ---------- -- The existing demo.map.json is v1-format. Loading should auto-upgrade. local v1_id = maps.load("maps/demo.map.json") engine.test.equals(v1_id, "demo", "v1 demo map loaded") maps.set_current(v1_id) maps.load_textures(aliases) engine.test.equals(maps.atlas_count(), 1, "v1 auto-upgrade has 1 atlas") engine.test.equals(maps.atlas_id_at(0), "demo_tilemap", "v1 atlas[0] = demo_tilemap") engine.test.assert(maps.has_layer("surface"), "v1 auto-upgrade has surface layer") engine.test.assert(not maps.has_layer("wall"), "v1 has no wall layer") -- Walkability via v2-API should match v1-API result (atlas records now loaded) engine.test.assert(maps.is_walkable(5, 5), "v1 (5,5) walkable (was grass in v1 test)") engine.test.assert(not maps.is_walkable(0, 0), "v1 (0,0) not walkable (was stone in v1 test)") -- Geometry assertions (demo map, already current after load_textures block) local size = maps.size() engine.test.equals(size.w, 16, "map width = 16") engine.test.equals(size.h, 16, "map height = 16") engine.test.equals(maps.tile_size(), 32, "tile_size = 32") -- Tile lookup + walkability (post-M.2: tile.id is int, tile.name is string) engine.test.equals(maps.tile_at(0, 0).name, "stone", "tile_at(0,0) name = stone") engine.test.equals(maps.tile_at(5, 5).name, "grass", "tile_at(5,5) name = grass") engine.test.assert(not maps.is_walkable(0, 0), "stone border non-walkable") engine.test.assert(maps.is_walkable(5, 5), "grass interior walkable") -- Arity-flex tile_at: 3-arg with nil map_id falls back to current_map (P.2.1) engine.test.equals(maps.tile_at(nil, 5, 5).name, "grass", "tile_at 3-arg nil-fallback name = grass") -- Out-of-bounds returns nil engine.test.equals(maps.tile_at(-1, 0), nil, "tile_at OOB negative tx returns nil") engine.test.equals(maps.tile_at(0, 16), nil, "tile_at OOB ty=size.h returns nil") -- list() returns registered map-ids (demo + demo_v2 loaded earlier) local list = maps.list() engine.test.equals(#list, 2, "maps.list returns 2 registered maps") -- ---------- v0.4.0: Write APIs — set_cell_gid ---------- -- Build a fresh v2 map for write tests (separate from earlier load-based tests). -- maps.create only handles the v1 path (requires tilemap_table); use maps.load -- with a fixture file for the v2 path instead. local write_id = maps.load("maps/write_test.map.json") maps.set_current(write_id) -- Happy path local gid_painted = maps.encode_gid(0, 1, 0) maps.set_cell_gid("surface", 1, 2, gid_painted) engine.test.equals(maps.cell_gid("surface", 1, 2), gid_painted, "set_cell_gid writes the cell that cell_gid reads back") -- Bounds-error: x >= w local ok1, err1 = pcall(function() maps.set_cell_gid("surface", 4, 0, gid_painted) end) engine.test.assert(not ok1, "set_cell_gid rejects x out of bounds") engine.test.assert(err1 and err1:find("bounds"), "set_cell_gid bounds-error mentions 'bounds'") -- Bounds-error: negative coord local ok2 = pcall(function() maps.set_cell_gid("surface", -1, 0, gid_painted) end) engine.test.assert(not ok2, "set_cell_gid rejects negative x") -- Unknown-layer error local ok3, err3 = pcall(function() maps.set_cell_gid("bogus_layer", 0, 0, gid_painted) end) engine.test.assert(not ok3, "set_cell_gid rejects unknown layer") engine.test.assert(err3 and err3:find("layer"), "unknown-layer error mentions 'layer'") -- On-demand layer allocation engine.test.assert(maps.cell_gid("wall", 0, 0) == 0, "wall layer empty before any write") maps.set_cell_gid("wall", 2, 2, gid_painted) engine.test.equals(maps.cell_gid("wall", 2, 2), gid_painted, "set_cell_gid auto-allocates the wall layer") engine.test.equals(maps.cell_gid("wall", 0, 0), 0, "auto-allocated layer initialised to all-zero except the written cell") -- ---------- v0.4.0: set_roof ---------- -- Roof should be nil before any write. No direct cell_roof reader exists yet -- (out of scope for v0.4.0). Verify behaviourally via JSON round-trip in Task 3. -- Happy path: set roof cells, no assertion on read since no public reader maps.set_roof(1, 1, 1) maps.set_roof(1, 1, 0) -- toggle back maps.set_roof(2, 2, 1) -- leave this one set for Task 3's round-trip test -- Value validation local ok_v, err_v = pcall(function() maps.set_roof(0, 0, 2) end) engine.test.assert(not ok_v, "set_roof rejects value=2") engine.test.assert(err_v and err_v:find("0.*1"), "set_roof error mentions 0 or 1") -- Bounds rejection local ok_b = pcall(function() maps.set_roof(4, 0, 1) end) engine.test.assert(not ok_b, "set_roof rejects x out of bounds") -- ---------- v0.4.0: save_to_disk ---------- -- Map state from Task 1+2: surface(1,2) = gid_painted, wall(2,2) = gid_painted, -- roof(2,2) = 1. Save and re-load to verify round-trip. -- Re-establish deterministic state (defensive — earlier asserts may have toggled). maps.set_cell_gid("surface", 1, 2, gid_painted) maps.set_cell_gid("wall", 2, 2, gid_painted) maps.set_roof(2, 2, 1) -- Pick a temp path. Use TEMP env var for Windows compatibility; fall back to -- os.tmpname() on POSIX (where it returns a writable path). local tmp_dir = os.getenv("TEMP") or os.getenv("TMP") or "/tmp" -- Unique suffix via os.clock() + os.time() to avoid collisions. local tmp_suffix = string.format("sporel_maps_test_%d_%d", os.time(), math.floor(os.clock() * 1e6) % 1000000) local out_path = tmp_dir .. "/" .. tmp_suffix .. "_1.json" local out_path_2 = tmp_dir .. "/" .. tmp_suffix .. "_2.json" local rl_path = tmp_dir .. "/" .. tmp_suffix .. "_rl.json" maps.save_to_disk("write_test", out_path) -- Re-read raw bytes for byte-determinism test local f1 = io.open(out_path, "r") engine.test.assert(f1 ~= nil, "save_to_disk wrote a readable file") local bytes_1 = f1:read("*a") f1:close() -- Save a second time; bytes must match exactly maps.save_to_disk("write_test", out_path_2) local f2 = io.open(out_path_2, "r") local bytes_2 = f2:read("*a") f2:close() engine.test.equals(bytes_1, bytes_2, "save_to_disk is byte-deterministic across calls") -- Verify JSON has expected keys engine.test.assert(bytes_1:find('"schema_version"') ~= nil, "saved file has schema_version key") engine.test.assert(bytes_1:find('"surface"') ~= nil, "saved file has surface key") engine.test.assert(bytes_1:find('"wall"') ~= nil, "saved file has wall key (auto-allocated)") engine.test.assert(bytes_1:find('"roof"') ~= nil, "saved file has roof key (set in Task 2)") -- Re-load (with patched id to avoid registry collision). -- engine.asset.load_json only accepts relative (sandboxed) paths, so write -- the round-trip file into the maps-test lib's own maps/ directory where -- the asset resolver can find it by relative path. local lib_dir = engine.module.dir_of("lib-core.maps-test") local rewritten = bytes_1:gsub('"id"%s*:%s*"write_test"', '"id":"write_test_reloaded"') local abs_rl_path = lib_dir .. "/maps/write_test_reloaded.map.json" local rf = io.open(abs_rl_path, "w"); rf:write(rewritten); rf:close() maps.load("maps/write_test_reloaded.map.json") engine.test.equals(maps.cell_gid("surface", 1, 2, "write_test_reloaded"), gid_painted, "save round-trip preserves surface cell") engine.test.equals(maps.cell_gid("wall", 2, 2, "write_test_reloaded"), gid_painted, "save round-trip preserves wall cell") -- Cleanup os.remove(out_path); os.remove(out_path_2); os.remove(abs_rl_path) -- ===================================================================== -- 0.5.7: Cell-Tile material API tests (maps_t1) -- ===================================================================== -- Reload the write_test map for a clean slate. maps.set_current("write_test") -- maps_t1: round-trip set/get maps.set_cell_material("surface", 1, 1, true) engine.test.equals(maps.get_cell_material("surface", 1, 1), true, "maps_t1: set_cell_material then get_cell_material round-trips") maps.set_cell_material("surface", 1, 1, false) engine.test.equals(maps.get_cell_material("surface", 1, 1), false, "maps_t1: set_cell_material(false) clears the flag") -- Out-of-bounds rejection local ok_oob, err_oob = pcall(function() maps.set_cell_material("surface", -1, 0, true) end) engine.test.assert(not ok_oob, "maps_t1: set_cell_material rejects negative x") engine.test.assert(err_oob and err_oob:find("bounds"), "maps_t1: bounds-error mentions 'bounds'") -- Unknown-layer rejection local ok_lay = pcall(function() maps.set_cell_material("bogus_layer", 0, 0, true) end) engine.test.assert(not ok_lay, "maps_t1: set_cell_material rejects unknown layer") -- ===================================================================== -- maps_t2-t7: Cell-Tile slot-resolution proofs -- ===================================================================== -- write_test is 4x4. Use v2_size for w/h reference (already declared earlier). -- maps_t2: cell_material_slot returns a non-nil record for a -- cells_material cell. -- Clear the surface layer's cells_material first. for cy = 0, v2_size.h - 1 do for cx = 0, v2_size.w - 1 do maps.set_cell_material("surface", cx, cy, false) end end maps.set_cell_material("surface", 1, 1, true) local rec = maps.cell_material_slot("surface", 1, 1) engine.test.assert(rec ~= nil, "maps_t2: cell_material_slot returns non-nil for cell-tile material") -- maps_t3: isolated slot reachable for the first time. -- Clear cells_material AND vertices, place an isolated paint at (2, 2). for cy = 0, v2_size.h - 1 do for cx = 0, v2_size.w - 1 do maps.set_cell_material("surface", cx, cy, false) maps.set_vertex("surface", cx, cy, false) end maps.set_vertex("surface", v2_size.w, cy, false) end for cx = 0, v2_size.w do maps.set_vertex("surface", cx, v2_size.h, false) end maps.set_cell_material("surface", 2, 2, true) local r3 = maps.cell_material_slot("surface", 2, 2) engine.test.equals(r3.slot, 0, "maps_t3: slot 0 isolated reachable for first time") engine.test.equals(r3.rot, 0, "maps_t3: isolated rot=0") -- maps_t4: two adjacent cells → two end-caps facing each other. maps.set_cell_material("surface", 1, 2, true) maps.set_cell_material("surface", 2, 2, true) local r4a = maps.cell_material_slot("surface", 1, 2) local r4b = maps.cell_material_slot("surface", 2, 2) engine.test.equals(r4a.slot, 1, "maps_t4: left cell is end (slot 1)") engine.test.equals(r4a.rot, 1, "maps_t4: left end points east (rot 1)") engine.test.equals(r4b.slot, 1, "maps_t4: right cell is end (slot 1)") engine.test.equals(r4b.rot, 3, "maps_t4: right end points west (rot 3)") -- maps_t5: 3-cell line → middle is straight. maps.set_cell_material("surface", 0, 2, true) -- already painted (1,2) and (2,2) from t4 local r5 = maps.cell_material_slot("surface", 1, 2) engine.test.equals(r5.slot, 4, "maps_t5: middle of 3-cell line is straight (slot 4)") -- rot may be 1 or 3 (horizontal canonical); accept both engine.test.assert(r5.rot == 1 or r5.rot == 3, "maps_t5: straight is horizontal (rot 1 or 3)") -- Reset surface for t6. for cx = 0, v2_size.w - 1 do maps.set_cell_material("surface", cx, 2, false) end -- maps_t6: T-junction at (1, 2) with arms W (0, 2), E (2, 2), S (1, 3). -- write_test is 4x4 so (1, 3) is the last row. maps.set_cell_material("surface", 0, 2, true) maps.set_cell_material("surface", 1, 2, true) maps.set_cell_material("surface", 2, 2, true) maps.set_cell_material("surface", 1, 3, true) local r6 = maps.cell_material_slot("surface", 1, 2) engine.test.equals(r6.slot, 5, "maps_t6: T-junction is tee_open (slot 5)") -- Reset for t7. for cy = 0, v2_size.h - 1 do for cx = 0, v2_size.w - 1 do maps.set_cell_material("surface", cx, cy, false) end end -- maps_t7: 4-cell "+" at (1, 1) with N (1, 0), E (2, 1), S (1, 2), W (0, 1). maps.set_cell_material("surface", 1, 1, true) maps.set_cell_material("surface", 1, 0, true) maps.set_cell_material("surface", 2, 1, true) maps.set_cell_material("surface", 1, 2, true) maps.set_cell_material("surface", 0, 1, true) local r7 = maps.cell_material_slot("surface", 1, 1) engine.test.equals(r7.slot, 8, "maps_t7: cross is cross_open (slot 8) — second previously-unreachable slot reachable") -- Reset surface fully so later tests (if any added) start clean. for cy = 0, v2_size.h - 1 do for cx = 0, v2_size.w - 1 do maps.set_cell_material("surface", cx, cy, false) end end engine.exit(engine.test.failures()) end return M