@@ -177,17 +177,49 @@ extension RunnerTests {
177177
178178 private func captureNavigationFallbackVisualState ( app : XCUIApplication ) -> NavigationVisualSample {
179179#if os( iOS )
180- // A visual check is optional evidence, and the check is about THIS app's screen after the tap.
181- // It uses the app's own resolved screen, never the system-surface fallback: sampling SpringBoard's
182- // home screen twice would read as "unchanged" for an app that resolved no window, laundering a
183- // wrong-process sample into the same false "no back control" verdict a `nil` sample produces (#2728).
184- switch captureResolvedAppScreen ( app : app ) {
180+ return Self . navigationFallbackSample (
181+ resolvingApp : { self . captureResolvedAppScreen ( app : app ) } ,
182+ systemSurface : { self . captureResolvedAppScreen ( app : self . springboard ) } ,
183+ encoding : { runnerPngData ( for : $0 . image ) }
184+ )
185+ #else
186+ return NavigationVisualSample ( data : nil )
187+ #endif
188+ }
189+
190+ /// The decision the in-app `back` fallback makes about WHAT to sample, kept apart from the live app
191+ /// so the decision itself is testable. It samples only the app's own resolved screen: consulting the
192+ /// system surface for an app that resolved no window would capture SpringBoard's home screen, which
193+ /// reads as "unchanged" across a before/after pair and launders a wrong-process frame into a false
194+ /// "no back control" — the opposite of the unknown-outcome answer the fallback owes (#2728). The
195+ /// system surface is still threaded in, so sampling it is the tested contract: a caller that started
196+ /// to consult it fails the fallback's test. `navigationVisualSample` then names the refusal.
197+ static func navigationFallbackSample (
198+ resolvingApp : ( ) -> Result < CapturedAppScreen , RunnerAppScreenCaptureFailure > ,
199+ systemSurface : ( ) -> Result < CapturedAppScreen , RunnerAppScreenCaptureFailure > ,
200+ encoding : ( CapturedAppScreen ) -> Data ?
201+ ) -> NavigationVisualSample {
202+ _ = systemSurface
203+ return navigationVisualSample ( from : resolvingApp ( ) , encoding : encoding )
204+ }
205+
206+ /// Turns a capture answer into a navigation sample: the encoded frame when the display answered,
207+ /// and the typed reason it refused otherwise. A resolved display whose image would not encode is
208+ /// named as the capture failure it is, not as an unnamed no-sample that would default to "no
209+ /// display resolved" (#2728). Kept apart from the query so the mapping itself is testable.
210+ static func navigationVisualSample (
211+ from outcome : Result < CapturedAppScreen , RunnerAppScreenCaptureFailure > ,
212+ encoding : ( CapturedAppScreen ) -> Data ?
213+ ) -> NavigationVisualSample {
214+ switch outcome {
185215 case . success ( let captured ) :
186- guard let png = runnerPngData ( for : captured . image ) else {
187- // The display resolved but the image would not encode: name it as the capture failure it is,
188- // not as an unnamed no-sample that would default to "no display resolved" (#2728).
216+ guard let png = encoding ( captured ) else {
189217 let refusal = RunnerAppScreenCaptureFailure . unrenderableImage
190- return NavigationVisualSample ( data : nil , refusalCode : refusal . rawValue , refusalHint : refusal . hint )
218+ return NavigationVisualSample (
219+ data : nil ,
220+ refusalCode : refusal . rawValue ,
221+ refusalHint : refusal . hint
222+ )
191223 }
192224 return NavigationVisualSample ( data : png )
193225 case . failure ( let failure ) :
@@ -197,9 +229,6 @@ extension RunnerTests {
197229 refusalHint : failure . hint
198230 )
199231 }
200- #else
201- return NavigationVisualSample ( data : nil )
202- #endif
203232 }
204233
205234 private func verifyNavigationFallbackOutcome (
@@ -217,6 +246,16 @@ extension RunnerTests {
217246 after . data ? . count ?? - 1 ,
218247 observation . logToken
219248 )
249+ return Self . inAppBackOutcome ( observation : observation , before : before , after : after )
250+ }
251+
252+ /// What an observation of the fallback's before/after samples concludes. Kept apart from the sleep
253+ /// and the capture so the three-way decision is testable without a running app.
254+ static func inAppBackOutcome (
255+ observation : NavigationVisualObservation ,
256+ before : NavigationVisualSample ,
257+ after : NavigationVisualSample
258+ ) -> InAppBackOutcome {
220259 switch observation {
221260 case . changed :
222261 return . performed
@@ -363,5 +402,71 @@ extension RunnerTests {
363402 XCTAssertEqual ( unnamed . code , " APP_SCREEN_UNRESOLVED " )
364403 XCTAssertTrue ( unnamed . message . contains ( " unknown outcome " ) )
365404 }
405+
406+ func testVerifyNavigationFallbackOutcomeReportsUnresolvedWindowWithoutSystemSurface ( ) {
407+ // The in-app `back` fallback ran its tap but the app resolved no window. It must name that refusal
408+ // as an unknown outcome AND must not have sampled the system surface: capturing SpringBoard's home
409+ // screen twice reads as "unchanged" and launders a wrong-process frame into "no back control
410+ // exists" (#2728). This drives the SAME `navigationFallbackSample` production calls, handing it a
411+ // system surface that fails the test if consulted — so reverting the fallback to sample SpringBoard
412+ // (or dropping the refusal code) turns this red, which an inline `.never` re-creation could not.
413+ var askedSystemSurface = false
414+ let sample = Self . navigationFallbackSample (
415+ resolvingApp : { . failure ( . unresolvedWindow ) } ,
416+ systemSurface : {
417+ askedSystemSurface = true
418+ return . failure ( . unresolvedWindow )
419+ } ,
420+ encoding : { _ in Data ( [ 1 , 2 , 3 ] ) }
421+ )
422+ XCTAssertFalse ( askedSystemSurface , " the in-app fallback samples the app only, never SpringBoard " )
423+
424+ XCTAssertNil ( sample . data )
425+ XCTAssertEqual ( sample . refusalCode , " APP_SCREEN_WINDOW_UNRESOLVED " )
426+
427+ let observation = Self . navigationVisualObservation ( before : sample . data , after : sample . data )
428+ XCTAssertEqual ( observation , . unobserved )
429+
430+ switch Self . inAppBackOutcome ( observation : observation , before : sample , after : sample ) {
431+ case . unverified ( let payload ) :
432+ XCTAssertEqual ( payload . code , " APP_SCREEN_WINDOW_UNRESOLVED " )
433+ case . performed , . unavailable :
434+ XCTFail ( " a refused capture must report an unknown outcome, not 'no back control' " )
435+ }
436+ }
437+
438+ func testNavigationVisualSampleDistinguishesEncodedFrameFromRefusal ( ) {
439+ // The same capture entry point yields three different samples, and only the refusal ones may carry
440+ // a code: an encoded frame is evidence, a resolved-but-unencodable image and a refusal are not
441+ // (#2728). Reverting the mapping to a plain no-sample loses the reason a host keys on.
442+ let captured = CapturedAppScreen (
443+ image : RunnerImage ( ) ,
444+ displayID : 3 ,
445+ pixelWidth : 12 ,
446+ pixelHeight : 24 ,
447+ pixelsPerPoint : 3
448+ )
449+
450+ let encoded = Self . navigationVisualSample (
451+ from : . success ( captured ) ,
452+ encoding : { _ in Data ( [ 7 , 7 ] ) }
453+ )
454+ XCTAssertEqual ( encoded . data , Data ( [ 7 , 7 ] ) )
455+ XCTAssertNil ( encoded . refusalCode )
456+
457+ let unencodable = Self . navigationVisualSample (
458+ from : . success ( captured ) ,
459+ encoding : { _ in nil }
460+ )
461+ XCTAssertNil ( unencodable . data )
462+ XCTAssertEqual ( unencodable . refusalCode , " APP_SCREEN_CAPTURE_UNRENDERABLE " )
463+
464+ let refused = Self . navigationVisualSample (
465+ from : . failure ( . unresolvedScreen ) ,
466+ encoding : { _ in Data ( [ 7 , 7 ] ) }
467+ )
468+ XCTAssertNil ( refused . data )
469+ XCTAssertEqual ( refused . refusalCode , " APP_SCREEN_UNRESOLVED " )
470+ }
366471#endif
367472}
0 commit comments