field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraAccessibilityAudit.swift (15748B)


      1 import XCTest
      2 
      3 @MainActor
      4 final class TeraAccessibilityAudit {
      5   private let test: XCTestCase
      6   private var findings: [String] = []
      7 
      8   init(test: XCTestCase) {
      9     self.test = test
     10   }
     11 
     12   func run(_ app: XCUIApplication, dynamicType: Bool = false, includeContrast: Bool = true) throws -> [String] {
     13     // Native scans can move focus and scroll position. Audit clipping before
     14     // those scans and retain every finding, including unidentified ones.
     15     var types: XCUIAccessibilityAuditType = [.elementDetection, .hitRegion,
     16                                              .sufficientElementDescription, .trait]
     17     if dynamicType {
     18       types.insert(.dynamicType)
     19     }
     20     let before = XCTAttachment(string: app.debugDescription)
     21     before.name = "Accessibility tree before audit"
     22     before.lifetime = .keepAlways
     23     test.add(before)
     24     let screenshot = XCTAttachment(screenshot: app.screenshot())
     25     screenshot.name = "Screen before audit"
     26     screenshot.lifetime = .keepAlways
     27     test.add(screenshot)
     28     try scan(app, types: .textClipped)
     29     if includeContrast {
     30       try auditContrast(app)
     31     }
     32     try scan(app, types: types)
     33     return findings
     34   }
     35 
     36   private func scan(_ app: XCUIApplication, types: XCUIAccessibilityAuditType) throws {
     37     try app.performAccessibilityAudit(for: types) { issue in
     38       let detail = XCTAttachment(string: "Audit type: \(issue.auditType.rawValue)\nAudit: \(issue.detailedDescription)\nElement: \(issue.element?.debugDescription ?? "unidentified")")
     39       detail.lifetime = .keepAlways
     40       self.test.add(detail)
     41       let tree = XCTAttachment(string: app.debugDescription)
     42       tree.name = "Audit finding accessibility tree"
     43       tree.lifetime = .keepAlways
     44       self.test.add(tree)
     45       let screen = XCTAttachment(screenshot: app.screenshot())
     46       screen.name = "Audit finding screen"
     47       screen.lifetime = .keepAlways
     48       self.test.add(screen)
     49       if let element = issue.element {
     50         let fresh = TeraAuditTarget(element, app: app).resolve(in: app)
     51         let geometry = XCTAttachment(string: "Current target: \(fresh.debugDescription)")
     52         geometry.lifetime = .keepAlways
     53         self.test.add(geometry)
     54       }
     55       return self.retainFinding(issue)
     56     }
     57   }
     58 }
     59 
     60 @MainActor
     61 enum TeraAccessibilityNavigation {
     62   static func viewport(_ app: XCUIApplication) -> (top: CGFloat, bottom: CGFloat) {
     63     let navigation = app.navigationBars.firstMatch
     64     let tabs = app.tabBars.firstMatch
     65     // A presented account/import sheet covers the underlying tab bar. Its
     66     // foreground content must use the sheet viewport, not the covered tabs.
     67     let top = navigation.exists ? navigation.frame.maxY : 64
     68     let bottom = tabs.exists && tabs.isHittable ? tabs.frame.minY : app.frame.maxY - 40
     69     return (top, bottom)
     70   }
     71 
     72   static func scroll(_ app: XCUIApplication, to element: XCUIElement, up: Bool = true,
     73                      requiresHit: Bool = true, test: XCTestCase)
     74   {
     75     var forward = up
     76     var previousViewport: [String] = []
     77     var stationaryDrags = 0
     78     for _ in 0 ..< 28 {
     79       let position = scrollPosition(app)
     80       let frame = element.exists ? String(describing: element.frame) : "not instantiated"
     81       XCTContext.runActivity(named: "Accessibility scroll: position \(position ?? "unknown"), target \(frame)") { _ in }
     82       if isVisible(element, app: app, requiresHit: requiresHit) {
     83         return
     84       }
     85       // Lazy forms revise their estimated content height while revealing rows.
     86       // A repeated percentage is not an edge. Reverse only after actual
     87       // visible content has stopped moving across two completed drags.
     88       let viewport = visibleContent(app)
     89       stationaryDrags = !viewport.isEmpty && viewport == previousViewport ? stationaryDrags + 1 : 0
     90       if stationaryDrags == 2 {
     91         forward.toggle()
     92         stationaryDrags = 0
     93       }
     94       previousViewport = viewport
     95       if element.exists, element.frame.width > 0, element.frame.height > 0 {
     96         let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
     97         let frame = element.frame
     98         let distance = bottom - top - 40
     99         let delta = max(-distance, min(distance, frame.midY - (top + bottom) / 2))
    100         if abs(delta) > 8 {
    101           drag(app, amount: -delta, top: top, bottom: bottom)
    102           continue
    103         }
    104       }
    105       // Audit scans can leave a lazy form on either side of the target.
    106       // Reverse only when scrolling has stopped making progress, so a long
    107       // maximum-text form can use the whole bound to reach the opposite end.
    108       let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
    109       let delta = (bottom - top - 40) * (forward ? -0.7 : 0.7)
    110       drag(app, amount: delta, top: top, bottom: bottom)
    111     }
    112     // The final drag can expose a lazy element. Check its settled frame before
    113     // declaring the bounded search unsuccessful.
    114     if isVisible(element, app: app, requiresHit: requiresHit) {
    115       return
    116     }
    117     let screenshot = XCTAttachment(screenshot: app.screenshot())
    118     screenshot.lifetime = .keepAlways
    119     test.add(screenshot)
    120     let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
    121     let exists = element.exists
    122     let frame = exists ? element.frame : .zero
    123     let detail = exists ? element.debugDescription : "Target is not instantiated"
    124     let geometry = String(format: "minY=%.17g maxY=%.17g top=%.17g bottom=%.17g", frame.minY, frame.maxY, top, bottom)
    125     XCTFail("Required accessibility element is unreachable: exists=\(exists) hittable=\(exists && element.isHittable) \(geometry). \(detail).\n\(app.debugDescription)")
    126   }
    127 
    128   private static func isVisible(_ element: XCUIElement, app: XCUIApplication, requiresHit: Bool) -> Bool {
    129     guard element.exists, !requiresHit || element.isHittable else { return false }
    130     let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
    131     let frame = element.frame
    132     return contains(frame, top: top, bottom: bottom)
    133   }
    134 
    135   static func contains(_ frame: CGRect, top: CGFloat, bottom: CGFloat) -> Bool {
    136     // UIKit's scaled geometry can differ by a few floating-point units at the
    137     // same boundary (observed 189 versus 189.00000000000011). Eight ulps are
    138     // far below a screen pixel; actual clipping still fails the native audits.
    139     frame.width > 0 && frame.height > 0
    140       && frame.minY >= top - top.ulp * 8
    141       && frame.maxY <= bottom + bottom.ulp * 8
    142   }
    143 
    144   private static func scrollPosition(_ app: XCUIApplication) -> String? {
    145     // Text editors have their own scrollbars. Progress belongs to the outer
    146     // form, whose visible scrollbar is farther right than those nested editors.
    147     let bars = app.otherElements.matching(NSPredicate(format: "label BEGINSWITH %@", "Vertical scroll bar"))
    148       .allElementsBoundByIndex.filter { $0.frame.height > 0 && $0.frame.intersects(app.frame) }
    149     let outer = bars.max { lhs, rhs in
    150       lhs.frame.maxX == rhs.frame.maxX ? lhs.frame.height < rhs.frame.height : lhs.frame.maxX < rhs.frame.maxX
    151     }
    152     return outer?.value as? String
    153   }
    154 
    155   private static func visibleContent(_ app: XCUIApplication) -> [String] {
    156     let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
    157     return app.staticTexts.allElementsBoundByIndex.compactMap { text in
    158       let frame = text.frame
    159       guard frame.height > 0, frame.maxY > top, frame.minY < bottom else { return nil }
    160       return "\(text.identifier):\(text.label):\(frame.minY.rounded()):\(frame.maxY.rounded())"
    161     }
    162   }
    163 
    164   private static func drag(_ app: XCUIApplication, amount: CGFloat, top: CGFloat, bottom: CGFloat) {
    165     // Application coordinates can resolve to a zero-width origin. The actual
    166     // window gives a stable point in the form margin, outside nested editors.
    167     let window = app.windows.firstMatch
    168     let origin = window.coordinate(withNormalizedOffset: .zero)
    169     // Start and end in the middle half of the scrollable area. The system
    170     // bars can intercept gestures near the nominal safe-area boundaries.
    171     let span = bottom - top
    172     let delta = max(-span / 2, min(span / 2, amount))
    173     let startY = (delta < 0 ? bottom - span / 4 : top + span / 4) - window.frame.minY
    174     let start = origin.withOffset(CGVector(dx: 20, dy: startY))
    175     // A second withOffset replaces the previous offset. Calculate both ends
    176     // from the same origin so the destination stays inside the viewport.
    177     let finish = origin.withOffset(CGVector(dx: 20, dy: startY + delta))
    178     // Hold at the destination to stop momentum before reading the next frame.
    179     start.press(forDuration: 0.05, thenDragTo: finish,
    180                 withVelocity: .slow, thenHoldForDuration: 0.3)
    181   }
    182 }
    183 
    184 /// Contrast sampling under translucent navigation/tab bars can report a
    185 /// partially occluded element. Every such finding must pass a new contrast
    186 /// audit with its entire frame exposed. Unidentified findings remain fatal.
    187 private extension TeraAccessibilityAudit {
    188   func auditContrast(_ app: XCUIApplication) throws {
    189     var pending: [String: TeraAuditTarget] = [:]
    190     var verified = Set<String>()
    191     var requested: TeraAuditTarget?
    192     for _ in 0 ..< 20 {
    193       let target = requested?.resolve(in: app)
    194       let targetKey = requested?.key
    195       if let target {
    196         // A menu's static label has no separate tap action. It still must be
    197         // fully exposed and pass the actual native contrast re-audit below.
    198         TeraAccessibilityNavigation.scroll(app, to: target, up: requested?.scrollUp ?? true,
    199                                            requiresHit: requested?.type != .staticText, test: test)
    200       }
    201       let findingsBefore = findings.count
    202       try app.performAccessibilityAudit(for: .contrast) { issue in
    203         let observation = XCTAttachment(string: "Contrast observation: \(issue.detailedDescription)\n\(issue.element?.debugDescription ?? "unidentified")")
    204         observation.lifetime = .keepAlways
    205         self.test.add(observation)
    206         guard let element = issue.element else { return self.retainFinding(issue) }
    207         // SC 1.4.3 exempts inactive controls. Keep this bound to the actual
    208         // disabled camera control; no active text or clipping finding is exempt.
    209         if element.label == "Camera", !element.isEnabled,
    210            app.buttons["Camera"].exists, !app.buttons["Camera"].isEnabled {
    211              return true
    212            }
    213         let key = self.contrastKey(element)
    214         guard key != targetKey, self.isPartlyOccluded(element, app: app) else { return self.retainFinding(issue) }
    215         if !verified.contains(key) {
    216           pending[key] = TeraAuditTarget(element, app: app)
    217         }
    218         let evidence = XCTAttachment(string: "Contrast requires unobscured recheck: \(element.debugDescription)")
    219         evidence.lifetime = .keepAlways
    220         self.test.add(evidence)
    221         return true
    222       }
    223       if let target, let targetKey {
    224         let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
    225         XCTAssertTrue((requested?.type == .staticText || target.isHittable)
    226                         && TeraAccessibilityNavigation.contains(target.frame, top: top, bottom: bottom),
    227                       "Contrast recheck moved outside the viewport")
    228         if findings.count == findingsBefore {
    229           verified.insert(targetKey)
    230         }
    231       }
    232       guard let next = pending.keys.sorted().first else { return }
    233       requested = pending.removeValue(forKey: next)
    234     }
    235     XCTFail("Not all partially obscured contrast findings passed an unobscured recheck")
    236   }
    237 
    238   func retainFinding(_ issue: XCUIAccessibilityAuditIssue) -> Bool {
    239     findings.append("\(issue.compactDescription): \(issue.element?.label ?? "unidentified")")
    240     // Return every finding to the calling test for a mandatory assertion.
    241     // Continuing this scan does not waive the finding.
    242     return true
    243   }
    244 
    245   func contrastKey(_ element: XCUIElement) -> String {
    246     "\(element.elementType.rawValue):\(element.identifier):\(element.label)"
    247   }
    248 
    249   func isPartlyOccluded(_ element: XCUIElement, app: XCUIApplication) -> Bool {
    250     let frame = element.frame
    251     let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
    252     guard frame.height > 0, frame.height < bottom - top - 16,
    253           frame.minX >= app.frame.minX, frame.maxX <= app.frame.maxX else { return false }
    254     return frame.minY < app.frame.maxY && frame.maxY > app.frame.minY
    255       && !TeraAccessibilityNavigation.contains(frame, top: top, bottom: bottom)
    256   }
    257 }
    258 
    259 private struct TeraAuditTarget {
    260   let type: XCUIElement.ElementType
    261   let identifier: String
    262   let label: String
    263   let scrollUp: Bool
    264 
    265   var key: String {
    266     "\(type.rawValue):\(identifier):\(label)"
    267   }
    268 
    269   @MainActor init(_ element: XCUIElement, app: XCUIApplication) {
    270     type = element.elementType
    271     identifier = element.identifier
    272     label = element.label
    273     scrollUp = element.frame.midY > app.frame.midY
    274   }
    275 
    276   @MainActor func resolve(in app: XCUIApplication) -> XCUIElement {
    277     let predicate = identifier.isEmpty ? NSPredicate(format: "label == %@", label)
    278       : NSPredicate(format: "identifier == %@ AND label == %@", identifier, label)
    279     // UIKit-backed SwiftUI section headings expose different legacy and modern
    280     // automation types. Match the stable identity and label across both trees.
    281     return app.descendants(matching: .any).matching(predicate).firstMatch
    282   }
    283 }
    284 
    285 @MainActor
    286 enum TeraAccessibilitySettings {
    287   static func setSystemReduceMotion(_ enabled: Bool, restoring test: XCTestCase? = nil) -> Bool {
    288     let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences")
    289     // This helper operates Settings, not the app under qualification. Keep
    290     // its navigation stable while Tera uses the real maximum system size.
    291     settings.launchArguments = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
    292     settings.launch()
    293     if settings.navigationBars["Settings"].exists {
    294       for _ in 0 ..< 3 {
    295         settings.swipeDown()
    296       }
    297     }
    298     for _ in 0 ..< 8 {
    299       if settings.switches["Reduce Motion"].exists {
    300         break
    301       }
    302       let motion = settings.buttons["Motion"].exists ? settings.buttons["Motion"] : settings.cells["Motion"]
    303       let accessibility = settings.buttons["Accessibility"].exists ? settings.buttons["Accessibility"] : settings.cells["Accessibility"]
    304       if motion.exists, motion.isHittable {
    305         motion.tap()
    306       } else if accessibility.exists, accessibility.isHittable {
    307         accessibility.tap()
    308       } else if settings.navigationBars.buttons.firstMatch.exists {
    309         settings.navigationBars.buttons.firstMatch.tap()
    310       } else {
    311         settings.swipeUp()
    312       }
    313     }
    314     let toggle = settings.switches["Reduce Motion"]
    315     XCTAssertTrue(toggle.waitForExistence(timeout: 5), settings.debugDescription)
    316     let original = toggle.value as? String == "1"
    317     // Register restoration before changing the setting, including on failure.
    318     if let test {
    319       let originalSetting = XCTAttachment(string: "Original system Reduce Motion: \(original)")
    320       originalSetting.lifetime = .keepAlways
    321       test.add(originalSetting)
    322       test.addTeardownBlock {
    323         await MainActor.run {
    324           _ = setSystemReduceMotion(original)
    325         }
    326       }
    327     }
    328     if original != enabled {
    329       // Settings exposes the entire row as the switch accessibility element;
    330       // its label does not toggle the visual control on this OS version.
    331       toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
    332     }
    333     let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", enabled ? "1" : "0"), object: toggle)
    334     XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 5), .completed, settings.debugDescription)
    335     settings.terminate()
    336     return original
    337   }
    338 }