// Concatenated with the production source by test-mobile-photo-zoom.sh so this // fixture can exercise the private controller without widening its app API. @main struct NativePhotoZoomTests { @MainActor static func main() { if #available(iOS 18.0, *) { let data = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) } let viewer = NativePhotoZoomViewController( image: UIImage(data: data)!, labels: (0..<100).map { "Photo \($0)" }, initialIndex: 0, canReply: false, canMore: false, canDelete: false ) var requests: [(String, Int, FlutterResult)] = [] viewer.request = { method, index, _, completion in if method == "select" { completion(nil) } else { requests.append((method, index, completion)) } } viewer.loadViewIfNeeded() viewer.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) viewer.view.layoutIfNeeded() precondition(!requests.isEmpty && requests.allSatisfy { $0.1 <= 2 }, "Initial requests must stay adjacent") let pager = viewer.children.compactMap { $0 as? UIPageViewController }.first! let location = CGPoint(x: 195, y: 400) precondition(viewer.shouldBeginDismissal(location: location, velocity: CGVector(dx: 0, dy: 300)), "Resting photos dismiss downward") precondition(!viewer.shouldBeginDismissal(location: location, velocity: CGVector(dx: 300, dy: 10)), "Horizontal swipes must page") let scroll = descendants(pager.viewControllers!.first!.view).compactMap { $0 as? UIScrollView }.first! scroll.zoomScale = 2 precondition(!viewer.shouldBeginDismissal(location: location, velocity: CGVector(dx: 0, dy: 300)), "Zoomed photos must pan instead of dismiss") scroll.zoomScale = 1 let indicator = descendants(viewer.view).compactMap { $0 as? UIPageControl }.first! let stale = requests requests.removeAll() indicator.currentPage = 50; sendValueChanged(indicator) precondition(!requests.isEmpty && requests.allSatisfy { (48...52).contains($0.1) }, "Scrubbing must move the request window") let page = pager.viewControllers!.first! page.loadViewIfNeeded() let photo = descendants(page.view).compactMap { $0 as? UIImageView }.first! for request in stale { request.2(FlutterStandardTypedData(bytes: data)) } precondition(photo.image == nil, "Late old-window responses cannot paint the selected photo") let current = requests requests.removeAll() let blueData = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) } let previous = photo.image viewer.updateFrame(blueData, at: 0) precondition(photo.image === previous, "Offscreen animation frames cannot paint the selected photo") viewer.updateFrame(blueData, at: 50) precondition(photo.image !== previous, "Continuing animation frames must update the selected photo") let updated = photo.image precondition(current.contains { $0.0 == "image" && $0.1 == 50 }, "Hold the selected initial-image reply until after a continuing frame") for request in current { request.2(FlutterStandardTypedData(bytes: data)) } precondition(photo.image === updated, "Delayed initial image and preview replies must not rewind a continuing frame") viewer.updateFrame(Data([0, 1]), at: 50) precondition(photo.image === updated, "Malformed frames must preserve the last displayed image") indicator.currentPage = 0; sendValueChanged(indicator) precondition(requests.contains { $0.1 == 0 }, "Leaving the window evicts old decoded images") let closing = requests requests.removeAll() var closed = false viewer.onClose = { _ in closed = true } viewer.viewDidDisappear(false) for request in closing { request.2(FlutterStandardTypedData(bytes: data)) } precondition(closed && requests.isEmpty, "Teardown must ignore late loads and stop requesting") let closedPage = pager.viewControllers!.first! closedPage.loadViewIfNeeded() let closedPhoto = descendants(closedPage.view).compactMap { $0 as? UIImageView }.first! let closedImage = closedPhoto.image viewer.updateFrame(data, at: 0) precondition(closedPhoto.image === closedImage, "Closed viewers must ignore continuing frames") print("PASS: native photo window, stale responses, eviction and teardown") railJourney(data: data) videoJourney(data: data) loaderJourney() } } @available(iOS 18.0, *) @MainActor static func railJourney(data: Data) { let viewer = NativePhotoZoomViewController(image: UIImage(data: data)!, labels: ["One", "Two"], initialIndex: 0, canReply: true, canMore: true, canDelete: false) viewer.loadViewIfNeeded() viewer.view.frame = CGRect(x: 0, y: 0, width: 951, height: 669) let pager = viewer.children.compactMap { $0 as? UIPageViewController }.first! let buttons = ["Close photo", "Reply in thread", "More image actions"].map { label in descendants(viewer.view).compactMap { $0 as? UIButton }.first { $0.accessibilityLabel == label }! } let indicator = descendants(viewer.view).compactMap { $0 as? UIPageControl }.first! let pageContainer = indicator.superview!.superview!.superview! let page = pager.viewControllers!.first! for left in [false, true] { let centerX: CGFloat = left ? 48 : 903 viewer.sourceRailGeometry = (viewer.view.bounds.size, centerX) viewer.layoutGallery(insets: UIEdgeInsets(top: 0, left: left ? 76 : 0, bottom: 0, right: left ? 0 : 76)) precondition(pager.view.frame == viewer.view.bounds, "Native gallery must span the full Duo window") pageContainer.layoutIfNeeded() let indicatorFrame = indicator.convert(indicator.bounds, to: viewer.view) precondition(abs(indicatorFrame.midX - pager.view.frame.midX) < 0.5, "Photo indicator must share the full-width photo center on either rail side") for (slot, button) in buttons.enumerated() { precondition(left ? button.center.x <= 76 : button.center.x >= 875, "Every action is centered inside the rail") precondition(button.center.x == (left ? 54 : 897), "Native actions must match the channel rail's inward-aligned centerline") precondition(button.bounds.width >= 44 && button.bounds.height >= 44, "Retain accessible hit targets") if slot > 0 { precondition(button.frame.minY > buttons[slot - 1].frame.maxY, "Close, Reply, More must not overlap") } } } precondition(buttons[1].frame.minY > 669 - 140 && buttons[2].frame.maxY == 669 - 12, "Reply and More must anchor to the bottom of the rail") viewer.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) viewer.layoutGallery(insets: UIEdgeInsets(top: 59, left: 0, bottom: 34, right: 0)) precondition(buttons[0].frame.maxY < buttons[1].frame.minY, "Ordinary phones retain top Close and bottom actions") precondition(pager.viewControllers!.first === page, "Folding must retain the selected native page") print("PASS: native gallery rail sides, full viewport, targets and compact transition") } @available(iOS 18.0, *) @MainActor static func videoJourney(data: Data) { let path = CommandLine.arguments[1] let viewer = NativePhotoZoomViewController(image: UIImage(data: data)!, labels: ["Clip", "Photo", "Second clip"], initialIndex: 0, canReply: true, canMore: true, canDelete: false, videoIndices: [0, 2]) let feedback = GalleryFeedbackSpy() viewer.selectionFeedback = feedback var requested: [Int] = [] var lateVideo: FlutterResult? viewer.request = { method, index, _, completion in if method == "video" { requested.append(index) if index == 0 { completion(path) } else { lateVideo = completion } } else { completion(method == "select" ? nil : FlutterStandardTypedData(bytes: data)) } } viewer.loadViewIfNeeded() viewer.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) viewer.view.layoutIfNeeded() viewer.viewDidAppear(false) let pager = viewer.children.compactMap { $0 as? UIPageViewController }.first! let indicator = descendants(viewer.view).compactMap { $0 as? UIPageControl }.first! precondition(viewer.shouldBeginDismissal(location: CGPoint(x: 195, y: 400), velocity: CGVector(dx: 0, dy: 300)), "Videos must dismiss downward too") precondition(!viewer.shouldBeginDismissal(location: CGPoint(x: 195, y: 400), velocity: CGVector(dx: 300, dy: 0)), "Video horizontal scrubbing must not dismiss") let first = pager.viewControllers!.first! first.loadViewIfNeeded() let posterView = descendants(first.view).compactMap { $0 as? UIImageView }.first! let oldPoster = posterView.image viewer.updateFrame(data, at: 0) precondition(posterView.image !== oldPoster, "A late poster updates the selected video") let selectedPoster = posterView.image viewer.updateFrame(data, at: 2) precondition(posterView.image === selectedPoster, "A stale poster cannot replace the selected video") let playerController = first.children.compactMap { $0 as? AVPlayerViewController }.first! let player = playerController.player! let item = player.currentItem! let firstSpinner = descendants(first.view).compactMap { $0 as? NativeGalleryLoadingIndicator }.first! let deadline = Date().addingTimeInterval(5) while (item.status == .unknown || !firstSpinner.isHidden) && Date() < deadline { RunLoop.current.run(until: Date().addingTimeInterval(0.01)) } precondition(item.status == .readyToPlay, "Real MP4 fixture must become playable") precondition(firstSpinner.isHidden, "Playable video removes the loader") precondition(requested == [0], "Only the selected video may download") precondition(feedback.clicks == 0, "Opening must not trigger selection haptics") viewer.pageViewController(pager, willTransitionTo: [first]) precondition(feedback.clicks == 1, "Swipe feedback fires before the page changes") viewer.pageViewController(pager, willTransitionTo: [first]) precondition(feedback.clicks == 1, "Repeated start callbacks do not duplicate swipe feedback") viewer.pageViewController(pager, didFinishAnimating: true, previousViewControllers: [first], transitionCompleted: false) precondition(feedback.clicks == 1, "Cancellation does not add a second haptic") indicator.currentPage = 2; sendValueChanged(indicator) precondition(player.currentItem == nil && playerController.player == nil, "Paging releases the old player before Dart deletes its file") precondition(requested == [0, 2], "Paging requests the selected video") precondition(feedback.clicks == 2, "Changing to a video triggers one selection click") let loadingPage = pager.viewControllers!.first! loadingPage.loadViewIfNeeded() let spinner = descendants(loadingPage.view).compactMap { $0 as? NativeGalleryLoadingIndicator }.first! precondition(!spinner.isHidden && spinner.accessibilityLabel == "Loading video", "Show the shared arc treatment while loading") precondition(!descendants(loadingPage.view).compactMap { $0 as? UILabel }.contains { $0.text == "Loading video…" }, "No visible loading copy") lateVideo?(nil) precondition(spinner.isHidden, "Failure stops the loader") let retry = descendants(loadingPage.view).compactMap { $0 as? UIButton }.first { $0.currentTitle == "Retry video" }! precondition(!retry.isHidden, "Failure retains retry") for target in retry.allTargets { for action in retry.actions(forTarget: target, forControlEvent: .touchUpInside) ?? [] { _ = (target as AnyObject).perform(NSSelectorFromString(action), with: retry) } } precondition(!spinner.isHidden && requested == [0, 2, 2], "Retry restarts the loader") indicator.currentPage = 0; sendValueChanged(indicator) lateVideo?(path) let current = pager.viewControllers!.first! current.loadViewIfNeeded() let active = current.children.compactMap { $0 as? AVPlayerViewController }.first! precondition(active.player != nil, "Returning to a video restores its player") precondition(spinner.isHidden, "Leaving a loading video stops its loader") let next = viewer.pageViewController(pager, viewControllerAfter: current)! viewer.pageViewController(pager, willTransitionTo: [next]) precondition(feedback.clicks == 4, "Next swipe fires before the new photo is selected") pager.setViewControllers([next], direction: .forward, animated: false) viewer.pageViewController(pager, didFinishAnimating: true, previousViewControllers: [current], transitionCompleted: true) precondition(feedback.clicks == 4, "Completed swipe does not add a delayed haptic") viewer.pageViewController(pager, didFinishAnimating: true, previousViewControllers: [current], transitionCompleted: true) precondition(feedback.clicks == 4, "Repeated callbacks for the same page do not duplicate haptics") viewer.viewDidDisappear(false) precondition(active.player == nil, "Closing releases the player before returning to Flutter") print("PASS: native video decoding, selected-only loading, paging and teardown") } @MainActor static func loaderJourney() { let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) let root = UIViewController(); window.rootViewController = root let loader = NativeGalleryLoadingIndicator(frame: CGRect(x: 28, y: 28, width: 44, height: 44)) root.view.addSubview(loader); window.isHidden = false loader.setLoading(true); loader.layoutIfNeeded() if !UIAccessibility.isReduceMotionEnabled { precondition(loader.layer.animation(forKey: "loading")?.duration == 0.5, "Match the shared spinner cadence") } loader.setLoading(false) precondition(loader.layer.animation(forKey: "loading") == nil && loader.isHidden) loader.setLoading(true); loader.removeFromSuperview() precondition(loader.layer.animation(forKey: "loading") == nil, "Detached loaders must stop animating") window.isHidden = true } @MainActor static func sendValueChanged(_ control: UIControl) { // A command-line UIKit fixture has no UIApplication action dispatcher. for target in control.allTargets { for action in control.actions(forTarget: target, forControlEvent: .valueChanged) ?? [] { _ = (target as AnyObject).perform(NSSelectorFromString(action), with: control) } } } @MainActor static func descendants(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap { descendants($0) } } } @MainActor private final class GalleryFeedbackSpy: UISelectionFeedbackGenerator { var clicks = 0 override func selectionChanged() { clicks += 1 } }