1515using System . Threading ;
1616using System . Threading . Tasks ;
1717using UnityEditor ;
18- using UnityEngine ;
1918using UnityEngine . Audio ;
2019using UnityEngine . TestTools ;
2120using Object = UnityEngine . Object ;
@@ -53,11 +52,11 @@ public class SceneLoadingScreenControllerInputBlockShould
5352 // suppression is still active, instead of leaking into an unrelated later test.
5453 private const int FLUSH_FRAME_COUNT = 30 ;
5554
56- private World world ;
55+ private World ? world ;
5756 private SingleInstanceEntity inputMapEntity ;
58- private IInputBlock inputBlock ;
59- private SceneLoadingScreenView viewInstance ;
60- private AudioMixerVolumesController audioMixerVolumesController ;
57+ private IInputBlock ? inputBlock ;
58+ private SceneLoadingScreenView ? viewInstance ;
59+ private AudioMixerVolumesController ? audioMixerVolumesController ;
6160 private bool originalIgnoreFailingMessages ;
6261
6362 [ OneTimeSetUp ]
@@ -119,7 +118,7 @@ public void TearDown()
119118 if ( viewInstance != null )
120119 Object . DestroyImmediate ( viewInstance . gameObject ) ;
121120
122- world . Dispose ( ) ;
121+ world ! . Dispose ( ) ;
123122
124123 // Reset the static field so later tests in the same run aren't left with a stale in-memory
125124 // prefs instance (mirrors the reset half of the same established pattern).
@@ -134,12 +133,7 @@ public async Task ReleaseInputBlockWhenCloseIntentIsCancelledAsync()
134133 // suppression - the flag must be raised inside the test body itself.
135134 LogAssert . ignoreFailingMessages = true ;
136135
137- ISceneTipsProvider tipsProvider = Substitute . For < ISceneTipsProvider > ( ) ;
138-
139- tipsProvider . GetAsync ( Arg . Any < CancellationToken > ( ) )
140- . Returns ( UniTask . FromResult ( new SceneTips ( TimeSpan . Zero , false , new List < SceneTips . Tip > ( ) ) ) ) ;
141-
142- SceneLoadingScreenController controller = CreateController ( tipsProvider ) ;
136+ SceneLoadingScreenController controller = CreateController ( EmptyTipsProvider ( ) ) ;
143137
144138 using var cts = new CancellationTokenSource ( ) ;
145139 cts . Cancel ( ) ;
@@ -168,50 +162,30 @@ public async Task ReleaseInputBlockWhenCloseIntentIsCancelledAsync()
168162 }
169163
170164 [ Test ]
171- public async Task ReleaseInputBlockWhenCloseRacesTheInitialTipsLoadAsync ( )
165+ public async Task ReleaseInputBlockWhenClosedWhileSceneIsStillLoadingAsync ( )
172166 {
173167 // The framework resets LogAssert state at test start, wiping any fixture/SetUp-scoped
174168 // suppression - the flag must be raised inside the test body itself.
175169 LogAssert . ignoreFailingMessages = true ;
176170
177- ISceneTipsProvider tipsProvider = Substitute . For < ISceneTipsProvider > ( ) ;
178-
179- // The tips load never resolves during this test, so `tips` stays at its default value
180- // (Tips == null) for the whole run - exactly the close-races-the-initial-load scenario from
181- // review.md ("earliest-cancel variant... cold addressables make the tips window largest on
182- // first show") that made the first patch attempt's placement of the release - after
183- // tips.Release() - still leak, because tips.Release() throws on a default SceneTips before
184- // reaching it.
185- tipsProvider . GetAsync ( Arg . Any < CancellationToken > ( ) ) . Returns ( UniTask . Never < SceneTips > ( CancellationToken . None ) ) ;
186-
187- SceneLoadingScreenController controller = CreateController ( tipsProvider ) ;
171+ SceneLoadingScreenController controller = CreateController ( EmptyTipsProvider ( ) ) ;
188172
189- // Deliberately not awaited: LaunchViewLifeCycleAsync runs synchronously through
190- // OnBeforeViewShow/OnViewShow and suspends inside LoadTipsAsync (awaiting a promise that
191- // never completes), so by the time control returns here the block has already been
192- // acquired and WaitForCloseIntentAsync is still in flight - matching the real
193- // MVCManager.ShowOverlayAsync race where the teardown's finally can call HideViewAsync while
194- // the orphaned lifecycle task is still suspended.
195- UniTask launch = controller . LaunchViewLifeCycleAsync ( new CanvasOrdering ( CanvasOrdering . SortingLayer . Overlay , 0 ) , CompletedParams ( ) , CancellationToken . None ) ;
173+ // A load report that never completes keeps WaitForCloseIntentAsync suspended waiting on it,
174+ // so the fade-out (the unpatched code's only release) never runs. Deliberately not awaited:
175+ // this matches the real MVCManager.ShowOverlayAsync race where the teardown's finally calls
176+ // HideViewAsync while the orphaned lifecycle task is still suspended (teleport superseded
177+ // mid-load - leak path 2 from report.md).
178+ AsyncLoadProcessReport pendingReport = AsyncLoadProcessReport . Create ( CancellationToken . None ) ;
179+ UniTask launch = controller . LaunchViewLifeCycleAsync ( new CanvasOrdering ( CanvasOrdering . SortingLayer . Overlay , 0 ) , new SceneLoadingScreenController . Params ( pendingReport ) , CancellationToken . None ) ;
196180
197181 Assert . That ( ActiveKinds ( ) , Is . EqualTo ( ALL_KINDS & ~ BLOCKED_BY_LOADING_SCREEN ) ,
198182 "input should be blocked right after showing the loading screen" ) ;
199183
200- try
201- {
202- await ( ( IController ) controller ) . HideViewAsync ( CancellationToken . None ) ;
203- }
204- catch ( NullReferenceException )
205- {
206- // Pre-existing, separate defect (review.md finding 1): unpatched OnViewClose() calls
207- // tips.Release() on a still-default `tips` and throws. That defect is not what is under
208- // test here - what matters is whether the input block was released before that
209- // statement could run at all, which is asserted below regardless of this exception.
210- }
184+ await ( ( IController ) controller ) . HideViewAsync ( CancellationToken . None ) ;
211185
212186 Assert . That ( ActiveKinds ( ) , Is . EqualTo ( ALL_KINDS ) ,
213- "BLOCK_USER_INPUT must be released even when OnViewClose races the initial tips load - " +
214- "unpatched, this leaks +1 on the refcount forever (#9502)" ) ;
187+ "BLOCK_USER_INPUT must be released even when the loading screen closes while the scene " +
188+ "is still loading - unpatched, this leaks +1 on the refcount forever (#9502)" ) ;
215189
216190 launch . Forget ( ) ;
217191
@@ -220,8 +194,15 @@ public async Task ReleaseInputBlockWhenCloseRacesTheInitialTipsLoadAsync()
220194 await FlushDeferredViewLogsAsync ( ) ;
221195 }
222196
197+ private static ISceneTipsProvider EmptyTipsProvider ( )
198+ {
199+ ISceneTipsProvider tipsProvider = Substitute . For < ISceneTipsProvider > ( ) ;
200+ tipsProvider . Get ( ) . Returns ( new SceneTips ( TimeSpan . Zero , false , new List < SceneTips . Tip > ( ) ) ) ;
201+ return tipsProvider ;
202+ }
203+
223204 private SceneLoadingScreenController CreateController ( ISceneTipsProvider tipsProvider ) =>
224- new ( ( ) => viewInstance , tipsProvider , TimeSpan . Zero , audioMixerVolumesController , inputBlock ) ;
205+ new ( ( ) => viewInstance ! , tipsProvider , TimeSpan . Zero , audioMixerVolumesController ! , inputBlock ! ) ;
225206
226207 private static SceneLoadingScreenController . Params CompletedParams ( )
227208 {
@@ -231,7 +212,7 @@ private static SceneLoadingScreenController.Params CompletedParams()
231212 }
232213
233214 private InputMapComponent . Kind ActiveKinds ( ) =>
234- inputMapEntity . GetInputMapComponent ( world ) . Active ;
215+ inputMapEntity . GetInputMapComponent ( world ! ) . Active ;
235216
236217 private static InputMapComponent . Kind AllKinds ( )
237218 {
0 commit comments