mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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>
267 lines
14 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|