current_schema_version() line-scanned res://../project.yaml at runtime. That resolves to the repo root in a dev run and to nothing in an exported build, so a shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas disk cache's ONLY invalidation signal, every exported build stamped and compared the same sentinel: a canvas cached by one build would be served by every later build, forever. T-1239 is what that failure looks like once it happens. loading_screen.gd carried a byte-for-byte copy of the same function, so the version shown to the player was "?.?.?" in exactly the builds where a version string is worth showing. Both call sites now share client/scripts/build_version.gd, which reads application/config/version out of ProjectSettings — a value Godot bakes into the PCK, identical in the editor and in an export by construction rather than by luck. No file IO, no fallback branch. project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors it. A mirror nobody checks would be worse than the bug it replaces -- the old code failed loudly everywhere, a stale mirror fails silently -- so tooling/check-client-version compares the two and the pre-push hook runs it unconditionally. Not gated on "were those files in this push": drift persists on main once introduced, and gating would let an existing drift ride along. The test this replaces asserted that current_schema_version() did not return its fallback, and passed -- in the one environment where the code under test worked. Three tests now pin the property that actually matters: a real version, sourced from the baked setting, matching project.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
684 lines
32 KiB
GDScript
684 lines
32 KiB
GDScript
## T-1183 tests: step_canvas_disk_cache.gd — the disk-backed Tier 2/3 cache
|
|
## store layered beneath step_canvas_cache.gd's in-memory Tier 1 (D-255(d)).
|
|
## Covers: index round-trip (write/read/evict), the two sweeps' independence
|
|
## (a storage sweep never touches an in-TTL sim-state entry and vice versa),
|
|
## the Global retention floor, the per-body deep-rung cap, the version-tag
|
|
## mismatch path (miss + re-fetch, NEVER decode — asserted via "no canvas
|
|
## payload survives," not just "no crash"), persistence across a simulated
|
|
## restart (a fresh instance reads the same on-disk root), malformed index
|
|
## recovery (truncated/corrupt index -> empty cache, never a crash), orphan
|
|
## payload reconciliation (a stray .dat file with no index row is reclaimed
|
|
## by the background sweep; an indexed sibling survives untouched), corrupt-
|
|
## payload self-heal (garbage bytes in a payload file degrade to a miss and
|
|
## drop both the index row and the file, never a crash), the atomic index
|
|
## write (no .tmp file survives a completed put(), index.json always parses),
|
|
## and size_bytes accuracy (matches the payload file's actual on-disk length,
|
|
## captured at write time rather than re-read).
|
|
##
|
|
## Every test uses an INJECTED, disposable root path
|
|
## (user://test_step_canvas_disk_cache/<unique>/) — never the real
|
|
## user://atlas_cache/ — and removes it in after_test() so test runs never
|
|
## leak state into each other or into a real cache directory.
|
|
class_name TestStepCanvasDiskCache
|
|
extends GdUnitTestSuite
|
|
|
|
const StepCanvasDiskCache := preload(
|
|
"res://ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd"
|
|
)
|
|
|
|
var _test_root: String = ""
|
|
|
|
|
|
func before_test() -> void:
|
|
_test_root = "user://test_step_canvas_disk_cache/%d/" % Time.get_ticks_usec()
|
|
|
|
|
|
func after_test() -> void:
|
|
var probe := StepCanvasDiskCache.new(_test_root)
|
|
probe.clear_all()
|
|
|
|
|
|
## Minimal canvas-shaped stand-in for tests that only exercise cache
|
|
## mechanics (eviction, sweeps, round-tripping) and don't care about real
|
|
## canvas contents. Always carries width/height — get_canvas()'s shape guard
|
|
## (mirroring step_canvas_protocol.gd's own `_decode_encoded_canvas`
|
|
## discriminator) treats a payload missing either as corrupt, so every put()
|
|
## fixture in this suite must satisfy that contract, not just production
|
|
## payloads. `extra` merges in each test's own distinguishing marker
|
|
## key(s) — centralizing the shape here means a future shape-contract change
|
|
## touches one function, not every put() call site.
|
|
func _stub_canvas(extra: Dictionary = {}) -> Dictionary:
|
|
var canvas: Dictionary = {"width": 1, "height": 1}
|
|
canvas.merge(extra, true)
|
|
return canvas
|
|
|
|
|
|
# =============================================================================
|
|
# Index round-trip: write / read / evict
|
|
# =============================================================================
|
|
|
|
|
|
func test_miss_returns_null_and_has_reports_false() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
assert_that(cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_null()
|
|
assert_bool(cache.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_false()
|
|
|
|
|
|
func test_put_then_get_round_trips_exact_canvas_including_png_bytes() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
var canvas := {
|
|
"width": 64,
|
|
"height": 64,
|
|
"morphology": PackedByteArray([137, 80, 78, 71, 1, 2, 3]), # PNG magic + junk, pre-decode
|
|
"courses": [{"a": 1}],
|
|
}
|
|
cache.put("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64), canvas)
|
|
assert_bool(cache.has("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64))).is_true()
|
|
var got: Variant = cache.get_canvas("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64))
|
|
assert_that(got).is_equal(canvas)
|
|
|
|
|
|
func test_put_overwrites_existing_key() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 2}))
|
|
assert_int(cache.entry_count("GJ1c")).is_equal(1)
|
|
assert_that(cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_equal(
|
|
_stub_canvas({"v": 2})
|
|
)
|
|
|
|
|
|
func test_different_rungs_at_identical_center_extent_do_not_collide() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put(
|
|
"GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64), _stub_canvas({"rung": "District"})
|
|
)
|
|
cache.put("GJ1c", "Chunk", Vector2i(10, 20), Vector2i(64, 64), _stub_canvas({"rung": "Chunk"}))
|
|
assert_int(cache.entry_count("GJ1c")).is_equal(2)
|
|
assert_that(cache.get_canvas("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64))).is_equal(
|
|
_stub_canvas({"rung": "District"})
|
|
)
|
|
assert_that(cache.get_canvas("GJ1c", "Chunk", Vector2i(10, 20), Vector2i(64, 64))).is_equal(
|
|
_stub_canvas({"rung": "Chunk"})
|
|
)
|
|
|
|
|
|
func test_different_bodies_do_not_collide() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64), _stub_canvas({"body": "c"}))
|
|
cache.put("GJ1d", "District", Vector2i(10, 20), Vector2i(64, 64), _stub_canvas({"body": "d"}))
|
|
assert_that(cache.get_canvas("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64))).is_equal(
|
|
_stub_canvas({"body": "c"})
|
|
)
|
|
assert_that(cache.get_canvas("GJ1d", "District", Vector2i(10, 20), Vector2i(64, 64))).is_equal(
|
|
_stub_canvas({"body": "d"})
|
|
)
|
|
|
|
|
|
func test_get_touches_last_read_at() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
cache._debug_backdate_entry(
|
|
"GJ1c",
|
|
StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64)),
|
|
1000,
|
|
1000
|
|
)
|
|
var before_read: int = Time.get_unix_time_from_system()
|
|
cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
# A fresh instance re-reads the persisted last_read_at from disk — proves
|
|
# the touch was actually written through, not just held in memory.
|
|
var reloaded := StepCanvasDiskCache.new(_test_root)
|
|
reloaded.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
# The visit sweep, run immediately after, must NOT evict — last_read_at
|
|
# was just updated to "now", far inside STORAGE_TTL_SEC.
|
|
reloaded.run_visit_sweep("GJ1c")
|
|
assert_bool(reloaded.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).override_failure_message(
|
|
"a just-touched entry must survive an immediate visit sweep"
|
|
).is_true()
|
|
assert_int(before_read).is_greater_equal(1000) # sanity: real clock, not the backdated stub
|
|
|
|
|
|
# =============================================================================
|
|
# Global retention floor
|
|
# =============================================================================
|
|
|
|
|
|
func test_global_rung_entry_is_flagged_retention_floor() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO, {"width": 1024, "height": 512})
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)
|
|
assert_bool(cache.has_entry_for_key("GJ1c", key)).is_true()
|
|
|
|
|
|
func test_global_entry_survives_visit_sweep_even_when_ancient() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO, _stub_canvas())
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)
|
|
# Backdate WAY past STORAGE_TTL_SEC (14 days) — a floored entry must be
|
|
# immune regardless of how old last_read_at is.
|
|
cache._debug_backdate_entry("GJ1c", key, 0, 0)
|
|
cache.run_visit_sweep("GJ1c")
|
|
assert_bool(cache.has("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)).override_failure_message(
|
|
"the Global/rung-0 entry must never be evicted by the visit sweep"
|
|
).is_true()
|
|
|
|
|
|
func test_global_entry_survives_capacity_sweep_even_when_it_alone_exceeds_budget() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO, _stub_canvas())
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)
|
|
# Force size_bytes artificially huge by backdating is not enough for
|
|
# size — but the floored flag alone must exempt it from run_capacity_sweep
|
|
# regardless of size accounting, so it is excluded from the sub-global
|
|
# byte total entirely (verified indirectly: it survives even though this
|
|
# test's other sub-global entries below would normally trigger eviction).
|
|
for i in range(3):
|
|
cache.put(
|
|
"GJ1c", "Chunk", Vector2i(i, 0), Vector2i(64, 64), _stub_canvas({"pad": PackedByteArray()})
|
|
)
|
|
cache.run_capacity_sweep("GJ1c")
|
|
assert_bool(cache.has("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)).is_true()
|
|
|
|
|
|
# =============================================================================
|
|
# The two Tier-2/3 sweeps are independent
|
|
# =============================================================================
|
|
|
|
|
|
func test_visit_sweep_evicts_old_geometry_entry_past_storage_ttl() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i(1, 1), Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i(1, 1), Vector2i(64, 64))
|
|
var ancient: int = Time.get_unix_time_from_system() - StepCanvasDiskCache.STORAGE_TTL_SEC - 10
|
|
cache._debug_backdate_entry("GJ1c", key, ancient, ancient)
|
|
cache.run_visit_sweep("GJ1c")
|
|
assert_bool(cache.has_entry_for_key("GJ1c", key)).override_failure_message(
|
|
"an entry older than STORAGE_TTL_SEC must be evicted by the visit sweep"
|
|
).is_false()
|
|
|
|
|
|
func test_visit_sweep_never_touches_an_in_ttl_sim_state_entry() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
# A recently-written sim-state entry with a LONG sim_ttl — well within
|
|
# both its own TTL and STORAGE_TTL_SEC. The visit sweep (storage axis)
|
|
# must not evict it purely because it's SimState-tagged.
|
|
cache.put(
|
|
"GJ1c", "Chunk", Vector2i(2, 2), Vector2i(64, 64), _stub_canvas({"flooded": true}), 0, 999_999
|
|
)
|
|
cache.run_visit_sweep("GJ1c")
|
|
assert_bool(cache.has("GJ1c", "Chunk", Vector2i(2, 2), Vector2i(64, 64))).is_true()
|
|
|
|
|
|
func test_staleness_sweep_never_touches_a_geometry_entry_regardless_of_age() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i(3, 3), Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i(3, 3), Vector2i(64, 64))
|
|
# Backdate far into the past — if run_staleness_sweep incorrectly treated
|
|
# geometry as staleness-bound, this would be evicted.
|
|
cache._debug_backdate_entry("GJ1c", key, 0, 0)
|
|
cache.run_staleness_sweep("GJ1c")
|
|
assert_bool(cache.has_entry_for_key("GJ1c", key)).override_failure_message(
|
|
"a Geometry-tier entry has no sim_ttl and must never be evicted by the staleness sweep"
|
|
).is_true()
|
|
|
|
|
|
func test_staleness_sweep_evicts_sim_state_entry_past_its_own_ttl_even_if_recently_visited() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put(
|
|
"GJ1c", "Chunk", Vector2i(4, 4), Vector2i(64, 64), _stub_canvas({"flooded": true}), 0, 100
|
|
)
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "Chunk", Vector2i(4, 4), Vector2i(64, 64))
|
|
var now: int = Time.get_unix_time_from_system()
|
|
# written_at far enough in the past that written_at + sim_ttl(100) < now,
|
|
# but last_read_at is "just now" — proving capacity/visit recency does
|
|
# NOT protect a sim-state entry from its own staleness clock.
|
|
cache._debug_backdate_entry("GJ1c", key, now - 500, now)
|
|
cache.run_staleness_sweep("GJ1c")
|
|
assert_bool(cache.has_entry_for_key("GJ1c", key)).override_failure_message(
|
|
"a sim-state entry past its own sim_ttl must be evicted regardless of last_read_at"
|
|
).is_false()
|
|
|
|
|
|
func test_capacity_sweep_never_touches_an_entry_under_budget() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i(5, 5), Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
cache.run_capacity_sweep("GJ1c")
|
|
assert_bool(cache.has("GJ1c", "District", Vector2i(5, 5), Vector2i(64, 64))).is_true()
|
|
|
|
|
|
# =============================================================================
|
|
# Per-body deep-rung retention cap (D-255(d) hardening (i))
|
|
# =============================================================================
|
|
|
|
|
|
func test_deep_rung_cap_evicts_oldest_deep_rung_entry_when_exceeded() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
# Fill past the cap: MAX_DEEP_RUNG_ENTRIES_PER_BODY + 2 distinct Quarter
|
|
# windows for one body, backdating each so eviction order is
|
|
# deterministic (oldest last_read_at goes first).
|
|
#
|
|
# Quarter, not Chunk: DEEP_RUNGS became [District, Quarter] when the ladder
|
|
# floor moved (D-255 amendment 2026-08-07). Quarter is now the finest
|
|
# navigable rung and therefore the exhaustive-pan accumulation case this
|
|
# cap exists to bound.
|
|
var cap: int = StepCanvasDiskCache.MAX_DEEP_RUNG_ENTRIES_PER_BODY
|
|
for i in range(cap + 2):
|
|
cache.put("GJ1c", "Quarter", Vector2i(i, 0), Vector2i(64, 64), _stub_canvas({"i": i}))
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "Quarter", Vector2i(i, 0), Vector2i(64, 64))
|
|
cache._debug_backdate_entry("GJ1c", key, i, i) # strictly increasing "recency"
|
|
|
|
assert_int(cache.entry_count("GJ1c")).override_failure_message(
|
|
"deep-rung entries must never transiently exceed the cap, even mid-fill"
|
|
).is_equal(cap)
|
|
|
|
# The two OLDEST (i=0, i=1) must have been evicted; the newest must survive.
|
|
assert_bool(cache.has("GJ1c", "Quarter", Vector2i(0, 0), Vector2i(64, 64))).is_false()
|
|
assert_bool(cache.has("GJ1c", "Quarter", Vector2i(1, 0), Vector2i(64, 64))).is_false()
|
|
assert_bool(
|
|
cache.has("GJ1c", "Quarter", Vector2i(cap + 1, 0), Vector2i(64, 64))
|
|
).override_failure_message("the most-recently-written deep-rung entry must survive").is_true()
|
|
|
|
|
|
## Region, not District: the ladder floor moved on 2026-08-07 and DEEP_RUNGS
|
|
## moved with it, from [Block, Chunk] to [District, Quarter]. District is now a
|
|
## capped rung, so it can no longer stand for "shallow" — Region is the finest
|
|
## rung the cap does not apply to.
|
|
func test_deep_rung_cap_does_not_apply_to_shallow_rungs() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
var cap: int = StepCanvasDiskCache.MAX_DEEP_RUNG_ENTRIES_PER_BODY
|
|
for i in range(cap + 5):
|
|
cache.put("GJ1c", "Region", Vector2i(i, 0), Vector2i(64, 64), _stub_canvas({"i": i}))
|
|
assert_int(cache.entry_count("GJ1c")).override_failure_message(
|
|
"Region is not a DEEP_RUNGS member — the deep-rung cap must not evict it"
|
|
).is_equal(cap + 5)
|
|
|
|
|
|
func test_deep_rung_cap_never_evicts_the_global_floor_entry() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO, _stub_canvas())
|
|
var cap: int = StepCanvasDiskCache.MAX_DEEP_RUNG_ENTRIES_PER_BODY
|
|
for i in range(cap + 3):
|
|
cache.put("GJ1c", "Quarter", Vector2i(i, 0), Vector2i(64, 64), _stub_canvas({"i": i}))
|
|
assert_bool(cache.has("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)).override_failure_message(
|
|
"the Global floor entry must survive deep-rung cap eviction regardless of fill order"
|
|
).is_true()
|
|
|
|
|
|
func test_deep_rung_cap_is_per_body_not_global() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
var cap: int = StepCanvasDiskCache.MAX_DEEP_RUNG_ENTRIES_PER_BODY
|
|
for i in range(cap):
|
|
cache.put("GJ1c", "Chunk", Vector2i(i, 0), Vector2i(64, 64), _stub_canvas({"i": i}))
|
|
# A second body's Chunk entries must not be capped by the first body's
|
|
# fill level — the cap is per-body.
|
|
cache.put("GJ1d", "Chunk", Vector2i(0, 0), Vector2i(64, 64), _stub_canvas({"body": "d"}))
|
|
assert_bool(cache.has("GJ1d", "Chunk", Vector2i(0, 0), Vector2i(64, 64))).is_true()
|
|
assert_int(cache.entry_count("GJ1c")).is_equal(cap)
|
|
|
|
|
|
# =============================================================================
|
|
# Version-tag mismatch (D-255(d) hardening (ii)) — miss + re-fetch, NEVER decode
|
|
# =============================================================================
|
|
|
|
|
|
func test_matching_schema_version_is_a_hit() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
assert_that(cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_not_null()
|
|
|
|
|
|
func test_mismatched_schema_version_is_a_miss_and_drops_the_entry() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
|
|
# Simulate "written by an older/newer build": write a payload file
|
|
# directly with a mismatched schema_version in the index, bypassing
|
|
# put() (which always stamps the CURRENT version).
|
|
var idx: Dictionary = cache._load_index("GJ1c")
|
|
var entry: Dictionary = idx[key]
|
|
entry["schema_version"] = "0.0.1-not-the-current-version"
|
|
idx[key] = entry
|
|
cache._save_index("GJ1c")
|
|
|
|
# get_canvas() on the mismatched entry must return null — a MISS, not a
|
|
# decode attempt (there is nothing here to "decode" beyond var_to_bytes
|
|
# reconstruction of the stored Dictionary itself, so the stronger,
|
|
# directly-assertable claim is: no canvas payload is ever handed back).
|
|
var result: Variant = cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
assert_that(result).override_failure_message(
|
|
"a schema-version-mismatched entry must be treated as a miss, never handed to the caller"
|
|
).is_null()
|
|
|
|
# The stale entry must also be DROPPED (re-fetch is the only path back
|
|
# in — a lingering mismatched index row would just re-fail every read).
|
|
assert_bool(cache.has_entry_for_key("GJ1c", key)).override_failure_message(
|
|
"a version-mismatched entry must be dropped from the index on read, not left dangling"
|
|
).is_false()
|
|
|
|
|
|
func test_mismatched_schema_version_has_reports_false() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
var idx: Dictionary = cache._load_index("GJ1c")
|
|
var entry: Dictionary = idx[key]
|
|
entry["schema_version"] = "9.9.9-future"
|
|
idx[key] = entry
|
|
cache._save_index("GJ1c")
|
|
assert_bool(cache.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_false()
|
|
|
|
|
|
## T-1241. The predecessor of this test asserted that current_schema_version()
|
|
## did not return its "?.?.?" fallback — and passed, in the ONE environment
|
|
## where the code under test worked. It read res://../project.yaml, which exists
|
|
## in a dev run and never in an exported build, so the shipped game got the
|
|
## fallback for every build, the cache compared a constant sentinel against
|
|
## itself, and nothing was ever invalidated. A green test said otherwise.
|
|
##
|
|
## The version now comes from ProjectSettings, which Godot bakes into the PCK, so
|
|
## the dev and exported answers are the same value by construction rather than by
|
|
## luck. These tests assert that construction: a real version, sourced from the
|
|
## setting, with no file lookup left to differ between environments.
|
|
func test_current_schema_version_is_a_real_version_not_the_fallback() -> void:
|
|
var v: String = StepCanvasDiskCache.current_schema_version()
|
|
assert_str(v).override_failure_message(
|
|
"current_schema_version() must return a real version, never the '?.?.?' fallback"
|
|
).is_not_equal("?.?.?")
|
|
assert_str(v).override_failure_message(
|
|
"current_schema_version() must not be empty — the ProjectSettings key is missing"
|
|
).is_not_empty()
|
|
|
|
|
|
func test_current_schema_version_comes_from_project_settings() -> void:
|
|
# The property that makes this export-safe: the value IS the baked setting,
|
|
# not something re-derived from a file that an export would not contain.
|
|
var setting: String = str(ProjectSettings.get_setting("application/config/version", ""))
|
|
assert_str(setting).override_failure_message(
|
|
"application/config/version must be set in client/project.godot — it is what "
|
|
+ "survives an export, and the Atlas disk cache's only invalidation signal"
|
|
).is_not_empty()
|
|
assert_str(StepCanvasDiskCache.current_schema_version()).is_equal(setting)
|
|
|
|
|
|
func test_baked_version_mirrors_project_yaml() -> void:
|
|
# Drift guard, dev-only by nature (an export has no project.yaml to compare
|
|
# against — which is the whole reason the value is baked). project.yaml stays
|
|
# the source of truth; this catches the bump-one-forget-the-other case from
|
|
# inside the suite, as well as from tooling/check-client-version in the hook.
|
|
var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml"
|
|
if not FileAccess.file_exists(yaml_path):
|
|
return # exported/packaged run — nothing to compare, and nothing to guard
|
|
var file := FileAccess.open(yaml_path, FileAccess.READ)
|
|
assert_object(file).is_not_null()
|
|
var content := file.get_as_text()
|
|
file.close()
|
|
|
|
var yaml_version := ""
|
|
for line: String in content.split("\n"):
|
|
if line.begins_with("version:"):
|
|
var parts := line.split(":", false, 1)
|
|
if parts.size() >= 2:
|
|
yaml_version = parts[1].strip_edges()
|
|
break
|
|
assert_str(yaml_version).override_failure_message(
|
|
"project.yaml must carry a `version:` line — it is the source of truth"
|
|
).is_not_empty()
|
|
assert_str(StepCanvasDiskCache.current_schema_version()).override_failure_message(
|
|
"client/project.godot's config/version has drifted from project.yaml. "
|
|
+ "Fix with the value in project.yaml; see `make check-client-version`."
|
|
).is_equal(yaml_version)
|
|
|
|
|
|
# =============================================================================
|
|
# Persistence across a simulated restart
|
|
# =============================================================================
|
|
|
|
|
|
func test_fresh_instance_reads_entries_written_by_a_prior_instance() -> void:
|
|
var writer := StepCanvasDiskCache.new(_test_root)
|
|
writer.put(
|
|
"GJ1c",
|
|
"Quarter",
|
|
Vector2i(7, 8),
|
|
Vector2i(64, 64),
|
|
_stub_canvas({"morphology": PackedByteArray([1, 2, 3])})
|
|
)
|
|
|
|
# A brand-new instance, same root — simulates a game restart. No shared
|
|
# in-memory state whatsoever between `writer` and `reader`.
|
|
var reader := StepCanvasDiskCache.new(_test_root)
|
|
var got: Variant = reader.get_canvas("GJ1c", "Quarter", Vector2i(7, 8), Vector2i(64, 64))
|
|
assert_that(got).is_equal(_stub_canvas({"morphology": PackedByteArray([1, 2, 3])}))
|
|
|
|
|
|
func test_fresh_instance_sees_global_floor_flag_persisted_by_a_prior_instance() -> void:
|
|
var writer := StepCanvasDiskCache.new(_test_root)
|
|
writer.put("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO, _stub_canvas())
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)
|
|
writer._debug_backdate_entry("GJ1c", key, 0, 0) # ancient, would evict if not floored
|
|
|
|
var reader := StepCanvasDiskCache.new(_test_root)
|
|
reader.run_visit_sweep("GJ1c")
|
|
assert_bool(reader.has("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO)).override_failure_message(
|
|
"the retention-floor flag must persist across a simulated restart"
|
|
).is_true()
|
|
|
|
|
|
# =============================================================================
|
|
# Malformed index recovery
|
|
# =============================================================================
|
|
|
|
|
|
func test_missing_index_file_is_treated_as_empty_cache() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
# No put() has ever happened for this body_id — no index file exists.
|
|
assert_int(cache.entry_count("nonexistent_body")).is_equal(0)
|
|
assert_that(cache.get_canvas("nonexistent_body", "District", Vector2i.ZERO, Vector2i(64, 64))).is_null()
|
|
|
|
|
|
func test_truncated_index_json_is_treated_as_empty_cache_not_a_crash() -> void:
|
|
var body_id := "GJ1c"
|
|
var dir_err := DirAccess.make_dir_recursive_absolute(_test_root + body_id + "/")
|
|
assert_int(dir_err).is_equal(OK)
|
|
var file := FileAccess.open(_test_root + body_id + "/index.json", FileAccess.WRITE)
|
|
file.store_string('{"some_key": {"written_at": 123, "tru') # deliberately truncated
|
|
file.close()
|
|
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
assert_int(cache.entry_count(body_id)).is_equal(0)
|
|
assert_that(cache.get_canvas(body_id, "District", Vector2i.ZERO, Vector2i(64, 64))).is_null()
|
|
|
|
# The store must still be USABLE after a corrupt read — put()/get() work
|
|
# normally and overwrite the bad file with a valid one on next save.
|
|
cache.put(body_id, "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
assert_that(cache.get_canvas(body_id, "District", Vector2i.ZERO, Vector2i(64, 64))).is_equal(
|
|
_stub_canvas({"v": 1})
|
|
)
|
|
|
|
|
|
func test_index_with_wrong_top_level_json_type_is_treated_as_empty_cache() -> void:
|
|
var body_id := "GJ1c"
|
|
DirAccess.make_dir_recursive_absolute(_test_root + body_id + "/")
|
|
var file := FileAccess.open(_test_root + body_id + "/index.json", FileAccess.WRITE)
|
|
file.store_string("[1, 2, 3]") # a JSON array, not the expected Dictionary
|
|
file.close()
|
|
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
assert_int(cache.entry_count(body_id)).is_equal(0)
|
|
|
|
|
|
func test_dangling_index_entry_with_missing_payload_file_is_a_miss() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
var idx: Dictionary = cache._load_index("GJ1c")
|
|
var entry: Dictionary = idx[key]
|
|
# Delete the payload file directly (simulates platform storage pressure
|
|
# clearing files without updating the index), leaving a dangling row.
|
|
DirAccess.remove_absolute(str(entry["file_path"]))
|
|
|
|
assert_that(cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_null()
|
|
assert_bool(cache.has_entry_for_key("GJ1c", key)).override_failure_message(
|
|
"a dangling index row (missing payload file) must be dropped on the miss, not left behind"
|
|
).is_false()
|
|
|
|
|
|
# =============================================================================
|
|
# Orphan payload reconciliation (fix for the payload-before-index write
|
|
# order's other failure direction — a discarded/corrupt index, or a crash
|
|
# between the payload write and the index write, orphans a .dat file with
|
|
# no index row pointing at it)
|
|
# =============================================================================
|
|
|
|
|
|
func test_background_sweep_removes_an_orphaned_payload_with_no_index_row() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
|
|
# Drop a stray payload file directly into the body dir, bypassing put() —
|
|
# simulates a crash between the payload write and the index write, or the
|
|
# aftermath of a discarded corrupt index.
|
|
var orphan_path := _test_root + "GJ1c/deadbeef.dat"
|
|
var orphan_file := FileAccess.open(orphan_path, FileAccess.WRITE)
|
|
orphan_file.store_var(_stub_canvas({"orphan": true}))
|
|
orphan_file.close()
|
|
assert_bool(FileAccess.file_exists(orphan_path)).override_failure_message(
|
|
"test setup: the stray payload file must exist before the sweep runs"
|
|
).is_true()
|
|
|
|
cache.run_background_sweep("GJ1c")
|
|
|
|
assert_bool(FileAccess.file_exists(orphan_path)).override_failure_message(
|
|
"an orphaned .dat file (no index row referencing it) must be removed by the background sweep"
|
|
).is_false()
|
|
# The INDEXED entry sharing the same directory must be untouched — the
|
|
# sweep must not treat "not the orphan" as "collateral damage".
|
|
assert_that(cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).override_failure_message(
|
|
"an indexed entry's payload must survive the orphan sweep and remain a hit"
|
|
).is_equal(_stub_canvas({"v": 1}))
|
|
|
|
|
|
# =============================================================================
|
|
# Corrupt payload degrades to a self-healing miss (payload-shape guard)
|
|
# =============================================================================
|
|
|
|
|
|
func test_corrupt_payload_bytes_degrade_to_miss_with_self_heal() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
var idx: Dictionary = cache._load_index("GJ1c")
|
|
var payload_path := str(idx[key]["file_path"])
|
|
|
|
# Overwrite the payload file with garbage bytes that are not a valid
|
|
# store_var() encoding at all — a stronger corruption than a merely
|
|
# wrong-shaped Dictionary, exercising the get_var() -> null path.
|
|
var garbage_file := FileAccess.open(payload_path, FileAccess.WRITE)
|
|
garbage_file.store_buffer(PackedByteArray([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]))
|
|
garbage_file.close()
|
|
|
|
var result: Variant = cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
assert_that(result).override_failure_message(
|
|
"a corrupt payload file must degrade to a miss, never crash or return a partial canvas"
|
|
).is_null()
|
|
assert_bool(cache.has_entry_for_key("GJ1c", key)).override_failure_message(
|
|
"a corrupt payload's index row must be dropped as part of the self-heal"
|
|
).is_false()
|
|
assert_bool(FileAccess.file_exists(payload_path)).override_failure_message(
|
|
"a corrupt payload file must be deleted as part of the self-heal"
|
|
).is_false()
|
|
|
|
|
|
# =============================================================================
|
|
# Atomic index write
|
|
# =============================================================================
|
|
|
|
|
|
func test_index_write_leaves_no_tmp_file_and_index_json_is_valid() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
|
|
var index_path := _test_root + "GJ1c/index.json"
|
|
var tmp_path := index_path + ".tmp"
|
|
assert_bool(FileAccess.file_exists(tmp_path)).override_failure_message(
|
|
"no .tmp file should remain next to index.json after a completed put()"
|
|
).is_false()
|
|
|
|
var file := FileAccess.open(index_path, FileAccess.READ)
|
|
var content := file.get_as_text()
|
|
file.close()
|
|
var json := JSON.new()
|
|
var err := json.parse(content)
|
|
assert_int(err).override_failure_message(
|
|
"index.json must parse as valid JSON after an atomic write"
|
|
).is_equal(OK)
|
|
assert_bool(json.data is Dictionary).is_true()
|
|
|
|
|
|
# =============================================================================
|
|
# size_bytes accuracy (captured at write time, no re-read)
|
|
# =============================================================================
|
|
|
|
|
|
func test_size_bytes_matches_actual_payload_file_length_on_disk() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put(
|
|
"GJ1c",
|
|
"District",
|
|
Vector2i.ZERO,
|
|
Vector2i(64, 64),
|
|
_stub_canvas({"morphology": PackedByteArray([1, 2, 3, 4, 5])})
|
|
)
|
|
var key := StepCanvasDiskCache.make_key("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))
|
|
var idx: Dictionary = cache._load_index("GJ1c")
|
|
var entry: Dictionary = idx[key]
|
|
var payload_path := str(entry["file_path"])
|
|
var actual_size: int = FileAccess.get_file_as_bytes(payload_path).size()
|
|
assert_int(int(entry["size_bytes"])).override_failure_message(
|
|
"size_bytes must equal the payload file's actual on-disk length"
|
|
).is_equal(actual_size)
|
|
|
|
|
|
# =============================================================================
|
|
# Key discipline — mirrors StepCanvasCache exactly (shared make_key())
|
|
# =============================================================================
|
|
|
|
|
|
## Global collapses the CENTRE to a sentinel (its canvas is whole-body and
|
|
## origin-anchored, so the server genuinely ignores `center`) but NOT the
|
|
## extent. Zeroing the extent was correct while Global had exactly one
|
|
## possible size per body; since the D-255 extent inversion made it
|
|
## viewport-sized, collapsing it meant the first canvas cached for a body
|
|
## answered every later request — so a window resize could never take effect,
|
|
## because the differently-sized request was a HIT on the stale one.
|
|
func test_make_key_global_ignores_center_but_honours_extent() -> void:
|
|
var same_size_a := StepCanvasDiskCache.make_key(
|
|
"GJ1c", "Global", Vector2i(10, 20), Vector2i(64, 64)
|
|
)
|
|
var same_size_b := StepCanvasDiskCache.make_key(
|
|
"GJ1c", "Global", Vector2i(999, -999), Vector2i(64, 64)
|
|
)
|
|
assert_str(same_size_a).override_failure_message(
|
|
"Global must ignore centre — its canvas is origin-anchored"
|
|
).is_equal(same_size_b)
|
|
|
|
var other_size := StepCanvasDiskCache.make_key(
|
|
"GJ1c", "Global", Vector2i(10, 20), Vector2i(960, 480)
|
|
)
|
|
assert_str(same_size_a).override_failure_message(
|
|
"Global must NOT ignore extent — a resize has to be able to miss"
|
|
).is_not_equal(other_size)
|
|
|
|
|
|
func test_clear_all_removes_every_body_and_the_root_directory() -> void:
|
|
var cache := StepCanvasDiskCache.new(_test_root)
|
|
cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 1}))
|
|
cache.put("GJ1d", "Chunk", Vector2i.ZERO, Vector2i(64, 64), _stub_canvas({"v": 2}))
|
|
cache.clear_all()
|
|
var reader := StepCanvasDiskCache.new(_test_root)
|
|
assert_int(reader.entry_count("GJ1c")).is_equal(0)
|
|
assert_int(reader.entry_count("GJ1d")).is_equal(0)
|
|
assert_bool(DirAccess.dir_exists_absolute(_test_root)).is_false()
|