diff --git a/files/docs/milestones/22/Part_10_Refactor_splash_loader.md b/files/docs/milestones/22/Part_10_Refactor_splash_loader.md
new file mode 100644
index 000000000..96781f647
--- /dev/null
+++ b/files/docs/milestones/22/Part_10_Refactor_splash_loader.md
@@ -0,0 +1,119 @@
+# Refactor splash loader; add monotonic progress & tests - #893
+
+---
+
+## PR #893 Summary: Refactor splash loader; add monotonic progress & tests
+
+**Repository:** [ikostan/SkyLockAssault](https://github.com/ikostan/SkyLockAssault)
+**Author:** @ikostan
+**Branch:** `implement-three-state-presentation-pipeline-in-splash_screengd` → `main`
+**Linked Issue:** #778 ([TASK] TASK-01: Implement Three-State Presentation Pipeline in splash_screen.gd)
+**Milestone:** Milestone 22 – Optimize Test Suite Runtime & Fix Loading Screen
+**Labels:** enhancement, web, GUI, frontend
+
+### Purpose
+
+Restructure `splash_screen.gd` into a clear three-state presentation pipeline that separates resource polling, UI progress presentation, and scene-transition routing. Deliver strictly monotonic, deterministic progress updates and harden loading/transition behavior with validation and fallbacks. Add Gut integration tests to lock in the new behavior.
+
+Restructure `splash_screen.gd` to separate concerns: polling backend, presentation smoothing, and transition routing. Add display_target and presentation_speed; use move_toward for deterministic, linear UI progress and ensure strictly monotonic display_target updates from threaded loader progress. Improve ResourceLoader handling (match on status), validate PackedScene on load, set load_failed on errors, and provide fallbacks (DEFAULT_STARTUP_SCENE or direct file load). Add defensive checks before change_scene_to_packed and ensure transitions respect min_load_time. Add Gut integration tests (test/gut/test_splash_screen.gd + .uid) covering monotonic scaling, presentation convergence, backend early-exit, invalid-resource handling, and transition timing.
+
+### Core Improvements
+
+#### 1. Three-State Pipeline (`scripts/ui/screens/splash_screen.gd`)
+
+Replaced the monolithic `_process` loop with three focused responsibilities:
+
+| Stage | Responsibility |
+|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Backend polling** (`_poll_resource_backend`) | Query `ResourceLoader.load_threaded_get_status`, update `display_target` monotonically via `max()`, handle `THREAD_LOAD_*` statuses |
+| **Presentation** (`_update_presentation_handler`) | Drive `loader_progress` toward `display_target` with `move_toward(..., presentation_speed * delta)` for linear, frame-rate-independent smoothing |
+| **Transition routing** (`_evaluate_transition_router`) | Gate scene change on loaded/failed state, `min_load_time` elapsed, progress ≈ 100%, and not already transitioning |
+
+#### 2. Deterministic & Monotonic Progress
+
+- New state: `display_target` and `presentation_speed`
+- Backend progress only raises `display_target` (never decreases)
+- UI progress uses `move_toward` instead of `lerp` for predictable linear convergence
+- On load failure / invalid resource, force `display_target = 100.0` so the bar still completes and fallback routing can run
+
+#### 3. Hardened Loading & Transitions
+
+- Explicit `match` on ResourceLoader status values
+- Validate loaded resource is a `PackedScene` before use; set `load_failed` otherwise
+- Defensive checks (`is_instance_valid`, type check) before `change_scene_to_packed`
+- Fallbacks:
+ - Empty `next_scene` → `DEFAULT_STARTUP_SCENE`
+ - Load failure / validation failure → direct `change_scene_to_file(target_path)`
+- Transitions respect `min_load_time` so the splash is visible for a minimum duration
+
+#### 4. Gut Integration Tests (`test/gut/test_splash_screen.gd`)
+
+Coverage includes:
+- Monotonic `display_target` behavior under decreasing backend progress
+- Linear `move_toward` convergence tied to delta and `presentation_speed`
+- Backend early-return when already loaded
+- Invalid / non-PackedScene resource handling (`load_failed` + progress forced to 100)
+- Transition router respects `min_load_time` even when progress is complete
+
+### Benefits
+
+- Clear separation of concerns makes the splash loader easier to reason about and extend
+- Progress bar no longer jumps backward or stalls due to non-monotonic backend reports
+- Safer scene transitions with explicit validation and fallback paths
+- Automated tests protect against regressions in progress, failure handling, and timing
+
+### Status Notes
+
+Fully addresses the objectives of TASK-01 (#778): three-state pipeline, `move_toward` linear progress, monotonic scaling, and PackedScene validation with safe fallbacks.
+
+---
+
+## Reviewer's Guide
+
+Refactors the splash screen loader into a three-stage pipeline (backend polling, presentation, and transition routing), introduces deterministic monotonic progress handling, hardens scene-loading fallbacks, slightly adjusts test timeouts, and adds Gut integration tests documenting and locking in the new behavior.
+
+### File-Level Changes
+
+| Change | Details | Files |
+|---------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
+| Refactor splash_screen.gd into a three-stage loader pipeline with monotonic progress and guarded scene transitions. |
- Replace the monolithic _process loop with _ready plus three helpers: _poll_resource_backend, _update_presentation_handler, and _evaluate_transition_router, all invoked from _process(delta).
- Introduce presentation_speed (exported with clamping) and display_target to drive loader_progress via move_toward for linear, frame-rate-independent progress smoothing.
- Rework ResourceLoader threaded status handling using match, updating display_target monotonically via max() on backend progress and marking load_failed on failed or invalid resource states.
- Validate that threaded-loaded resources are PackedScene instances before assigning to scene; on invalid type or null, set load_failed and force display_target to 100 for failure fallback.
- Harden transition routing by gating scene changes on is_scene_loaded/load_failed, min_load_time, a near-100% loader_progress threshold, and transitioning flag, with fallbacks for empty next_scene and invalid PackedScene via change_scene_to_file and DEFAULT_STARTUP_SCENE.
| `scripts/ui/screens/splash_screen.gd` |
+| Relax shared test timeout configuration to accommodate longer-running or asynchronous tests. | - Increase TEST_TIMEOUT default from 5000ms to 7000ms while keeping DEFAULT_TIMEOUT unchanged.
| `tests/test_utils.py` |
+| Add Gut integration tests and milestone documentation to lock in splash screen progress and transition behavior. | - Create a Gut test suite that instantiates the splash screen scene and verifies load_start_time initialization in _ready(), monotonic display_target updates, and linear move_toward convergence affecting both loader_progress and progress_bar value.
- Add tests that exercise backend early-exit when the scene is already loaded, handling of non-PackedScene resources by setting load_failed and forcing display_target to 100, and transition router gating based on min_load_time and progress thresholds.
- Document the refactor, new three-state pipeline, monotonic progress behavior, hardened loading/transition logic, and test coverage in a milestone markdown file for PR #893.
- Register the new Gut test file with a corresponding .uid file so the test runner discovers the suite.
| `test/gut/test_splash_screen.gd`
`files/docs/milestones/22/Part_10_Refactor_splash_loader.md`
`test/gut/test_splash_screen.gd.uid` |
+
+### Assessment against linked issues
+
+| Issue | Objective | Addressed | Explanation |
+|------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------|-------------|
+| https://github.com/ikostan/SkyLockAssault/issues/778 | Refactor splash_screen.gd into a three-state presentation pipeline: update _process(delta) to capture frame delta, and split logic into _poll_resource_backend, a presentation handler using move_toward(), and a transition router, with strictly monotonic progress scaling via max() on backend progress. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/778 | Ensure progress animation is smooth and linear, independent of frame rate, and stop polling ResourceLoader.load_threaded_get_status() once the resource reaches its final cache state or failure. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/778 | Add defensive resource validation: perform an explicit PackedScene type check on ResourceLoader.load_threaded_get() results and protect change_scene_to_packed() with validation and safe fallbacks. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/780 | Implement a GUT unit test suite for splash_screen.gd in test_splash_screen.gd that follows repository test patterns (extends gut test.gd, proper setup/teardown, add_child_autofree, frame awaiting, and type-hinted variables). | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/780 | Add unit tests validating monotonic progress behavior and move_toward-based convergent, frame-rate-independent progress resolution for the splash screen. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/780 | Add unit tests validating transition gating mechanics for the splash screen: locked when the progress bar is incomplete and firing when progress reaches full completion under appropriate conditions. | ✅ | |
+
+### Possibly linked issues
+
+- **#778**: PR fully implements TASK-01’s three-state pipeline, linear move_toward progress, monotonic scaling, and PackedScene safeguards.
+
+---
+
+## PR #893 Summary: Bots / AI Contributions
+
+### AI / Bot Contributors
+
+- **@sourcery-ai**
+ Generated the PR summary and Reviewer’s Guide. Performed code review with suggestions (e.g., relaxing the exact `loader_progress == 100.0` check for `move_toward` convergence, and considering exposing `presentation_speed` as an exported variable).
+
+- **@coderabbitai**
+ Generated the PR summary, walkthrough, and poem. Conducted code review with actionable feedback (including test isolation for Globals settings and ensuring backend-path tests actually exercise the polled code).
+
+- **@deepsource-io**
+ Performed automated DeepSource Code Review and published a PR Report Card (Security / Reliability / Complexity / Hygiene).
+
+### Human Contributor
+
+- **@ikostan**
+ Primary author of the PR. Implemented the three-state splash loader refactor (backend polling, presentation smoothing with `move_toward` / monotonic `display_target`, and transition routing), hardened ResourceLoader / PackedScene validation and fallbacks, added Gut integration tests covering monotonic progress, presentation convergence, early-exit, invalid resources, and min-load-time gating, and iteratively refined the implementation across multiple commits.
+
+---
+
diff --git a/scripts/ui/screens/splash_screen.gd b/scripts/ui/screens/splash_screen.gd
index 9e96c055f..a96ce805f 100644
--- a/scripts/ui/screens/splash_screen.gd
+++ b/scripts/ui/screens/splash_screen.gd
@@ -13,9 +13,17 @@
extends Control
const DEFAULT_STARTUP_SCENE := "res://scenes/main_menu.tscn"
+const TRANSITION_PROGRESS_THRESHOLD: float = 99.9
+
+@export_range(0.0, 500.0, 0.1, "or_greater") var presentation_speed: float:
+ get:
+ return _presentation_speed
+ set(value):
+ _presentation_speed = max(0.0, value)
var resolved_next_scene: String = ""
var loader_progress: float = 0.0 # Current smoothed progress value.
+var display_target: float = 0.0 # Target display progress value.
var min_load_time: float = 1.0 # Minimum splashing time in seconds for visibility.
var load_start_time: float = 0.0 # Timestamp when splashing starts.
var is_scene_loaded: bool = false # Flag to track if the scene is fully loaded.
@@ -24,83 +32,111 @@ var load_failed: bool = false # Flag if splashing request failed.
var transitioning: bool = false # Flag to prevent multiple scene changes.
var label_text: String = "Loading: "
+var _presentation_speed: float = 50.0
+
@onready var progress_bar: ProgressBar = $ProgressBar # Progress bar UI element.
@onready var label: Label = $Label # Label for displaying loading status.
-# Polls loading status and updates UI. Changes scene when loaded.
-# Eliminated fake_progress; relies on real ResourceLoader progress.
-func _process(_delta: float) -> void:
- var elapsed_time: float = (Time.get_ticks_msec() / 1000.0) - load_start_time
+func _ready() -> void:
+ load_start_time = Time.get_ticks_msec() / 1000.0
+
+
+func _process(delta: float) -> void:
+ _poll_resource_backend()
+ _update_presentation_handler(delta)
+ _evaluate_transition_router()
- var real_progress: float = 0.0
- if is_scene_loaded:
- real_progress = 100.0 # Force 100% if already loaded (ignores post-load status).
- elif load_failed:
- real_progress = 0.0 # Keep at 0 if failed early.
- else:
- # Only poll if not done.
- var progress_array: Array = []
- var status: int = ResourceLoader.load_threaded_get_status(
- Globals.next_scene, progress_array
- )
-
- if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS:
+
+## State 1: Resource polling backend.
+## Polls status until cache state is reached or failure occurs.
+func _poll_resource_backend() -> void:
+ # Immediately stop polling if already marked as loaded or failed
+ if is_scene_loaded or load_failed:
+ return
+
+ var progress_array: Array = []
+ var status: int = ResourceLoader.load_threaded_get_status(Globals.next_scene, progress_array)
+
+ match status:
+ ResourceLoader.THREAD_LOAD_IN_PROGRESS:
if progress_array.size() > 0:
- real_progress = progress_array[0] * 100.0 # Convert to percentage.
+ var backend_progress: float = progress_array[0] * 100.0
+ # Preserve strictly monotonic progress scaling
+ display_target = max(display_target, backend_progress)
else:
Globals.log_message(
"Progress array empty during IN_PROGRESS.", Globals.LogLevel.WARNING
)
- elif status == ResourceLoader.THREAD_LOAD_LOADED:
- real_progress = 100.0
- if not is_scene_loaded:
+ ResourceLoader.THREAD_LOAD_LOADED:
+ display_target = 100.0
+ var loaded_res := ResourceLoader.load_threaded_get(Globals.next_scene)
+
+ # Type check validation on retrieved resource
+ if loaded_res is PackedScene:
+ scene = loaded_res
is_scene_loaded = true
- scene = ResourceLoader.load_threaded_get(Globals.next_scene)
Globals.log_message("Scene loaded successfully.", Globals.LogLevel.DEBUG)
+ else:
+ Globals.log_message(
+ "Loaded resource is null or not a PackedScene.", Globals.LogLevel.ERROR
+ )
+ load_failed = true
- elif (
- status == ResourceLoader.THREAD_LOAD_FAILED
- or status == ResourceLoader.THREAD_LOAD_INVALID_RESOURCE
- ):
+ ResourceLoader.THREAD_LOAD_FAILED, ResourceLoader.THREAD_LOAD_INVALID_RESOURCE:
Globals.log_message("Loading failed or invalid.", Globals.LogLevel.ERROR)
load_failed = true
+ display_target = 100.0 # Force target to end on failure fallback
- # Use real progress only (eliminates fake_progress process).
- # Sub-threads + Web min_load_time fix 50% quirk and give breathing room.
- var display_progress: float = real_progress
- if load_failed:
- display_progress = 100.0 # Force end on failure.
- # Smooth progress with lerp.
- loader_progress = lerp(loader_progress, display_progress, 0.01)
- # Update UI.
+## State 2: Presentation Handler.
+## Smoothly steps UI display towards display_target linearly via move_toward.
+func _update_presentation_handler(delta: float) -> void:
+ loader_progress = move_toward(loader_progress, display_target, presentation_speed * delta)
+
+ # Update visual UI components
progress_bar.value = loader_progress
label.text = label_text + str(int(loader_progress)) + "%"
- # Proceed only when both loaded (or failed fallback) and minimum time elapsed.
- if (is_scene_loaded or load_failed) and elapsed_time >= min_load_time and not transitioning:
- transitioning = true # Lock to prevent re-entry.
- loader_progress = 100.0
- progress_bar.value = 100.0
- label.text = label_text + "100%"
- # Optional delay at 100%.
- await get_tree().create_timer(1.5).timeout
+## State 3: Transition Router.
+## Evaluates temporal and resource safety flags to switch scene context profiles.
+func _evaluate_transition_router() -> void:
+ var elapsed_time: float = (Time.get_ticks_msec() / 1000.0) - load_start_time
- var target_path: String = Globals.next_scene # Cache the path.
- Globals.next_scene = "" # Reset to avoid stale values.
+ # Proceed only when loaded (or failed fallback), display progress >= threshold,
+ # and minimum time elapsed.
+ if (
+ (is_scene_loaded or load_failed)
+ and elapsed_time >= min_load_time
+ and loader_progress >= TRANSITION_PROGRESS_THRESHOLD
+ and not transitioning
+ ):
+ transitioning = true # Lock to prevent re-entry
+
+ var target_path: String = Globals.next_scene # Cache the path
+ Globals.next_scene = "" # Reset to avoid stale values
if target_path == "":
Globals.log_message(
"Empty next_scene - returning to main menu.", Globals.LogLevel.ERROR
)
- get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
+ get_tree().change_scene_to_file(DEFAULT_STARTUP_SCENE)
elif load_failed:
# Fallback to direct load on failure
Globals.log_message("Fallback: Loading scene directly.", Globals.LogLevel.WARNING)
get_tree().change_scene_to_file(target_path)
else:
- # Change scene.
- get_tree().change_scene_to_packed(scene)
+ # Defensive validation before changing scene
+ if is_instance_valid(scene) and scene is PackedScene:
+ get_tree().change_scene_to_packed(scene)
+ else:
+ Globals.log_message(
+ (
+ "PackedScene validation failed during scene transition."
+ + " Direct load fallback."
+ ),
+ Globals.LogLevel.ERROR
+ )
+ get_tree().change_scene_to_file(target_path)
diff --git a/test/gut/test_splash_screen.gd b/test/gut/test_splash_screen.gd
new file mode 100644
index 000000000..b9694b0aa
--- /dev/null
+++ b/test/gut/test_splash_screen.gd
@@ -0,0 +1,256 @@
+## Copyright (C) 2026 Egor Kostan
+## SPDX-License-Identifier: GPL-3.0-or-later
+## test_splash_screen.gd
+##
+## Integration test suite validating TASK-01 and TASK-03 implementations in splash_screen.gd:
+## 1. Monotonic progress updates driven through _poll_resource_backend.
+## 2. Unsubscribing/ceasing polling upon cached completion or failure.
+## 3. Linear progression convergence via move_toward.
+## 4. Defensive PackedScene resource type verification via _poll_resource_backend.
+## 5. Automatic initialization of load_start_time on _ready().
+## 6. Full TASK-03 (#780) transition gating and move_toward resolution assertions.
+
+extends "res://addons/gut/test.gd"
+
+const SPLASH_SCREEN_PATH: String = "res://scenes/splash_screen.tscn"
+
+var splash_instance: Control
+var original_next_scene: String
+var original_settings: GameSettingsResource
+
+
+func before_each() -> void:
+ original_next_scene = Globals.next_scene
+ Globals.next_scene = "res://scenes/main_menu.tscn"
+ original_settings = Globals.settings
+
+ # Silence logs during test runs
+ Globals.settings = GameSettingsResource.new()
+ Globals.settings.current_log_level = 4
+
+
+func after_each() -> void:
+ Globals.next_scene = original_next_scene
+ Globals.settings = original_settings
+
+ AudioManager.stop_all_sfx()
+
+ var focus_owner := get_viewport().gui_get_focus_owner()
+ if is_instance_valid(focus_owner):
+ focus_owner.release_focus()
+
+ if is_instance_valid(splash_instance):
+ splash_instance.queue_free()
+
+ # Safely release ONLY the specific dummy scene created by the transition router
+ await get_tree().process_frame
+ var current_scene := get_tree().current_scene
+ if is_instance_valid(current_scene) and current_scene.name == "SplashTestDummyScene":
+ current_scene.free()
+
+
+## Factory helper to safely instantiate the full scene layout and resolve @onready nodes
+func _create_splash_instance() -> Control:
+ assert_true(
+ FileAccess.file_exists(SPLASH_SCREEN_PATH),
+ "File integrity check: Scene path '%s' must exist." % SPLASH_SCREEN_PATH
+ )
+ var scene := load(SPLASH_SCREEN_PATH) as PackedScene
+ assert_not_null(scene, "Failed to load splash screen scene from path: %s" % SPLASH_SCREEN_PATH)
+
+ var instance := scene.instantiate() as Control
+ assert_not_null(instance, "Failed to instantiate splash screen scene layout.")
+ add_child_autofree(instance)
+ return instance
+
+
+# ==========================================================================
+# 1. LIFECYCLE & INITIALIZATION TESTS
+# ==========================================================================
+
+## Asserts that load_start_time is initialized upon entering the scene tree (_ready).
+func test_load_start_time_initialized_on_ready() -> void:
+ splash_instance = _create_splash_instance()
+
+ assert_gt(
+ splash_instance.load_start_time,
+ 0.0,
+ "Regression: load_start_time must be initialized via _ready() upon entering tree."
+ )
+
+
+# ==========================================================================
+# 2. MONOTONIC PROGRESS & PRESENTATION PIPELINE TESTS
+# ==========================================================================
+
+## Verifies that progress display target is monotonic and does not regress
+## when _poll_resource_backend processes lower progress values from the loader.
+func test_monotonic_progress_scaling() -> void:
+ splash_instance = _create_splash_instance()
+ Globals.next_scene = "res://scenes/main_menu.tscn"
+
+ # Simulate initial higher progress target
+ splash_instance.display_target = 60.0
+
+ # Request threaded loading to create an active IN_PROGRESS status
+ ResourceLoader.load_threaded_request(Globals.next_scene)
+
+ # Execute polling; initial loader progress (<60%) must not decrease display_target
+ splash_instance._poll_resource_backend()
+
+ assert_eq(
+ splash_instance.display_target,
+ 60.0,
+ "Display target must remain at peak value when _poll_resource_backend runs."
+ )
+
+
+## Explicit TASK-03 spec alias verifying presentation engine rejects downward layout tracking values
+func test_monotonic_progress_safeguard() -> void:
+ test_monotonic_progress_scaling()
+
+
+## Verifies that move_toward smoothly steps progress forward deterministically based on delta.
+func test_presentation_handler_linear_convergence() -> void:
+ splash_instance = _create_splash_instance()
+
+ splash_instance.loader_progress = 0.0
+ splash_instance.display_target = 100.0
+ splash_instance.presentation_speed = 50.0 # 50 units per second
+
+ # Advance by 0.5 seconds -> expected move_toward step = 25.0
+ splash_instance._update_presentation_handler(0.5)
+
+ assert_eq(
+ splash_instance.loader_progress,
+ 25.0,
+ "Presentation handler must step progress linearly according to delta * presentation_speed."
+ )
+ assert_eq(
+ splash_instance.progress_bar.value,
+ 25.0,
+ "ProgressBar UI value must reflect updated loader_progress."
+ )
+
+
+## Explicit TASK-03 spec alias evaluating frame-rate independent tracking and move_toward convergence
+func test_convergent_move_toward_resolution() -> void:
+ test_presentation_handler_linear_convergence()
+
+
+# ==========================================================================
+# 3. BACKEND POLLING & EARLY EXIT SAFEGUARDS
+# ==========================================================================
+
+## Ensures backend polling early-returns when the scene is already marked as loaded.
+func test_poll_resource_backend_stops_when_loaded() -> void:
+ splash_instance = _create_splash_instance()
+
+ splash_instance.is_scene_loaded = true
+ splash_instance.display_target = 100.0
+
+ # Act: Call backend polling
+ splash_instance._poll_resource_backend()
+
+ # Assert display_target was unmodified by any ResourceLoader query
+ assert_eq(
+ splash_instance.display_target,
+ 100.0,
+ "Backend polling must exit immediately when is_scene_loaded flag is true."
+ )
+
+
+## Ensures backend polling sets load_failed and forces target to 100 on non-PackedScene resources.
+func test_poll_resource_backend_handles_invalid_resource_type() -> void:
+ splash_instance = _create_splash_instance()
+
+ # Resolve valid non-PackedScene resource path on disk
+ var non_packed_path := "res://scripts/ui/screens/splash_screen.gd"
+ if not FileAccess.file_exists(non_packed_path):
+ non_packed_path = "res://scripts/splash_screen.gd"
+
+ Globals.next_scene = non_packed_path
+
+ var err := ResourceLoader.load_threaded_request(Globals.next_scene)
+ assert_eq(err, OK, "Threaded load request for non-PackedScene resource must succeed.")
+
+ # Wait until threaded loader finishes loading the GDScript resource
+ var status: int = ResourceLoader.load_threaded_get_status(Globals.next_scene)
+ while status == ResourceLoader.THREAD_LOAD_IN_PROGRESS:
+ await get_tree().process_frame
+ status = ResourceLoader.load_threaded_get_status(Globals.next_scene)
+
+ # Execute production backend polling logic
+ splash_instance._poll_resource_backend()
+
+ assert_true(
+ splash_instance.load_failed,
+ "Non-PackedScene resource package must trigger load_failed flag in _poll_resource_backend."
+ )
+ assert_eq(
+ splash_instance.display_target,
+ 100.0,
+ "Failure condition in _poll_resource_backend must push display_target to 100."
+ )
+
+
+# ==========================================================================
+# 4. TRANSITION ROUTER DEFENSIVE VALIDATION & GATING MECHANICS
+# ==========================================================================
+
+## Validates that the transition router respects minimum load time before proceeding.
+func test_transition_router_respects_min_load_time() -> void:
+ splash_instance = _create_splash_instance()
+
+ splash_instance.is_scene_loaded = true
+ splash_instance.loader_progress = 100.0
+ splash_instance.min_load_time = 10.0 # Set long minimum load time
+
+ splash_instance._evaluate_transition_router()
+
+ assert_false(
+ splash_instance.transitioning,
+ "Transition router must lock out scene change until min_load_time has elapsed."
+ )
+
+
+## TASK-03 Spec: Evaluates transition gating remains locked when progress bar is incomplete
+func test_transition_gating_mechanics_locked_when_bar_incomplete() -> void:
+ splash_instance = _create_splash_instance()
+
+ splash_instance.is_scene_loaded = true
+ splash_instance.loader_progress = 50.0 # Bar incomplete (<99.9%)
+ splash_instance.min_load_time = 0.0
+
+ splash_instance._evaluate_transition_router()
+
+ assert_false(
+ splash_instance.transitioning,
+ "Transition router must remain locked when presentation progress is incomplete."
+ )
+
+
+## TASK-03 Spec: Evaluates transition gating unlocks and fires transition on full completion
+func test_transition_gating_mechanics_fire_on_full_completion() -> void:
+ splash_instance = _create_splash_instance()
+
+ # Pack a uniquely named dummy node to avoid root-level collision
+ var dummy_root := Node2D.new()
+ dummy_root.name = "SplashTestDummyScene"
+
+ var dummy_packed := PackedScene.new()
+ dummy_packed.pack(dummy_root)
+ dummy_root.free()
+
+ splash_instance.scene = dummy_packed
+ Globals.next_scene = "res://scenes/dummy_test_scene.tscn"
+ splash_instance.is_scene_loaded = true
+ splash_instance.loader_progress = 100.0
+ splash_instance.min_load_time = 0.0 # Force time elapsed
+
+ splash_instance._evaluate_transition_router()
+
+ assert_true(
+ splash_instance.transitioning,
+ "Transition router must unlock and set transitioning flag upon full completion."
+ )
diff --git a/test/gut/test_splash_screen.gd.uid b/test/gut/test_splash_screen.gd.uid
new file mode 100644
index 000000000..4cbc4190c
--- /dev/null
+++ b/test/gut/test_splash_screen.gd.uid
@@ -0,0 +1 @@
+uid://cy3bqndjmni20
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 21c98aeb6..855ac47d4 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -15,7 +15,7 @@
# Shared timeout configurations across test suites
DEFAULT_TIMEOUT = int(os.getenv("DEFAULT_TIMEOUT", "30000"))
-TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "5000"))
+TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "7000"))
LOG_LEVEL_MAP: dict[str, int] = {
"DEBUG": 0,