magicciv/src/game/engine/tests/unit/test_sprite_rendering_capability.gd
Natalie 3a2d589e6e feat(@projects/@magic-civilization): mark p2-11a and p2-56b as complete
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-04 03:07:14 -04:00

244 lines
8.3 KiB
GDScript

extends GutTest
## Tests for p0-23 sprite rendering capability.
##
## Verifies the additive-overlay design rule: procedural draw primitives are
## the always-on baseline, and sprite textures (when present on disk) render
## on top — never replacing the baseline. With zero sprites in
## `assets/sprites/`, unit + city rendering must still produce the full
## procedural visual (circle + letter + HP bar) without crashing.
const UnitRendererScript: GDScript = preload(
"res://engine/src/rendering/unit_renderer.gd"
)
const CityRendererScript: GDScript = preload(
"res://engine/src/rendering/city_renderer.gd"
)
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
var _renderer: UnitRendererScript = null
var _city_renderer: CityRendererScript = null
var _player: PlayerScript = null
func before_all() -> void:
DataLoader.load_theme("age-of-dwarves")
func before_each() -> void:
_player = PlayerScript.new()
_player.index = 0
_player.race_id = "dwarf"
_player.gender_preset = "male"
_player.color = Color.BLUE
_player.units = []
_player.cities = []
GameState.players = [_player]
_renderer = UnitRendererScript.new()
add_child_autofree(_renderer)
_city_renderer = CityRendererScript.new()
add_child_autofree(_city_renderer)
func after_each() -> void:
GameState.players = []
# ── Named-constant contract ──────────────────────────────────────────
func test_unit_sprite_lookup_formats_are_named_constants() -> void:
# Both formats are publicly accessible so ThemeAssets' cache and any
# future sprite-generation tooling can compose the same paths.
assert_eq(
UnitRendererScript.SPRITE_LOOKUP_RACE_SEX_FORMAT,
"sprites/units/%s_%s_%s.png",
"race+sex lookup format must use type_id_race_sex ordering",
)
assert_eq(
UnitRendererScript.SPRITE_LOOKUP_GENERIC_FORMAT,
"sprites/units/%s.png",
"generic fallback format must be bare type_id",
)
func test_city_sprite_lookup_format_is_named_constant() -> void:
assert_eq(
CityRendererScript.SPRITE_LOOKUP_CITY_FORMAT,
"sprites/cities/city_q%d.png",
"city sprite lookup format must be population-tier keyed",
)
# ── Unit sprite cache + lookup ───────────────────────────────────────
func test_unit_sprite_cache_tries_race_sex_variant_first() -> void:
# With no files on disk the cache populates both keys as null, but both
# paths must be attempted — i.e. the cache dictionary must contain both
# entries after a warm call.
_renderer._cache_unit_sprites("warriors", "dwarf", "male")
var variant_path: String = UnitRendererScript.SPRITE_LOOKUP_RACE_SEX_FORMAT % [
"warriors", "dwarf", "male",
]
var generic_path: String = UnitRendererScript.SPRITE_LOOKUP_GENERIC_FORMAT % "warriors"
assert_true(
_renderer._unit_sprite_cache.has(variant_path),
"race+sex path '%s' must be cached" % variant_path,
)
assert_true(
_renderer._unit_sprite_cache.has(generic_path),
"generic path '%s' must be cached" % generic_path,
)
func test_unit_sprite_cache_skips_race_sex_when_unknown() -> void:
# When race and sex are empty (e.g. wild creatures), only the generic
# path is attempted — no bogus "<type>__.png" style keys are ever added.
_renderer._cache_unit_sprites("dire_wolf", "", "")
var generic_path: String = UnitRendererScript.SPRITE_LOOKUP_GENERIC_FORMAT % "dire_wolf"
assert_true(
_renderer._unit_sprite_cache.has(generic_path),
"generic path must be cached for raceless units",
)
for key: String in _renderer._unit_sprite_cache:
assert_false(
key.contains("__"),
"no malformed empty-race/sex keys permitted: found '%s'" % key,
)
func test_unit_sprite_lookup_falls_back_to_procedural_when_no_files_exist() -> void:
# After p2-62: when no authored sprite exists on disk, the lookup falls
# back to the procedural renderer rather than returning null. The legacy
# circle-and-letter baseline still renders below it as before.
var sprite: Texture2D = _renderer._get_unit_sprite("warriors", "dwarf", "male")
assert_not_null(
sprite,
"with empty sprites/ dir, lookup must return procedural fallback",
)
# ── Unit rendering: baseline never removed ───────────────────────────
func test_unit_sync_populates_data_without_crashing() -> void:
# sync_units should ingest units regardless of sprite presence.
var unit: UnitScript = _build_unit("u1", "warriors", 0, Vector2i(3, 3))
_renderer.sync_units([unit])
assert_true(
_renderer._units.has("u1"),
"sync_units must register unit even when sprite is missing",
)
var data: Dictionary = _renderer._units["u1"]
assert_eq(
String(data.get("type_id", "")),
"warriors",
"type_id must be stored for sprite lookup",
)
assert_eq(
String(data.get("race_id", "")),
"dwarf",
"race_id must resolve from owning Player when unit lacks its own",
)
assert_eq(
String(data.get("sex", "")),
"male",
"sex must resolve from Player.gender_preset fallback",
)
func test_unit_sync_queues_redraw_without_sprites() -> void:
# With zero sprites on disk, sync_units must register the unit and queue
# a redraw — the procedural baseline (circle + letter) is then drawn by
# the engine on the next frame. Calling _draw() directly isn't safe
# outside the engine's draw cycle, so we assert that the scheduling
# path works end-to-end instead.
var unit: UnitScript = _build_unit("u1", "warriors", 0, Vector2i(3, 3))
_renderer.sync_units([unit])
assert_true(
_renderer.is_queued_for_deletion() == false,
"renderer must remain live after sync",
)
assert_true(
_renderer._units.has("u1"),
"sync_units must keep the unit entry regardless of sprite presence",
)
var sprite: Texture2D = _renderer._get_unit_sprite("warriors", "dwarf", "male")
assert_not_null(
sprite,
"with zero sprites on disk, lookup returns procedural fallback (p2-62)",
)
# ── City rendering: baseline never removed ───────────────────────────
func test_city_sprite_lookup_returns_null_when_no_files_exist() -> void:
var city: CityScript = _build_city("ironhold", 3, 0)
var sprite: Texture2D = _city_renderer._get_city_sprite(city)
assert_null(
sprite,
"with empty sprites/ dir, city sprite lookup must return null",
)
func test_city_population_to_quality_bucket_respects_named_bounds() -> void:
# population=0 -> quality 1; population=25 -> quality 5; clamp at max.
for pop: int in [0, 4, 5, 24, 99]:
var q: int = clampi(
pop / CityRendererScript.CITY_QUALITY_BUCKET + 1,
1, CityRendererScript.CITY_QUALITY_MAX,
)
assert_between(
q, 1, CityRendererScript.CITY_QUALITY_MAX,
"quality bucket for pop=%d must be in [1,%d]" % [
pop, CityRendererScript.CITY_QUALITY_MAX,
],
)
func test_city_add_queues_redraw_without_sprites() -> void:
# With zero sprites on disk, add_city must register the city and leave
# the renderer ready to draw its procedural baseline (circle + outline
# + name letter + HP bar + cultural borders). Direct _draw() calls are
# not safe outside the engine's draw cycle.
var city: CityScript = _build_city("ironhold", 3, 0)
_city_renderer.add_city(city)
assert_true(
_city_renderer._cities.has("ironhold"),
"add_city must register the city regardless of sprite presence",
)
var sprite: Texture2D = _city_renderer._get_city_sprite(city)
assert_null(
sprite,
"with zero sprites on disk, city sprite lookup returns null",
)
# ── Helpers ─────────────────────────────────────────────────────────
func _build_unit(
id: String, type_id: String, owner: int, pos: Vector2i,
) -> UnitScript:
var u: UnitScript = UnitScript.new()
u.id = id
u.unit_id = type_id
u.type_id = type_id
u.owner = owner
u.position = pos
u.hp = 80
u.max_hp = 80
u.unit_type = "melee"
return u
func _build_city(id: String, population: int, owner: int) -> CityScript:
var c: CityScript = CityScript.new()
c.id = id
c.city_name = id.capitalize()
c.owner_index = owner
c.position = Vector2i(5, 5)
c.population = population
c.hp = 100
c.max_hp = 100
c.owned_tiles = [Vector2i(5, 5)]
return c