Files
buzz/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift
klopez4212 7c165c3718 Smooth profile avatar editor transitions (#8640)
Smooths profile avatar editing with concurrent 200 ms entry/return
transitions, fixed-size circle movement between tabs, and a crossfade to
the dotted placeholder on cancel. New empty avatars open on Emoji, ready
to save.

Review fixes: keep the suggested emoji separate from the Image fallback,
and preserve native editor transparency across appearance changes.
Regression journeys cover empty Image previews, retained Emoji
selection, and light/dark updates.

Validation: focused Flutter journeys pass; restoring the old baseline
fails both fresh-editor regressions. At `bffb83f5e`: full Flutter suite
and push checks passed; all 107 native tests passed on the iOS
simulator. Prior iPhone visual approval covers the transition work;
these review fixes still need a device check.

Follow-up to #8628.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-29 08:16:17 +00:00

267 lines
14 KiB
Swift

import Flutter
import UIKit
import XCTest
@testable import Buzz
final class ProfileSetupRecoveryTests: XCTestCase {
@MainActor
func testSelectedEditorIconsContrastInBothAppearances() throws {
for dark in [false, true] {
for icon in ["emoji", "palette"] {
let control = NavigationGlassButtonPlatformView(
frame: CGRect(x: 0, y: 0, width: 64, height: 64), viewIdentifier: 911,
arguments: ["icon": icon, "controlSize": 64, "selected": true,
"brightness": dark ? "dark" : "light", "foregroundColor": dark ? 0xFFFFFFFF : 0xFF000000],
messenger: ProfileSetupTestMessenger())
let button = try XCTUnwrap(descendants(control.view()).compactMap { $0 as? UIButton }.first)
let image = try XCTUnwrap(button.configuration?.image)
XCTAssertEqual(image.renderingMode, .alwaysOriginal)
let rendered = UIGraphicsImageRenderer(size: image.size).image { _ in
image.draw(at: .zero)
}
let cgImage = try XCTUnwrap(rendered.cgImage)
let width = cgImage.width, height = cgImage.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
try pixels.withUnsafeMutableBytes { bytes in
let context = try XCTUnwrap(CGContext(data: bytes.baseAddress, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
}
let opaque = stride(from: 0, to: pixels.count, by: 4).filter { pixels[$0 + 3] > 250 }
XCTAssertFalse(opaque.isEmpty)
XCTAssertTrue(opaque.allSatisfy { pixels[$0] == (dark ? 0 : pixels[$0 + 3]) })
let attachment = XCTAttachment(image: rendered)
attachment.name = "Selected \(icon) dark=\(dark)"
attachment.lifetime = .keepAlways
add(attachment)
}
}
}
@MainActor
func testCameraShutterHasAnOpaqueCenter() throws {
let control = NavigationGlassButtonPlatformView(
frame: CGRect(x: 0, y: 0, width: 100, height: 100), viewIdentifier: 910,
arguments: ["icon": "shutter", "controlSize": 100, "accessibilityLabel": "Take photo"],
messenger: ProfileSetupTestMessenger())
let button = try XCTUnwrap(descendants(control.view()).compactMap { $0 as? UIButton }.first)
let image = try XCTUnwrap(button.configuration?.image?.cgImage)
var pixel = [UInt8](repeating: 0, count: 4)
let context = try XCTUnwrap(CGContext(data: &pixel, width: 1, height: 1, bitsPerComponent: 8,
bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
context.translateBy(x: -CGFloat(image.width / 2), y: -CGFloat(image.height / 2))
context.draw(image, in: CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
XCTAssertEqual(pixel[3], 255, "The shutter must be filled, not an outlined circle")
let attachment = XCTAttachment(image: UIImage(cgImage: image))
attachment.lifetime = .keepAlways
add(attachment)
}
@MainActor
func testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry() async throws {
let messenger = ProfileSetupTestMessenger()
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
var watchdogs: [() -> Void] = []
weak var engine: FlutterEngine?
let coordinator = NativeProfileSetupCoordinator(messenger: messenger,
scheduleEditorWatchdog: { watchdogs.append($0) },
startEditorEngine: {
engine = $0
// Start the normal app entry point: it never sends avatar-editor ready.
return $0.run()
}, configureEditorEngine: { _ in })
messenger.invoke("present", arguments: ["session": 81, "dark": true, "primaryArgb": 0xFFFFFFFF, "onPrimaryArgb": 0xFF000000])
let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController)
if let transition = nav.transitionCoordinator {
await withCheckedContinuation { continuation in
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
}
}
let profile = try XCTUnwrap(nav.viewControllers.first)
let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == "Add profile photo" })
XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen)
XCTAssertNil(profile.navigationItem.rightBarButtonItem)
let name = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UITextField }.first)
let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Continue" })
let skip = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Skip" })
let foreground = try XCTUnwrap(submit.configuration?.baseForegroundColor)
var red: CGFloat = 1, green: CGFloat = 1, blue: CGFloat = 1, alpha: CGFloat = 0
XCTAssertTrue(foreground.getRed(&red, green: &green, blue: &blue, alpha: &alpha))
XCTAssertEqual(red, 0); XCTAssertEqual(green, 0); XCTAssertEqual(blue, 0); XCTAssertEqual(alpha, 1)
XCTAssertTrue(skip.isHidden)
XCTAssertTrue(avatar.superview!.isHidden)
XCTAssertFalse(submit.isEnabled)
name.text = "Kenny"
name.sendActions(for: .editingChanged)
submit.sendActions(for: .touchUpInside)
XCTAssertNotNil(engine, "Prepare the editor before the avatar is tapped")
XCTAssertNil(profile.navigationItem.rightBarButtonItem)
XCTAssertEqual(submit.alpha, 1)
XCTAssertTrue(name.isHidden)
XCTAssertFalse(skip.isHidden)
XCTAssertFalse(submit.isEnabled, "Continue requires an avatar saved from the editor")
XCTAssertFalse(avatar.superview!.isHidden)
profile.view.layoutIfNeeded()
XCTAssertEqual(avatar.bounds.width, 220, accuracy: 0.5)
XCTAssertEqual(avatar.transform.a, 0.85, accuracy: 0.001)
XCTAssertTrue(descendants(profile.view).compactMap { $0 as? UILabel }
.contains { $0.text == "Choose your avatar" && $0.accessibilityTraits.contains(.header) })
XCTAssertTrue(avatar.point(inside: CGPoint(x: 4, y: 110), with: nil))
XCTAssertTrue(avatar.point(inside: CGPoint(x: 110, y: 110), with: nil))
let pan = UIPanGestureRecognizer()
let disabledPan = UIPanGestureRecognizer()
disabledPan.isEnabled = false
nav.view.addGestureRecognizer(pan)
nav.view.addGestureRecognizer(disabledPan)
nav.interactivePopGestureRecognizer?.isEnabled = true
for _ in 0..<2 {
autoreleasepool {
avatar.sendActions(for: .touchUpInside)
XCTAssertNotNil(engine)
XCTAssertEqual(profile.navigationItem.rightBarButtonItem?.title, "Done")
XCTAssertFalse(profile.navigationItem.rightBarButtonItem?.isEnabled ?? true)
XCTAssertTrue(profile.navigationItem.leftBarButtonItem?.isEnabled ?? false)
let editor = profile.children.compactMap { $0 as? FlutterViewController }.first
XCTAssertEqual(editor?.isViewOpaque, false, "Native chrome must remain visible through the exiting editor")
XCTAssertEqual(editor?.view.backgroundColor, UIColor.clear)
for dark in [false, true] {
messenger.invoke("appearance", arguments: ["session": 81, "dark": dark,
"pageBackgroundArgb": dark ? 0xFF000000 : 0xFFFFFFFF])
XCTAssertEqual(editor?.isViewOpaque, false)
XCTAssertEqual(editor?.view.backgroundColor, UIColor.clear)
XCTAssertEqual(nav.overrideUserInterfaceStyle, dark ? .dark : .light)
}
XCTAssertFalse(pan.isEnabled)
XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true)
XCTAssertTrue(nav.isModalInPresentation)
XCTAssertFalse(nav.isNavigationBarHidden)
if watchdogs.count > 1 {
// An old attempt's watchdog cannot tear down the successful retry.
watchdogs[0]()
XCTAssertTrue(profile.children.contains { $0 is FlutterViewController })
XCTAssertNotNil(engine)
}
watchdogs.last?()
XCTAssertTrue(nav.topViewController === profile)
XCTAssertFalse(nav.isNavigationBarHidden)
XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen)
XCTAssertTrue(pan.isEnabled)
XCTAssertFalse(disabledPan.isEnabled)
XCTAssertTrue(nav.interactivePopGestureRecognizer?.isEnabled ?? false)
XCTAssertFalse(nav.isModalInPresentation)
XCTAssertTrue(avatar.isEnabled)
XCTAssertFalse(avatar.isHidden)
}
// UIKit releases popped controllers at the end of its autorelease turn.
let released = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in engine == nil }, object: nil)
await fulfillment(of: [released], timeout: 2)
}
XCTAssertEqual(watchdogs.count, 2)
skip.sendActions(for: .touchUpInside)
XCTAssertEqual(messenger.savedArguments?["name"] as? String, "Kenny")
XCTAssertEqual(messenger.savedArguments?["skipAvatar"] as? Bool, true)
XCTAssertTrue(skip.isEnabled, "Failed name-only save must remain retryable through Skip")
XCTAssertFalse(submit.isEnabled)
let notNow = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Not now" })
XCTAssertFalse(notNow.isHidden)
XCTAssertTrue(notNow.isEnabled)
notNow.sendActions(for: .touchUpInside)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in parent.presentedViewController == nil }, object: nil)
await fulfillment(of: [dismissed], timeout: 2)
XCTAssertEqual(messenger.completionAction, "skip")
withExtendedLifetime(coordinator) {}
nav.dismiss(animated: false)
}
@MainActor
func testSelectedEmojiShowsEditablePreview() async throws {
let messenger = ProfileSetupTestMessenger()
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
let coordinator = NativeProfileSetupCoordinator(messenger: messenger, startEditorEngine: { _ in false }, configureEditorEngine: { _ in })
messenger.invoke("present", arguments: ["session": 82, "name": "Kenny", "emoji": "😊", "emojiColor": 0xFFFFCC00, "primaryArgb": 0xFF000000, "onPrimaryArgb": 0xFFFFFFFF])
let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController)
if let transition = nav.transitionCoordinator {
await withCheckedContinuation { continuation in
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
}
}
let profile = try XCTUnwrap(nav.viewControllers.first)
let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == "Edit profile photo" })
let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Continue" })
submit.sendActions(for: .touchUpInside)
XCTAssertTrue(submit.isEnabled)
for dark in [true, false] {
let background: UInt32 = dark ? 0xFF000000 : 0xFFFFFFFF
messenger.invoke("appearance", arguments: ["session": 82, "dark": dark, "pageBackgroundArgb": background,
"primaryArgb": dark ? 0xFFFFFFFF : 0xFF000000, "onPrimaryArgb": background])
XCTAssertTrue(parent.presentedViewController === nav)
XCTAssertEqual(nav.overrideUserInterfaceStyle, dark ? .dark : .light)
XCTAssertTrue(submit.isEnabled)
XCTAssertEqual(avatar.accessibilityLabel, "Edit profile photo")
let expected = dark ? UIColor.black : UIColor.white
var actualWhite: CGFloat = -1, alpha: CGFloat = 0
XCTAssertTrue(profile.view.backgroundColor!.getWhite(&actualWhite, alpha: &alpha))
var expectedWhite: CGFloat = -1
expected.getWhite(&expectedWhite, alpha: &alpha)
XCTAssertEqual(actualWhite, expectedWhite, accuracy: 0.001)
}
let image = try XCTUnwrap(avatar.image(for: .normal))
XCTAssertEqual(image.size, CGSize(width: 220, height: 220))
let attachment = XCTAttachment(image: image)
attachment.name = "Selected emoji with edit badge"
attachment.lifetime = .keepAlways
add(attachment)
withExtendedLifetime(coordinator) {}
nav.dismiss(animated: false)
}
@MainActor
private func descendants(_ view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
}
private final class ProfileSetupTestMessenger: NSObject, FlutterBinaryMessenger {
private var handler: FlutterBinaryMessageHandler?
var savedArguments: [String: Any]?
var completionAction: String?
func send(onChannel channel: String, message: Data?) {}
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
let codec = FlutterStandardMethodCodec.sharedInstance()
if let message {
let call = codec.decodeMethodCall(message)
if call.method == "save" { savedArguments = call.arguments as? [String: Any] }
}
callback?(codec.encodeSuccessEnvelope(["saved": false]))
}
func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection {
self.handler = handler
return 1
}
func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) { handler = nil }
func invoke(_ method: String, arguments: [String: Any]) {
let codec = FlutterStandardMethodCodec.sharedInstance()
handler?(codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))) { data in
if let data { self.completionAction = try? codec.decodeEnvelope(data) as? String }
}
}
}