feat(ui): client disk cache — FileAccess tiers beneath the LRU (D-255, T-1183)

step_canvas_disk_cache.gd: the Tier 2/3 store per D-255(d) and the
round-2 three-tier spec. Payload is the WIRE, pre-decode — store_var/
get_var round-trips the PNG-encoded PackedByteArrays natively, and
Image.load_png_from_buffer never runs in this file. One composite key
shared verbatim with Tier 1 (hash filenames for filesystem safety);
per-body index.json with malformed-index recovery (rebuild-or-discard,
never crash).

Three independent eviction mechanisms, exactly as ruled: rung-0 Global
carries a retention floor no sweep touches (now also threaded into
Tier 1 per the T-1182 handoff); Tier 2 geometry is byte-valid forever
and evicts only by time-since-last-visit (14d starting tunable, on
body-open) and LRU byte budget (256 MiB/body, 5-min coarse timer) —
two separate sweeps; Tier 3 sim-state TTL is wired and tested but has
no production caller yet (no sim-state field exists on
EncodedStepCanvas — the D-253 stub inheritance, documented).

Hardening per D-255(d), both mandatory: per-body deep-rung cap
(512 Block+Chunk entries, enforced synchronously in put(), floor- and
budget-independent — the ticket sanctions count-or-quota; count chosen
as the direct D-226(d) information-content proxy) and a schema/version
tag on every entry (project.yaml version via the existing
loading_screen line-scan idiom — D-192 co-ship makes the client
version the wire-schema version; exact-inequality mismatch = miss +
drop, NEVER decode, checked in both has() and get_canvas()).

Integration: request_now() checks Tier 2 on a Tier-1 miss (synchronous
promote), Ready responses write through to both tiers, Pending never
writes; viewer runs the visit sweep on body-open + the background
sweep on a 5-min timer.

30 new disk-cache tests + 7 request-integration + 3 sweep-wiring tests
(restart persistence, sweep independence both directions, cap
semantics, version-mismatch never-decode, corrupt-index recovery).
Full client suite 3,440/3,440, 0 orphans; cold-parse clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 11:08:48 +02:00
co-authored by Claude Fable 5
parent 83986dbb90
commit 6b76e365bb
6 changed files with 1327 additions and 39 deletions
+60
View File
@@ -325,3 +325,63 @@ func test_edge_scroll_inactive_well_inside_the_viewport() -> void:
v._app_has_focus = true
v._last_mouse_pos = Vector2(400.0, 300.0) # dead center — far from any edge
assert_bool(v._is_cursor_edge_scrolling()).is_false()
# =============================================================================
# T-1183: disk-cache sweep-trigger wiring
# =============================================================================
## enter() must invoke the disk cache's (2a) visit sweep for the entered
## body — a smoke test that the wiring exists and doesn't crash; the sweep
## LOGIC itself (what gets evicted and why) is covered exhaustively by
## test_step_canvas_disk_cache.gd. Uses a distinctive body_id with nothing
## ever cached under it, so the sweep is a true no-op read (no writes to the
## real user://atlas_cache/ directory this test could leak).
func test_enter_runs_the_disk_cache_visit_sweep_without_crashing() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "T1183_sweep_smoke_test_body", "body_radius_km": 6238.4}, {})
# If the wiring is broken (e.g. calling a method that doesn't exist), the
# enter() call itself would already have failed above — reaching here
# with the expected held rung is the assertion.
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
## The coarse background sweep timer exists, is not per-frame (a real
## Timer node, not a _process()-driven counter), autostarts, and is set to
## the documented coarse interval — never a sub-frame or per-frame value.
func test_disk_sweep_timer_is_coarse_and_autostarts() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
var timer: Timer = v.get_node("DiskSweepTimer")
assert_object(timer).is_not_null()
# Godot resets Timer.autostart to false once the timer has actually
# started after entering the tree (documented engine behavior — the flag
# is a one-shot "start me on _ready()" instruction, not a persistent
# state mirror). The real behavioral guarantee is "the timer is running,
# unpaused, without anyone having to call start() explicitly" —
# is_stopped() == false is the correct read of that.
assert_bool(timer.is_stopped()).override_failure_message(
"the disk sweep timer must autostart running, no explicit start() call needed"
).is_false()
assert_float(timer.wait_time).override_failure_message(
"the disk sweep timer must be coarse (minutes), never a per-frame interval"
).is_greater_equal(60.0)
## The timer's timeout must actually route to the disk cache's
## run_background_sweep() for the currently-entered body — verified by
## invoking the private handler directly (the same "call the handler, don't
## wait on a real Timer" pattern used elsewhere in this cluster for
## non-blocking test speed) against an injected-root request so this test
## touches no real cache files.
func test_disk_sweep_timeout_handler_runs_background_sweep_for_the_current_body() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "T1183_sweep_smoke_test_body", "body_radius_km": 6238.4}, {})
# No live server, no cached entries for this body — the assertion is
# that calling the handler does not crash and leaves the (empty) cache
# consistent, mirroring the enter()-sweep smoke test above.
v._on_disk_sweep_timeout()
assert_int(v.get_request().get_disk_cache().entry_count("T1183_sweep_smoke_test_body")).is_equal(0)