mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Wrap channel and DM titles in native glass and remove the temporary-channel clock. Apply a soft status-area fade across iPhone navigation, and fade the large title with scroll progress and fade in the centered title, with Reduce Motion support. Validation: mobile formatting/analysis and the full mobile suite passed. Native iOS 26/27 simulator checks covered actual text opacity, header resizing, quick reversal, and restored heading position; signed iPhone build, installation, and launch succeeded. `just ci` was blocked by disk exhaustion during Rust compilation. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
1991 lines
77 KiB
Swift
1991 lines
77 KiB
Swift
import AVFoundation
|
|
import Flutter
|
|
import UIKit
|
|
import UserNotifications
|
|
import XCTest
|
|
|
|
@testable import Buzz
|
|
|
|
class RunnerTests: XCTestCase {
|
|
|
|
@MainActor
|
|
func testNativeRemovalConfirmationUsesDestructiveAlertAndCancelsOnce() async throws {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
|
window.rootViewController = parent
|
|
parent.view.backgroundColor = .systemBackground
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let coordinator = NativeConfirmationDialogCoordinator(messenger: messenger, parentViewController: parent)
|
|
let arguments: [String: Any] = ["title": "Remove community?",
|
|
"message": "Are you sure you want to remove “Alpha”? You can pair with it again later.",
|
|
"confirmLabel": "Remove", "cancelLabel": "Cancel", "dark": false]
|
|
var replies: [Bool] = []
|
|
let completed = expectation(description: "Native confirmation dismissed")
|
|
messenger.invoke("present", arguments: arguments) {
|
|
if let value = $0 as? Bool { replies.append(value); completed.fulfill() }
|
|
}
|
|
let alert = try XCTUnwrap(parent.presentedViewController as? UIAlertController)
|
|
if let transition = alert.transitionCoordinator {
|
|
await withCheckedContinuation { continuation in
|
|
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
|
|
}
|
|
}
|
|
XCTAssertEqual(alert.preferredStyle, .alert)
|
|
XCTAssertEqual(alert.title, "Remove community?")
|
|
XCTAssertEqual(alert.message, arguments["message"] as? String)
|
|
XCTAssertEqual(alert.actions.map(\.title), ["Cancel", "Remove"])
|
|
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .destructive])
|
|
XCTAssertEqual(alert.overrideUserInterfaceStyle, .light)
|
|
XCTAssertTrue(replies.isEmpty)
|
|
var duplicate: Bool?
|
|
messenger.invoke("present", arguments: arguments) { duplicate = $0 as? Bool }
|
|
XCTAssertEqual(duplicate, false)
|
|
XCTAssertTrue(parent.presentedViewController === alert)
|
|
let capture = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
|
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
}
|
|
let attachment = XCTAttachment(image: capture)
|
|
attachment.name = "Native remove community alert"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
coordinator.cancel()
|
|
coordinator.cancel()
|
|
await fulfillment(of: [completed], timeout: 3)
|
|
XCTAssertNil(parent.presentedViewController)
|
|
XCTAssertEqual(replies, [false])
|
|
}
|
|
|
|
@MainActor
|
|
func testCommunityAvatarReportsItsVisibleFrameAndHidesWithoutMoving() async throws {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 393, height: 852))
|
|
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
|
parent.view.backgroundColor = .systemBackground
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
var leading: [String: Any] = ["id": "leading", "label": "Community settings",
|
|
"avatarInitial": "A", "enabled": true, "tracksAvatarBounds": true]
|
|
let bar = factory.create(withFrame: CGRect(x: 0, y: 0, width: 393, height: 160),
|
|
viewIdentifier: 999991, arguments: ["title": "Alpha", "largeTitle": true, "leading": leading,
|
|
"actions": [["id": "profile", "label": "Profile", "avatarInitial": "P", "enabled": true]]])
|
|
parent.view.addSubview(bar.view())
|
|
parent.view.layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
func avatar() throws -> UIButton {
|
|
try XCTUnwrap(navigation.topViewController?.navigationItem.leftBarButtonItems?.first?.customView as? UIButton)
|
|
}
|
|
// A rectangular custom view makes UIKit's surrounding glass a pill.
|
|
let buttonBounds = try avatar().bounds
|
|
XCTAssertEqual(buttonBounds.width, buttonBounds.height)
|
|
XCTAssertEqual(buttonBounds.width, 36)
|
|
let visibleFrame = try XCTUnwrap(messenger.avatarBounds)
|
|
XCTAssertEqual((visibleFrame["width"] as? NSNumber)?.doubleValue, 36)
|
|
XCTAssertEqual((visibleFrame["height"] as? NSNumber)?.doubleValue, 36)
|
|
let image = try XCTUnwrap(avatar().imageView)
|
|
let imageFrame = image.convert(image.bounds, to: bar.view())
|
|
XCTAssertEqual((visibleFrame["x"] as? NSNumber)?.doubleValue, imageFrame.minX)
|
|
XCTAssertEqual((visibleFrame["y"] as? NSNumber)?.doubleValue, imageFrame.minY)
|
|
try avatar().sendActions(for: .touchUpInside)
|
|
XCTAssertEqual(messenger.actions, ["leading"])
|
|
leading["avatarHidden"] = true
|
|
messenger.configure(["title": "Bravo", "largeTitle": true, "leading": leading])
|
|
var landingPrepared = false
|
|
messenger.invoke("prepareForReveal", arguments: [:]) { _ in landingPrepared = true }
|
|
XCTAssertTrue(landingPrepared)
|
|
XCTAssertFalse(bar.view().layer.needsLayout())
|
|
XCTAssertEqual(try avatar().alpha, 0)
|
|
XCTAssertEqual((messenger.avatarBounds?["x"] as? NSNumber)?.doubleValue, imageFrame.minX)
|
|
XCTAssertEqual((messenger.avatarBounds?["y"] as? NSNumber)?.doubleValue, imageFrame.minY)
|
|
leading["avatarHidden"] = false
|
|
messenger.configure(["title": "Bravo", "largeTitle": true, "leading": leading])
|
|
XCTAssertEqual(try avatar().alpha, 1)
|
|
}
|
|
|
|
@MainActor
|
|
func testConversationTitleHasWidthBeforePlatformViewLayout() throws {
|
|
let parent = UIViewController()
|
|
let factory = IosNavigationBarFactory(messenger: NavigationTestMessenger(), parent: parent)
|
|
let bar = factory.create(withFrame: .zero, viewIdentifier: 999991,
|
|
arguments: ["title": "Alice, Bob", "subtitle": "3 members", "titleEnabled": true])
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
XCTAssertGreaterThan(title.frame.width, 0)
|
|
XCTAssertGreaterThan(title.intrinsicContentSize.width, 0)
|
|
XCTAssertLessThanOrEqual(title.intrinsicContentSize.width, 240)
|
|
_ = bar.view()
|
|
}
|
|
|
|
@MainActor
|
|
func testLongGroupTitleIsCappedAndTruncates() {
|
|
let title = NavigationTitleView(title: String(repeating: "Long participant name, ", count: 12), subtitle: "12 members", color: .label)
|
|
title.maximumWidth = 180
|
|
XCTAssertEqual(title.intrinsicContentSize.width, 180)
|
|
title.frame = CGRect(origin: .zero, size: title.intrinsicContentSize)
|
|
title.layoutIfNeeded()
|
|
for label in title.contentView.subviews.compactMap({ $0 as? UILabel }) {
|
|
XCTAssertEqual(label.lineBreakMode, .byTruncatingTail)
|
|
XCTAssertTrue(title.bounds.contains(label.frame))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testPresenceDotSitsBesideCenteredSubtitle() throws {
|
|
let title = NavigationTitleView(title: "Alice", subtitle: "Offline", color: .label)
|
|
title.setSubtitlePresence(.gray)
|
|
title.frame = CGRect(x: 0, y: 0, width: 180, height: 44)
|
|
title.layoutIfNeeded()
|
|
let dot = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-status-dot" })
|
|
let label = try XCTUnwrap(title.contentView.subviews.compactMap { $0 as? UILabel }.first { $0.text == "Offline" })
|
|
XCTAssertEqual(label.frame.minX - dot.frame.maxX, 6, accuracy: 0.1)
|
|
XCTAssertEqual(label.frame.midY, dot.frame.midY, accuracy: 0.1)
|
|
XCTAssertEqual((dot.frame.minX + label.frame.maxX) / 2, title.bounds.midX, accuracy: 0.1)
|
|
}
|
|
|
|
@MainActor
|
|
func testTitleTapIgnoresUIKitControlWrapperButPreservesDisclosure() {
|
|
let wrapper = UIControl()
|
|
let title = NavigationTitleView(title: "general", subtitle: "36 members", color: .label)
|
|
wrapper.addSubview(title)
|
|
let label = UILabel()
|
|
title.addSubview(label)
|
|
XCTAssertTrue(title.acceptsTitleTouch(in: label))
|
|
XCTAssertTrue(title.acceptsTitleTouch(in: title))
|
|
let disclosure = UIButton(type: .custom)
|
|
title.addSubview(disclosure)
|
|
XCTAssertFalse(title.acceptsTitleTouch(in: disclosure))
|
|
var activated = false
|
|
title.onActivate = { activated = true }
|
|
XCTAssertTrue(title.accessibilityActivate())
|
|
XCTAssertTrue(activated)
|
|
}
|
|
|
|
@MainActor
|
|
func testCompactConversationLabelsFitAccessibilityXXXL() async throws {
|
|
guard #available(iOS 17.0, *) else { return }
|
|
for subtitle in ["36 members", "Online"] {
|
|
let parent = UIViewController()
|
|
parent.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
|
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 393, height: 852))
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let factory = IosNavigationBarFactory(messenger: NavigationTestMessenger(), parent: parent)
|
|
let bar = factory.create(
|
|
withFrame: CGRect(x: 0, y: 0, width: 393, height: 120), viewIdentifier: 989898,
|
|
arguments: ["title": "general", "subtitle": subtitle, "titleEnabled": true, "back": true])
|
|
parent.view.addSubview(bar.view())
|
|
parent.view.layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
// UIKit may cap inherited toolbar traits; also exercise an explicit AX
|
|
// override while preserving the real navigation bar's allocated bounds.
|
|
title.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
|
|
title.setNeedsLayout()
|
|
title.layoutIfNeeded()
|
|
XCTAssertEqual(title.traitCollection.preferredContentSizeCategory, .accessibilityExtraExtraExtraLarge)
|
|
XCTAssertTrue(title.accessibilityLabel?.contains(subtitle) == true)
|
|
for label in title.contentView.subviews.compactMap({ $0 as? UILabel }) where !label.isHidden {
|
|
XCTAssertTrue(title.bounds.contains(label.frame))
|
|
XCTAssertGreaterThanOrEqual(label.bounds.height, label.intrinsicContentSize.height)
|
|
let frame = label.convert(label.bounds, to: navigation.navigationBar)
|
|
XCTAssertGreaterThanOrEqual(frame.minY, 0)
|
|
XCTAssertLessThanOrEqual(frame.maxY, navigation.navigationBar.bounds.height)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMembersActivityAppearsAndClears() throws {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
let idle: [String: Any] = ["id": "0", "label": "View members", "symbol": "person.2", "enabled": true]
|
|
let bar = factory.create(withFrame: CGRect(x: 0, y: 0, width: 390, height: 120),
|
|
viewIdentifier: 999994, arguments: ["title": "Group", "actions": [idle]])
|
|
parent.view.addSubview(bar.view())
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
func item() throws -> UIBarButtonItem { try XCTUnwrap(navigation.topViewController?.navigationItem.rightBarButtonItems?.first) }
|
|
let idleImage = try XCTUnwrap(item().image?.pngData())
|
|
var working = idle
|
|
working["activityColor"] = 0xFF00FF00
|
|
working["activityLabel"] = "Agent working"
|
|
messenger.configure(["title": "Group", "actions": [working]])
|
|
XCTAssertEqual(try item().accessibilityValue, "Agent working")
|
|
XCTAssertNotEqual(try item().image?.pngData(), idleImage)
|
|
messenger.configure(["title": "Group", "actions": [idle]])
|
|
XCTAssertNil(try item().accessibilityValue)
|
|
XCTAssertEqual(try item().image?.pngData(), idleImage)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeConversationRetentionTextAndAccessibility() throws {
|
|
for dm in [false, true] {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
var args: [String: Any] = ["title": dm ? "Alice" : "general", "titleEnabled": true]
|
|
let bar = factory.create(withFrame: .zero, viewIdentifier: 999995, arguments: args)
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
for (subtitle, explanation) in [
|
|
("Temporary · 1h TTL · Online", "Ephemeral channel. Cleans up after 1 hour of inactivity."),
|
|
("Temporary · Cleanup due · Online", "Ephemeral channel. Cleanup is due now."),
|
|
] {
|
|
args["subtitle"] = subtitle
|
|
args["ephemeralLabel"] = explanation
|
|
messenger.configure(args)
|
|
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
title.frame.size = title.intrinsicContentSize
|
|
for direction in [UISemanticContentAttribute.forceLeftToRight, .forceRightToLeft] {
|
|
title.semanticContentAttribute = direction
|
|
title.layoutIfNeeded()
|
|
let status = try XCTUnwrap(title.contentView.subviews.compactMap { $0 as? UILabel }.first { $0.text == subtitle })
|
|
XCTAssertFalse(status.isHidden)
|
|
XCTAssertGreaterThan(status.bounds.width, 0)
|
|
XCTAssertTrue(title.bounds.contains(status.frame))
|
|
XCTAssertTrue(title.isAccessibilityElement)
|
|
XCTAssertTrue(title.accessibilityLabel?.contains(explanation) == true)
|
|
XCTAssertFalse(title.contentView.subviews.contains { $0 is UIButton }, "Retention stays text-only")
|
|
}
|
|
XCTAssertTrue(title.accessibilityActivate())
|
|
XCTAssertEqual(messenger.actions.last, "title")
|
|
}
|
|
args["subtitle"] = "Online"
|
|
args.removeValue(forKey: "ephemeralLabel")
|
|
messenger.configure(args)
|
|
let permanent = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
XCTAssertFalse(permanent.accessibilityLabel?.contains("Ephemeral") == true)
|
|
XCTAssertFalse(permanent.contentView.subviews.compactMap { $0 as? UILabel }.contains { $0.text?.contains("Temporary") == true })
|
|
_ = bar // Keep the platform view alive throughout reconfiguration.
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeDmTitlePreservesAvatarPresenceAndAccessibility() throws {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
let bar = factory.create(
|
|
withFrame: CGRect(x: 0, y: 0, width: 390, height: 120), viewIdentifier: 999996,
|
|
arguments: ["title": "Alice", "subtitle": "Online", "titleEnabled": false,
|
|
"titleAvatar": ["avatarInitial": "A", "avatarBackground": 0xFFE0E0FF,
|
|
"avatarForeground": 0xFF000000],
|
|
"titlePresenceColor": 0xFF00FF00]
|
|
)
|
|
parent.view.addSubview(bar.view())
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
title.frame.size = title.intrinsicContentSize
|
|
title.layoutIfNeeded()
|
|
XCTAssertEqual(title.accessibilityLabel, "Alice, Online")
|
|
XCTAssertEqual(title.accessibilityTraits, .header)
|
|
XCTAssertFalse(title.accessibilityActivate())
|
|
let avatar = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-avatar" } as? UIImageView)
|
|
XCTAssertNotNil(avatar.image)
|
|
XCTAssertEqual(avatar.frame.size, CGSize(width: 32, height: 32))
|
|
let presence = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-presence" })
|
|
XCTAssertEqual(presence.backgroundColor, UIColor.green)
|
|
let labels = title.contentView.subviews.compactMap { $0 as? UILabel }
|
|
XCTAssertEqual(labels.map(\.text), ["Alice", "Online"])
|
|
for label in labels {
|
|
XCTAssertTrue(title.bounds.contains(label.frame))
|
|
XCTAssertFalse(label.frame.intersects(avatar.frame))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testChannelTitleHasUnclippedLabelsAndOpensSettings() async throws {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
let bar = factory.create(
|
|
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120),
|
|
viewIdentifier: 999998,
|
|
arguments: ["title": "general", "subtitle": "36 members", "titleEnabled": true,
|
|
"back": true, "actions": [["id": "0", "label": "Start Huddle", "symbol": "headphones", "enabled": true]]]
|
|
)
|
|
parent.view.addSubview(bar.view())
|
|
parent.view.layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
title.layoutIfNeeded()
|
|
// The title owns one native glass capsule and remains a single action.
|
|
XCTAssertEqual(title.accessibilityTraits, .button)
|
|
let labels = title.contentView.subviews.compactMap { $0 as? UILabel }
|
|
XCTAssertEqual(labels.map(\.text), ["general", "36 members"])
|
|
for label in labels {
|
|
XCTAssertTrue(title.bounds.contains(label.frame))
|
|
XCTAssertGreaterThanOrEqual(label.bounds.width, label.intrinsicContentSize.width)
|
|
XCTAssertGreaterThanOrEqual(label.bounds.height, label.intrinsicContentSize.height)
|
|
}
|
|
XCTAssertTrue(title.accessibilityActivate())
|
|
XCTAssertEqual(messenger.actions.last, "title")
|
|
}
|
|
|
|
@MainActor
|
|
func testConversationGlassAndBackdropAreReadyBeforeScrolling() throws {
|
|
for subtitle in ["36 members", "Online", ""] {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
var args: [String: Any] = ["title": "Conversation", "subtitle": subtitle,
|
|
"titleEnabled": true, "alwaysFrosted": true]
|
|
let bar = factory.create(withFrame: .zero, viewIdentifier: 999994, arguments: args)
|
|
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
|
XCTAssertEqual(material.alpha, 1, "The initial configuration must not wait for a scroll")
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
if #available(iOS 26.0, *) {
|
|
XCTAssertTrue(title.effect is UIGlassEffect)
|
|
} else {
|
|
XCTAssertTrue(title.effect is UIBlurEffect)
|
|
}
|
|
title.frame.size = title.intrinsicContentSize
|
|
title.layoutIfNeeded()
|
|
XCTAssertEqual(title.layer.cornerRadius, title.bounds.height / 2)
|
|
XCTAssertEqual(title.contentView.subviews.compactMap { $0 as? UILabel }.count, 2)
|
|
XCTAssertTrue(title.accessibilityActivate())
|
|
XCTAssertEqual(messenger.actions.last, "title")
|
|
for offset in [0.0, 6.0, 52.0, 0.0, -20.0] {
|
|
messenger.scroll(to: offset)
|
|
XCTAssertEqual(material.alpha, 1)
|
|
args["dark"] = true
|
|
args["subtitle"] = "Updated status"
|
|
messenger.configure(args)
|
|
XCTAssertEqual(material.alpha, 1, "Live status/theme changes must preserve the backdrop")
|
|
}
|
|
messenger.configure(["title": "Home", "largeTitle": true])
|
|
XCTAssertEqual(material.alpha, 0, "Large-title pages keep their existing scroll treatment")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testConversationUsesNativeSoftEdgeWithoutExpandingHitArea() throws {
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let messenger = NavigationTestMessenger()
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
let bar = factory.create(
|
|
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120),
|
|
viewIdentifier: 999993,
|
|
arguments: ["title": "general", "subtitle": "36 members", "alwaysFrosted": true])
|
|
parent.view.addSubview(bar.view())
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
|
for offset in [0.0, 52.0, 0.0] {
|
|
messenger.scroll(to: offset)
|
|
bar.view().setNeedsLayout()
|
|
bar.view().layoutIfNeeded()
|
|
if #available(iOS 27.0, *) {
|
|
let scroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView)
|
|
XCTAssertEqual(scroll.topEdgeEffect.style, .soft)
|
|
XCTAssertFalse(scroll.topEdgeEffect.isHidden)
|
|
XCTAssertTrue(scroll.bottomEdgeEffect.isHidden)
|
|
XCTAssertTrue(material.isHidden, "Do not stack a uniform blur over the native fade")
|
|
XCTAssertFalse(bar.view().clipsToBounds)
|
|
XCTAssertEqual(scroll.contentOffset.y, offset, accuracy: 0.5)
|
|
XCTAssertFalse(bar.view().point(inside: CGPoint(x: 20, y: 121), with: nil))
|
|
} else if #available(iOS 26.0, *) {
|
|
let safeTop = navigation.view.safeAreaInsets.top
|
|
XCTAssertEqual(material.frame.height, safeTop > 0 ? safeTop + 6 : 0, accuracy: 0.5)
|
|
XCTAssertFalse(material.isHidden)
|
|
} else {
|
|
XCTAssertEqual(material.frame.height, bar.view().bounds.height, accuracy: 0.5)
|
|
XCTAssertFalse(material.isHidden)
|
|
}
|
|
XCTAssertEqual(material.alpha, 1)
|
|
XCTAssertNotNil(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testSubtitlelessThreadKeepsFullFallbackBehindControls() throws {
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let messenger = NavigationTestMessenger()
|
|
let bar = IosNavigationBarFactory(messenger: messenger, parent: parent).create(
|
|
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120),
|
|
viewIdentifier: 999991, arguments: ["title": "Thread", "back": true, "alwaysFrosted": true])
|
|
parent.view.addSubview(bar.view())
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
|
for offset in [0.0, 52.0, 0.0] {
|
|
messenger.scroll(to: offset)
|
|
bar.view().setNeedsLayout()
|
|
bar.view().layoutIfNeeded()
|
|
XCTAssertNil(navigation.topViewController?.navigationItem.titleView)
|
|
XCTAssertEqual(material.frame.height, bar.view().bounds.height, accuracy: 0.5)
|
|
XCTAssertEqual(material.alpha, 1)
|
|
if #available(iOS 27.0, *) {
|
|
XCTAssertTrue(material.isHidden)
|
|
} else {
|
|
XCTAssertFalse(material.isHidden, "Plain thread titles and legacy buttons need a control-area backdrop")
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testCompactNavigationUsesSoftEdgeWithLegacyMaterialFallback() async throws {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
let bar = factory.create(
|
|
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120),
|
|
viewIdentifier: 999997,
|
|
arguments: ["title": "general", "subtitle": "36 members", "titleEnabled": true]
|
|
)
|
|
parent.view.addSubview(bar.view())
|
|
parent.view.layoutIfNeeded()
|
|
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
|
XCTAssertEqual(material.alpha, 0, "Compact titles must not frost an unscrolled page")
|
|
// Scrolling, returning to rest, and pull-to-refresh all share the same
|
|
// treatment as large titles; reconfiguration must preserve that depth.
|
|
for offset in [0.0, 6.0, 52.0, 12.0, 0.0, -20.0, 0.0] {
|
|
messenger.scroll(to: offset)
|
|
bar.view().setNeedsLayout()
|
|
bar.view().layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
XCTAssertNotNil(material.effect as? UIBlurEffect)
|
|
let fade = try XCTUnwrap(material.layer.mask as? CAGradientLayer)
|
|
let locations = try XCTUnwrap(fade.locations)
|
|
XCTAssertEqual(locations.first?.doubleValue, 0)
|
|
XCTAssertEqual(locations.last?.doubleValue, 1)
|
|
XCTAssertLessThan(locations[1].doubleValue, 1, "Compact material needs a soft lower edge")
|
|
XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.001)
|
|
messenger.configure(["title": "general", "subtitle": "37 members"])
|
|
XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.001,
|
|
"Updating a compact title must not restore blur at rest")
|
|
XCTAssertEqual(material.frame, bar.view().bounds)
|
|
if #available(iOS 27.0, *) {
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let mirroredScroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView)
|
|
XCTAssertTrue(mirroredScroll.topEdgeEffect.isHidden)
|
|
let edge = try XCTUnwrap(bar.view().subviews.first {
|
|
$0.accessibilityIdentifier == "navigation-status-edge"
|
|
} as? UIScrollView)
|
|
XCTAssertFalse(edge.isHidden)
|
|
XCTAssertFalse(edge.topEdgeEffect.isHidden)
|
|
XCTAssertEqual(edge.topEdgeEffect.style, .soft)
|
|
XCTAssertEqual(edge.contentOffset, .zero)
|
|
XCTAssertFalse(edge.isUserInteractionEnabled)
|
|
XCTAssertTrue(material.isHidden, "Only the native fade should be visible")
|
|
XCTAssertTrue(mirroredScroll.bottomEdgeEffect.isHidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testLargeTitleTextFadesWithScrollAndCompactTitleCanReverse() async throws {
|
|
guard !UIAccessibility.isReduceMotionEnabled else {
|
|
throw XCTSkip("The system has disabled transition animation")
|
|
}
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let messenger = NavigationTestMessenger()
|
|
let bar = IosNavigationBarFactory(messenger: messenger, parent: parent).create(
|
|
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 180),
|
|
viewIdentifier: 999992, arguments: ["title": "Home", "largeTitle": true])
|
|
parent.view.addSubview(bar.view())
|
|
parent.view.layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let metrics = try XCTUnwrap(messenger.metrics)
|
|
let expanded = try XCTUnwrap(metrics["expandedHeight"] as? Double)
|
|
let compact = try XCTUnwrap(metrics["compactHeight"] as? Double)
|
|
let boundary = expanded - compact
|
|
|
|
func labels(in view: UIView) -> [UILabel] {
|
|
let own = (view as? UILabel).map { [$0] } ?? []
|
|
return own + view.subviews.flatMap { labels(in: $0) }
|
|
}
|
|
func visibleOpacity(of view: UIView) -> Float {
|
|
guard !view.isHidden else { return 0 }
|
|
let opacity = view.layer.presentation()?.opacity ?? Float(view.alpha)
|
|
let maskOpacity = view.layer.mask.map { $0.presentation()?.opacity ?? $0.opacity } ?? 1
|
|
return opacity * maskOpacity * (view.superview.map { visibleOpacity(of: $0) } ?? 1)
|
|
}
|
|
let title = try XCTUnwrap(labels(in: navigation.view)
|
|
.filter { $0.text == "Home" }.max { $0.font.pointSize < $1.font.pointSize })
|
|
let centeredTitle = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView?.subviews.compactMap { $0 as? UILabel }.first)
|
|
let initialTitleY = title.convert(title.bounds, to: parent.view).minY
|
|
XCTAssertEqual(title.textColor.cgColor.alpha, 1, accuracy: 0.01)
|
|
messenger.scroll(to: boundary * 0.7)
|
|
XCTAssertGreaterThan(title.textColor.cgColor.alpha, 0.1)
|
|
XCTAssertLessThan(title.textColor.cgColor.alpha, 0.6, "The actual large text must fade before it collapses")
|
|
// Flutter also resizes the platform view every scroll frame. Exercise that
|
|
// layout, which could invalidate a snapshot-only header transition.
|
|
bar.view().frame.size.height -= boundary * 0.7
|
|
bar.view().setNeedsLayout()
|
|
bar.view().layoutIfNeeded()
|
|
XCTAssertLessThan(title.textColor.cgColor.alpha, 0.6)
|
|
messenger.scroll(to: boundary)
|
|
try await Task.sleep(nanoseconds: 60_000_000)
|
|
XCTAssertFalse(try XCTUnwrap(centeredTitle.layer.mask).bounds.isEmpty)
|
|
XCTAssertGreaterThan(visibleOpacity(of: centeredTitle), 0.01)
|
|
XCTAssertLessThan(visibleOpacity(of: centeredTitle), 0.99)
|
|
XCTAssertEqual(title.textColor.cgColor.alpha, 0, accuracy: 0.01)
|
|
messenger.scroll(to: boundary * 0.7)
|
|
try await Task.sleep(nanoseconds: 240_000_000)
|
|
XCTAssertEqual(visibleOpacity(of: centeredTitle), 0, accuracy: 0.01)
|
|
XCTAssertGreaterThan(title.textColor.cgColor.alpha, 0.1)
|
|
messenger.scroll(to: boundary)
|
|
try await Task.sleep(nanoseconds: 240_000_000)
|
|
XCTAssertEqual(visibleOpacity(of: centeredTitle), 1, accuracy: 0.01)
|
|
XCTAssertEqual(navigation.navigationBar.frame.height, compact, accuracy: 0.5)
|
|
messenger.scroll(to: 0)
|
|
bar.view().frame.size.height += boundary * 0.7
|
|
bar.view().setNeedsLayout()
|
|
bar.view().layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 240_000_000)
|
|
XCTAssertEqual(title.textColor.cgColor.alpha, 1, accuracy: 0.01)
|
|
XCTAssertEqual(visibleOpacity(of: centeredTitle), 0, accuracy: 0.01)
|
|
XCTAssertEqual(navigation.navigationBar.frame.height, expanded, accuracy: 0.5)
|
|
XCTAssertEqual(title.convert(title.bounds, to: parent.view).minY, initialTitleY, accuracy: 0.5)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeNavigationTitleExpandsAgainAtTop() async throws {
|
|
let messenger = NavigationTestMessenger()
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
|
let bar = factory.create(
|
|
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 180),
|
|
viewIdentifier: 999999,
|
|
arguments: ["title": "Home", "largeTitle": true]
|
|
)
|
|
parent.view.addSubview(bar.view())
|
|
parent.view.layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
|
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
|
XCTAssertNotNil(material.effect)
|
|
XCTAssertEqual(material.alpha, 0)
|
|
if #available(iOS 27.0, *) {
|
|
let scroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView)
|
|
XCTAssertTrue(scroll.topEdgeEffect.isHidden)
|
|
let edge = try XCTUnwrap(bar.view().subviews.first {
|
|
$0.accessibilityIdentifier == "navigation-status-edge"
|
|
} as? UIScrollView)
|
|
XCTAssertFalse(edge.isHidden)
|
|
XCTAssertFalse(edge.topEdgeEffect.isHidden)
|
|
XCTAssertEqual(edge.topEdgeEffect.style, .soft)
|
|
XCTAssertEqual(edge.contentOffset, .zero)
|
|
XCTAssertFalse(edge.isUserInteractionEnabled)
|
|
XCTAssertTrue(material.isHidden)
|
|
XCTAssertFalse(bar.view().clipsToBounds)
|
|
}
|
|
let expandedHeight = navigation.navigationBar.frame.height
|
|
let metrics = try XCTUnwrap(messenger.metrics)
|
|
let compactHeight = try XCTUnwrap(metrics["compactHeight"] as? Double)
|
|
let measuredExpandedHeight = try XCTUnwrap(metrics["expandedHeight"] as? Double)
|
|
XCTAssertEqual(expandedHeight, measuredExpandedHeight, accuracy: 0.5)
|
|
XCTAssertGreaterThan(expandedHeight, compactHeight)
|
|
for _ in 0..<3 {
|
|
messenger.scroll(to: measuredExpandedHeight - compactHeight)
|
|
bar.view().setNeedsLayout()
|
|
bar.view().layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
XCTAssertLessThan(navigation.navigationBar.frame.height, expandedHeight)
|
|
XCTAssertEqual(material.alpha, 1)
|
|
XCTAssertEqual(material.frame, bar.view().bounds)
|
|
messenger.scroll(to: 0)
|
|
bar.view().setNeedsLayout()
|
|
bar.view().layoutIfNeeded()
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
XCTAssertEqual(navigation.navigationBar.frame.height, expandedHeight, accuracy: 0.5)
|
|
XCTAssertEqual(material.alpha, 0)
|
|
if #available(iOS 27.0, *) {
|
|
let edge = try XCTUnwrap(bar.view().subviews.first {
|
|
$0.accessibilityIdentifier == "navigation-status-edge"
|
|
} as? UIScrollView)
|
|
XCTAssertFalse(edge.isHidden, "Returning to rest must not remove the status fade")
|
|
XCTAssertEqual(edge.contentOffset, .zero)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func testVoiceNotePackagingStagesHaveBoundedDeadlines() {
|
|
XCTAssertEqual(VoiceNotePackager.videoEnvelopeTimeout, 30)
|
|
XCTAssertEqual(VoiceNotePackager.exportTimeout, 30)
|
|
}
|
|
|
|
func testTimedOutVoiceNoteExportCleansLateOutputWithoutRedelivering() throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let outputURL = directory.appendingPathComponent("output.mp4")
|
|
let videoURL = directory.appendingPathComponent("envelope.mp4")
|
|
try Data([1]).write(to: outputURL)
|
|
try Data([2]).write(to: videoURL)
|
|
let completion = VoiceNoteExportCompletion(
|
|
outputURL: outputURL,
|
|
videoURL: videoURL
|
|
)
|
|
var cancelCount = 0
|
|
var deliveryCount = 0
|
|
|
|
completion.timeout(
|
|
cancel: { cancelCount += 1 },
|
|
deliver: { deliveryCount += 1 }
|
|
)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: outputURL.path))
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: videoURL.path))
|
|
|
|
// AVFoundation may recreate the destination while cancellation settles.
|
|
try Data([3]).write(to: outputURL)
|
|
completion.exportDidFinish(
|
|
succeeded: false,
|
|
deliver: { deliveryCount += 1 }
|
|
)
|
|
|
|
XCTAssertEqual(cancelCount, 1)
|
|
XCTAssertEqual(deliveryCount, 1)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: outputURL.path))
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: videoURL.path))
|
|
}
|
|
|
|
func testSuccessfulVoiceNoteExportPreservesOutputForFlutter() throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let outputURL = directory.appendingPathComponent("output.mp4")
|
|
let videoURL = directory.appendingPathComponent("envelope.mp4")
|
|
try Data([1]).write(to: outputURL)
|
|
try Data([2]).write(to: videoURL)
|
|
let completion = VoiceNoteExportCompletion(
|
|
outputURL: outputURL,
|
|
videoURL: videoURL
|
|
)
|
|
var deliveryCount = 0
|
|
|
|
completion.exportDidFinish(
|
|
succeeded: true,
|
|
deliver: { deliveryCount += 1 }
|
|
)
|
|
|
|
XCTAssertEqual(deliveryCount, 1)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path))
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: videoURL.path))
|
|
}
|
|
|
|
func testPushAuthorizationStatusNamesCoverDisplayPermissionStates() {
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.notDetermined), "notDetermined")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.denied), "denied")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.authorized), "authorized")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.provisional), "provisional")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.ephemeral), "ephemeral")
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorBoundsAndReactivates() {
|
|
var selector = HuddleActiveTalkerSelector(capacity: 15)
|
|
|
|
for peer in 0..<15 {
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: peer, levelDbov: -20),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
}
|
|
let rejected = selector.activate(peerIndex: 15, levelDbov: -20)
|
|
XCTAssertEqual(
|
|
rejected,
|
|
HuddleTalkerSelection(accepted: false, evictedPeerIndex: nil)
|
|
)
|
|
var allocationCount = 0
|
|
XCTAssertNil(rejected.allocateIfAccepted {
|
|
allocationCount += 1
|
|
return NSObject()
|
|
})
|
|
XCTAssertEqual(allocationCount, 0)
|
|
XCTAssertEqual(Set(selector.active.keys), Set(0..<15))
|
|
|
|
selector.remove(7)
|
|
XCTAssertFalse(selector.active.keys.contains(7))
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 7, levelDbov: -10),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertTrue(selector.active.keys.contains(7))
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorUsesRecentActivity() {
|
|
var selector = HuddleActiveTalkerSelector(capacity: 2)
|
|
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 4, levelDbov: -10),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 2, levelDbov: -20),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 4, levelDbov: -10),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 9, levelDbov: -5),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: 2)
|
|
)
|
|
XCTAssertEqual(Set(selector.active.keys), Set([4, 9]))
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorDoesNotChurnAtEqualLevels() {
|
|
var selector = HuddleActiveTalkerSelector(capacity: 2)
|
|
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 4, levelDbov: -127),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 2, levelDbov: -127),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
let rejected = selector.activate(peerIndex: 9, levelDbov: -127)
|
|
XCTAssertEqual(
|
|
rejected,
|
|
HuddleTalkerSelection(accepted: false, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertNil(rejected.allocateIfAccepted { NSObject() })
|
|
XCTAssertNil(selector.active[9])
|
|
XCTAssertEqual(Set(selector.active.keys), Set([2, 4]))
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorExpiresInactiveSlots() {
|
|
var now: TimeInterval = 0
|
|
var selector = HuddleActiveTalkerSelector(
|
|
capacity: 2,
|
|
now: { now },
|
|
inactivityTimeout: 1
|
|
)
|
|
|
|
_ = selector.activate(peerIndex: 4, levelDbov: -10)
|
|
now = 0.999
|
|
_ = selector.activate(peerIndex: 2, levelDbov: -5)
|
|
now = 1
|
|
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 9, levelDbov: -30),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: 4)
|
|
)
|
|
XCTAssertEqual(Set(selector.active.keys), Set([2, 9]))
|
|
}
|
|
|
|
func testHuddlePacketJitterQueueReordersAndRejectsStaleDuplicates() {
|
|
var queue = HuddlePacketJitterQueue(capacity: 3, startPackets: 2)
|
|
queue.enqueue(remotePacket(sequence: 11))
|
|
queue.enqueue(remotePacket(sequence: 10))
|
|
XCTAssertEqual(queue.packets.map(\.sequence), [10, 11])
|
|
XCTAssertEqual(queue.drainOne()?.sequence, 10)
|
|
queue.enqueue(remotePacket(sequence: 10))
|
|
queue.enqueue(remotePacket(sequence: 12))
|
|
XCTAssertEqual(queue.packets.map(\.sequence), [11, 12])
|
|
}
|
|
|
|
private func remotePacket(sequence: Int) -> HuddleRemoteOpusPacket {
|
|
HuddleRemoteOpusPacket(
|
|
peerIndex: 1,
|
|
sequence: sequence,
|
|
timestamp48k: Int64(sequence * 960),
|
|
levelDbov: -20,
|
|
opus: Data([1])
|
|
)
|
|
}
|
|
|
|
func testHuddleOpusCodecRoundTripsFixedV2Frame() throws {
|
|
let encoder = try HuddleOpusEncoder()
|
|
let decoder = try HuddleOpusDecoder()
|
|
let samples = (0..<HuddleAudioFormats.frameSamples).map { index in
|
|
Float(sin(2 * .pi * 440 * Double(index) / HuddleAudioFormats.sampleRate))
|
|
}
|
|
|
|
let packet = try encoder.encode(samples)
|
|
let decoded = try decoder.decode(packet)
|
|
|
|
XCTAssertFalse(packet.isEmpty)
|
|
XCTAssertLessThanOrEqual(packet.count, HuddleAudioFormats.maximumOpusPacketBytes)
|
|
XCTAssertGreaterThan(decoded.frameLength, 0)
|
|
XCTAssertLessThanOrEqual(
|
|
decoded.frameLength,
|
|
AVAudioFrameCount(HuddleAudioFormats.frameSamples)
|
|
)
|
|
}
|
|
|
|
func testHuddleAudioLevelsReportDbovWithoutPersistingAudio() {
|
|
let silence = Array(repeating: Float.zero, count: HuddleAudioFormats.frameSamples)
|
|
let halfScale = Array(repeating: Float(0.5), count: HuddleAudioFormats.frameSamples)
|
|
|
|
XCTAssertEqual(HuddleAudioLevels.rmsDbov(silence), -127)
|
|
XCTAssertEqual(HuddleAudioLevels.peakDbov(silence), -127)
|
|
XCTAssertEqual(HuddleAudioLevels.rmsDbov(halfScale), -6)
|
|
XCTAssertEqual(HuddleAudioLevels.peakDbov(halfScale), -6)
|
|
}
|
|
|
|
func testRelativeTrackInsertionTimesPreserveAudioDelay() {
|
|
let times = AppDelegate.relativeTrackInsertionTimes(
|
|
videoStart: CMTime(seconds: 1, preferredTimescale: 600),
|
|
audioStart: CMTime(seconds: 1.5, preferredTimescale: 600)
|
|
)
|
|
|
|
XCTAssertEqual(CMTimeCompare(times.video, .zero), 0)
|
|
XCTAssertEqual(
|
|
CMTimeCompare(
|
|
times.audio ?? .invalid,
|
|
CMTime(seconds: 0.5, preferredTimescale: 600)
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testRelativeTrackInsertionTimesPreserveVideoDelay() {
|
|
let times = AppDelegate.relativeTrackInsertionTimes(
|
|
videoStart: CMTime(seconds: 2, preferredTimescale: 600),
|
|
audioStart: CMTime(seconds: 1, preferredTimescale: 600)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
CMTimeCompare(
|
|
times.video,
|
|
CMTime(seconds: 1, preferredTimescale: 600)
|
|
),
|
|
0
|
|
)
|
|
XCTAssertEqual(CMTimeCompare(times.audio ?? .invalid, .zero), 0)
|
|
}
|
|
|
|
func testRelativeTrackInsertionTimesZeroBasesVideoWithoutAudio() {
|
|
let times = AppDelegate.relativeTrackInsertionTimes(
|
|
videoStart: CMTime(seconds: 3, preferredTimescale: 600),
|
|
audioStart: nil
|
|
)
|
|
|
|
XCTAssertEqual(CMTimeCompare(times.video, .zero), 0)
|
|
XCTAssertNil(times.audio)
|
|
}
|
|
|
|
@MainActor
|
|
func testExpandedAttachmentSurfaceDismissesKeyboard() {
|
|
let window = KeyboardDismissalSpyWindow()
|
|
|
|
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: window)
|
|
|
|
XCTAssertTrue(window.didForceEndEditing)
|
|
}
|
|
|
|
func testExpandedAttachmentSurfaceMeasuresKeyboardOverlap() {
|
|
XCTAssertEqual(
|
|
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
|
|
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
keyboardLayoutFrame: CGRect(
|
|
x: 0,
|
|
y: 544,
|
|
width: 390,
|
|
height: 300
|
|
)
|
|
),
|
|
300
|
|
)
|
|
XCTAssertEqual(
|
|
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
|
|
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
keyboardLayoutFrame: CGRect(x: 0, y: 844, width: 390, height: 0)
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuReturnsToKeyboardDismissedAnchor() {
|
|
let anchorBounds = CGRect(x: 0, y: 0, width: 44, height: 44)
|
|
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverAnchorLayout.sourceRect(
|
|
anchorBounds: anchorBounds,
|
|
keyboardDismissalOffset: 300,
|
|
isExpanded: true
|
|
),
|
|
anchorBounds.offsetBy(dx: 0, dy: 340)
|
|
)
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverAnchorLayout.sourceRect(
|
|
anchorBounds: anchorBounds,
|
|
keyboardDismissalOffset: 300,
|
|
isExpanded: false
|
|
),
|
|
anchorBounds.offsetBy(dx: 0, dy: 300)
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuKeepsKeyboardWhenMenuFitsAboveTrigger() {
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
|
|
sourceRect: CGRect(x: 320, y: 480, width: 44, height: 44),
|
|
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
safeAreaInsets: UIEdgeInsets(top: 59, left: 0, bottom: 34, right: 0),
|
|
keyboardLayoutFrame: CGRect(
|
|
x: 0,
|
|
y: 544,
|
|
width: 390,
|
|
height: 300
|
|
),
|
|
menuHeight: NativeAttachmentMenuLayout.size(
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
).height
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuDismissesKeyboardAndRepositionsInCompactHeight() {
|
|
let sourceRect = CGRect(x: 760, y: 168, width: 44, height: 44)
|
|
let keyboardDismissalOffset =
|
|
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
|
|
sourceRect: sourceRect,
|
|
containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390),
|
|
safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59),
|
|
keyboardLayoutFrame: CGRect(
|
|
x: 0,
|
|
y: 228,
|
|
width: 844,
|
|
height: 162
|
|
),
|
|
menuHeight: NativeAttachmentMenuLayout.size(
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
).height
|
|
)
|
|
|
|
XCTAssertEqual(keyboardDismissalOffset, 162)
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverPresentationLayout.sourceRect(
|
|
sourceRect,
|
|
keyboardDismissalOffset: keyboardDismissalOffset
|
|
),
|
|
sourceRect.offsetBy(dx: 0, dy: 162)
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuDoesNotMoveWithoutSoftwareKeyboard() {
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
|
|
sourceRect: CGRect(x: 760, y: 168, width: 44, height: 44),
|
|
containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390),
|
|
safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59),
|
|
keyboardLayoutFrame: CGRect(x: 0, y: 390, width: 844, height: 0),
|
|
menuHeight: NativeAttachmentMenuLayout.size(
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
).height
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testNativeAttachmentMenuUsesRoomyRowsAndInsets() {
|
|
let traits = UITraitCollection(preferredContentSizeCategory: .large)
|
|
let size = NativeAttachmentMenuLayout.size(compatibleWith: traits)
|
|
|
|
XCTAssertEqual(size.width, 216)
|
|
XCTAssertEqual(size.height, 324) // Five actions, including voice notes.
|
|
XCTAssertEqual(NativeAttachmentMenuLayout.contentPadding, 16)
|
|
XCTAssertEqual(
|
|
NativeAttachmentMenuLayout.itemHeight(compatibleWith: traits),
|
|
52
|
|
)
|
|
XCTAssertEqual(NativeAttachmentMenuLayout.itemSpacing, 8)
|
|
XCTAssertEqual(NativeAttachmentMenuLayout.labelTextStyle, .title3)
|
|
}
|
|
|
|
func testNativeAttachmentMenuUsesInterAndSharedPopoverChrome() {
|
|
let font = NativeAttachmentMenuTypography.font(
|
|
forTextStyle: NativeAttachmentMenuLayout.labelTextStyle
|
|
)
|
|
var didSelect = false
|
|
let button = makeNativeAttachmentMenuButton(
|
|
title: "Photos",
|
|
symbol: "photo",
|
|
action: { didSelect = true }
|
|
)
|
|
let titleLabel = button.subviews.compactMap { $0 as? UILabel }.first
|
|
|
|
XCTAssertTrue(font.fontName.hasPrefix("Inter"))
|
|
XCTAssertTrue(titleLabel?.font.fontName.hasPrefix("Inter") == true)
|
|
XCTAssertEqual(NativeAttachmentPopoverStyle.cornerRadius, 20)
|
|
XCTAssertEqual(NativeAttachmentPopoverStyle.borderWidth, 1)
|
|
XCTAssertEqual(NativeAttachmentPopoverStyle.shadowOpacity, 0.18)
|
|
|
|
button.sendActions(for: .primaryActionTriggered)
|
|
XCTAssertTrue(didSelect)
|
|
}
|
|
|
|
func testNativeAttachmentMenuGrowsAndScrollsForAccessibilityText() {
|
|
let traits = UITraitCollection(
|
|
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
|
|
)
|
|
let itemHeight = NativeAttachmentMenuLayout.itemHeight(
|
|
compatibleWith: traits
|
|
)
|
|
let contentHeight = NativeAttachmentMenuLayout.contentHeight(
|
|
compatibleWith: traits
|
|
)
|
|
let size = NativeAttachmentMenuLayout.size(compatibleWith: traits)
|
|
|
|
XCTAssertGreaterThan(itemHeight, 52)
|
|
XCTAssertGreaterThan(contentHeight, 264)
|
|
XCTAssertEqual(
|
|
size.height,
|
|
min(contentHeight, NativeAttachmentMenuLayout.maximumHeight)
|
|
)
|
|
XCTAssertLessThanOrEqual(
|
|
size.height,
|
|
NativeAttachmentMenuLayout.maximumHeight
|
|
)
|
|
}
|
|
|
|
func testDynamicIslandQrScannerRecognizesTallSafeAreas() {
|
|
for safeAreaTopInset in [51, 59, 62] {
|
|
XCTAssertTrue(
|
|
AppDelegate.usesDynamicIslandQrScannerPortal(
|
|
safeAreaTopInset: CGFloat(safeAreaTopInset)
|
|
),
|
|
"\(safeAreaTopInset)"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testDynamicIslandQrScannerRejectsStandardSafeAreas() {
|
|
for safeAreaTopInset in [0, 44, 47, 50] {
|
|
XCTAssertFalse(
|
|
AppDelegate.usesDynamicIslandQrScannerPortal(
|
|
safeAreaTopInset: CGFloat(safeAreaTopInset)
|
|
),
|
|
"\(safeAreaTopInset)"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testClipboardImageDataPrefersOriginalPngBytes() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
let pngData = Data([0x89, 0x50, 0x4E, 0x47])
|
|
let jpegData = Data([0xFF, 0xD8, 0xFF])
|
|
pasteboard.setItems([
|
|
["public.png": pngData, "public.jpeg": jpegData]
|
|
])
|
|
|
|
XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), pngData)
|
|
}
|
|
|
|
func testClipboardImageDataPreservesOriginalWebPBytesForValidation() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
let webPData = Data("RIFFxxxxWEBP".utf8)
|
|
pasteboard.setData(webPData, forPasteboardType: "org.webmproject.webp")
|
|
|
|
XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), webPData)
|
|
}
|
|
|
|
func testClipboardImageDataPreservesOriginalGifBytesForValidation() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
let gifData = Data("GIF89a".utf8)
|
|
pasteboard.setData(gifData, forPasteboardType: "com.compuserve.gif")
|
|
|
|
XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), gifData)
|
|
}
|
|
|
|
func testClipboardImageDataReturnsNilWithoutAnImage() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
pasteboard.string = "text only"
|
|
|
|
XCTAssertNil(AppDelegate.clipboardImageData(from: pasteboard))
|
|
}
|
|
|
|
func testSanitizePngRemovesUIKitMetadataChunks() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
|
|
XCTAssertEqual(
|
|
try pngChunkTypes(fixture),
|
|
[
|
|
"IHDR", "sRGB", "eXIf", "pHYs", "iDOT", "IDAT", "IDAT", "IEND",
|
|
])
|
|
|
|
let sanitized = try MediaSanitizer.scrubPng(fixture)
|
|
|
|
XCTAssertEqual(
|
|
try pngChunkTypes(sanitized),
|
|
[
|
|
"IHDR", "sRGB", "IDAT", "IDAT", "IEND",
|
|
])
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
|
|
var withTrailingPayload = fixture
|
|
withTrailingPayload.append(Data("hidden location".utf8))
|
|
let scrubbedTrailingPayload = try MediaSanitizer.scrubPng(withTrailingPayload)
|
|
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
|
|
}
|
|
|
|
func testSanitizePngSupportsDataSlices() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
|
|
let padded = Data([0x00]) + fixture
|
|
let slice = padded.dropFirst()
|
|
XCTAssertNotEqual(slice.startIndex, 0)
|
|
|
|
let sanitized = try MediaSanitizer.scrubPng(slice)
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
}
|
|
|
|
func testSanitizeJpegRemovesUIKitMetadataSegments() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
|
XCTAssertEqual(try jpegMetadataMarkers(fixture), [0xE0, 0xE1, 0xED])
|
|
|
|
let sanitized = try MediaSanitizer.scrubJpeg(fixture)
|
|
|
|
XCTAssertEqual(try jpegMetadataMarkers(sanitized), [0xE0])
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
|
|
var withTrailingPayload = fixture
|
|
withTrailingPayload.append(Data("hidden location".utf8))
|
|
let scrubbedTrailingPayload = try MediaSanitizer.scrubJpeg(withTrailingPayload)
|
|
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
|
|
}
|
|
|
|
func testSanitizeJpegSupportsDataSlices() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
|
let padded = Data([0x00]) + fixture
|
|
let slice = padded.dropFirst()
|
|
XCTAssertNotEqual(slice.startIndex, 0)
|
|
|
|
let sanitized = try MediaSanitizer.scrubJpeg(slice)
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
}
|
|
|
|
func testEncodeJpegScrubsUIKitOutput() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
|
let image = try XCTUnwrap(UIImage(data: fixture))
|
|
|
|
let encoded = try XCTUnwrap(MediaSanitizer.encodeJpeg(image))
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(encoded, mimeType: "image/jpeg")
|
|
XCTAssertNotNil(UIImage(data: encoded))
|
|
}
|
|
|
|
func testSanitizeDisplayP3ImagePreservesRenderedColorInSRGB() throws {
|
|
let image = try displayP3Image(red: 0.9, green: 0.2, blue: 0.1)
|
|
let expectedColor = try sRGBPixel(from: image)
|
|
let mimeTypesAndAccuracy: [(mimeType: String, accuracy: UInt8)] = [
|
|
("image/png", 0), ("image/jpeg", 1),
|
|
]
|
|
|
|
for (mimeType, accuracy) in mimeTypesAndAccuracy {
|
|
let sanitized = try XCTUnwrap(
|
|
MediaSanitizer.sanitizeImage(image, mimeType: mimeType),
|
|
"Failed to sanitize Display-P3 image as \(mimeType)"
|
|
)
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: mimeType)
|
|
let decoded = try XCTUnwrap(UIImage(data: sanitized))
|
|
XCTAssertEqual(
|
|
decoded.cgImage?.colorSpace?.name,
|
|
CGColorSpace(name: CGColorSpace.sRGB)?.name
|
|
)
|
|
let actualColor = try sRGBPixel(from: decoded)
|
|
XCTAssertEqual(actualColor.count, expectedColor.count)
|
|
for (actual, expected) in zip(actualColor, expectedColor) {
|
|
XCTAssertLessThanOrEqual(
|
|
actual > expected ? actual - expected : expected - actual,
|
|
accuracy
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testCategoryTrackerHighlightsLastHeaderAtOrAboveTop() {
|
|
let order = ["people", "nature", "flags"]
|
|
let offsets: [String: CGFloat] = [
|
|
"people": -320,
|
|
"nature": -12,
|
|
"flags": 200,
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0
|
|
),
|
|
"nature"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerFollowsScrollPastEachHeader() {
|
|
let order = ["people", "nature", "flags"]
|
|
|
|
// Scrolled to the very top: the first section is highlighted.
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": 0, "nature": 400, "flags": 800],
|
|
viewportTop: 0
|
|
),
|
|
"people"
|
|
)
|
|
|
|
// Scrolled far enough that Flags has reached the top.
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": -800, "nature": -400, "flags": 0],
|
|
viewportTop: 0
|
|
),
|
|
"flags"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerFallsBackToFirstSectionBeforeAnyHeaderReachesTop() {
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: ["people", "nature"],
|
|
offsets: ["people": 40, "nature": 400],
|
|
viewportTop: 0
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerSelectsShortFinalSectionAtClampedBottom() {
|
|
// The list has overflowed (People scrolled above the top) and its end is on
|
|
// screen, but the short Custom section's header sits below the top because
|
|
// the content clamps before it can reach it. The rail must still highlight
|
|
// Custom rather than leaving Nature — its predecessor — selected.
|
|
let order = ["people", "nature", "custom"]
|
|
let offsets: [String: CGFloat] = [
|
|
"people": -900,
|
|
"nature": -420,
|
|
"custom": 360,
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0,
|
|
viewportBottom: 500,
|
|
contentBottom: 500
|
|
),
|
|
"custom"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerKeepsHeaderRuleWhenContentEndIsOffscreen() {
|
|
// The same short-final geometry, but the content end is still below the
|
|
// viewport (the user has not reached the bottom), so the ordinary
|
|
// header-at-top rule applies and Nature stays selected.
|
|
let order = ["people", "nature", "custom"]
|
|
let offsets: [String: CGFloat] = [
|
|
"people": -900,
|
|
"nature": -420,
|
|
"custom": 360,
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0,
|
|
viewportBottom: 500,
|
|
contentBottom: 900
|
|
),
|
|
"nature"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerDoesNotForceLastSectionForAShortList() {
|
|
// A list that fits without scrolling has its content end on screen too, but
|
|
// its first header is still at the top — so the bottom rule must not fire
|
|
// and steal the highlight to the final section.
|
|
let order = ["people", "nature"]
|
|
let offsets: [String: CGFloat] = ["people": 0, "nature": 120]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0,
|
|
viewportBottom: 500,
|
|
contentBottom: 240
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerDoesNotFlickerBackAtPinnedHeaderBoundary() {
|
|
let order = ["people", "nature", "flags"]
|
|
|
|
// Nature has just become selected. A subsequent layout pass can briefly
|
|
// report its pinned header a couple of points below the boundary while the
|
|
// previous header is still pinned. Keep Nature selected through that
|
|
// transient frame instead of alternating the category rail.
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": 0, "nature": 2, "flags": 400],
|
|
viewportTop: 0,
|
|
currentSelection: "nature"
|
|
),
|
|
"nature"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerReleasesBoundaryLatchOnRealUpwardScroll() {
|
|
let order = ["people", "nature", "flags"]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": 0, "nature": 24, "flags": 424],
|
|
viewportTop: 0,
|
|
currentSelection: "nature"
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerReleasesSelectionWhenOldHeaderIsMissing() {
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: ["people", "nature", "flags"],
|
|
offsets: ["people": 0, "flags": 400],
|
|
viewportTop: 0,
|
|
currentSelection: "nature"
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws {
|
|
let maximumConcurrentDownloads = 3
|
|
let taskCount = 8
|
|
let probe = NativeEmojiDownloadProbe()
|
|
let tasksAttemptedAdmission = XCTestExpectation(
|
|
description: "all download tasks attempted admission"
|
|
)
|
|
tasksAttemptedAdmission.expectedFulfillmentCount = taskCount
|
|
let loader = NativeEmojiRemoteImageLoader(
|
|
maximumConcurrentDownloads: maximumConcurrentDownloads,
|
|
cacheByteLimit: 0,
|
|
admissionAttemptForTesting: { tasksAttemptedAdmission.fulfill() },
|
|
downloader: { _ in
|
|
await probe.holdDownload()
|
|
return UIImage()
|
|
}
|
|
)
|
|
let tasks = (0..<taskCount).map { index in
|
|
Task {
|
|
try await loader.image(
|
|
for: URLRequest(
|
|
url: try XCTUnwrap(URL(string: "https://example.com/\(index).png"))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
await fulfillment(of: [tasksAttemptedAdmission], timeout: 2)
|
|
await probe.waitUntilStarted(maximumConcurrentDownloads)
|
|
var snapshot = await probe.snapshot()
|
|
XCTAssertEqual(snapshot.started, maximumConcurrentDownloads)
|
|
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
|
|
|
for expectedStarted in (maximumConcurrentDownloads + 1)...tasks.count {
|
|
await probe.releaseOne()
|
|
await probe.waitUntilStarted(expectedStarted)
|
|
}
|
|
await probe.releaseAll()
|
|
for task in tasks {
|
|
_ = try await task.value
|
|
}
|
|
|
|
snapshot = await probe.snapshot()
|
|
XCTAssertEqual(snapshot.started, tasks.count)
|
|
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
|
XCTAssertEqual(snapshot.active, 0)
|
|
}
|
|
|
|
func testNativeMessageActionsPreserveRequestedGroupsAndHeight() throws {
|
|
let actionArguments: [[String: Any]] = [
|
|
[
|
|
"id": "reply", "title": "Reply",
|
|
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
|
],
|
|
[
|
|
"id": "copyText", "title": "Copy text",
|
|
"symbol": "doc.on.doc", "group": "utility",
|
|
],
|
|
[
|
|
"id": "delete", "title": "Delete message",
|
|
"symbol": "trash", "group": "destructive", "destructive": true,
|
|
],
|
|
]
|
|
let definitions = try actionArguments.map { arguments in
|
|
try XCTUnwrap(NativeMessageActionDefinition(arguments: arguments))
|
|
}
|
|
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions),
|
|
[.primary, .utility, .destructive]
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceLayout.separatorCount(actions: definitions),
|
|
2
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceLayout.preferredHeight(
|
|
actions: definitions,
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
),
|
|
153
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMessageActionRowUsesUIKitTypographyAndSelection() throws {
|
|
let definition = try XCTUnwrap(
|
|
NativeMessageActionDefinition(
|
|
arguments: [
|
|
"id": "reply", "title": "Reply",
|
|
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
|
]
|
|
)
|
|
)
|
|
var selected = false
|
|
let row = NativeMessageActionRowControl(
|
|
definition: definition,
|
|
foregroundColor: .label,
|
|
destructiveColor: .systemRed,
|
|
onSelected: { selected = true }
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
row.actionTitleLabel.font.fontDescriptor.object(forKey: .textStyle) as? String,
|
|
UIFont.TextStyle.body.rawValue
|
|
)
|
|
XCTAssertNotNil(row.actionImageView.image)
|
|
row.sendActions(for: .touchUpInside)
|
|
XCTAssertTrue(selected)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMessageActionRowReceivesTapsAcrossItsWholeSurface() throws {
|
|
let definition = try XCTUnwrap(
|
|
NativeMessageActionDefinition(
|
|
arguments: [
|
|
"id": "edit", "title": "Edit message",
|
|
"symbol": "pencil", "group": "primary",
|
|
]
|
|
)
|
|
)
|
|
var selectionCount = 0
|
|
let row = NativeMessageActionRowControl(
|
|
definition: definition,
|
|
foregroundColor: .label,
|
|
destructiveColor: .systemRed,
|
|
onSelected: { selectionCount += 1 }
|
|
)
|
|
row.frame = CGRect(x: 0, y: 0, width: 288, height: 48)
|
|
row.layoutIfNeeded()
|
|
|
|
let iconCenter = row.actionImageView.convert(
|
|
CGPoint(x: row.actionImageView.bounds.midX, y: row.actionImageView.bounds.midY),
|
|
to: row
|
|
)
|
|
let labelCenter = row.actionTitleLabel.convert(
|
|
CGPoint(x: row.actionTitleLabel.bounds.midX, y: row.actionTitleLabel.bounds.midY),
|
|
to: row
|
|
)
|
|
for point in [CGPoint(x: 4, y: 24), iconCenter, labelCenter, CGPoint(x: 284, y: 24)] {
|
|
let target = row.hitTest(point, with: nil)
|
|
XCTAssertTrue(target === row, "Tap at \(point) must reach the action control")
|
|
(target as? UIControl)?.sendActions(for: .touchUpInside)
|
|
}
|
|
XCTAssertEqual(selectionCount, 4)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMessageActionRowExpandsForAccessibilityTypography() throws {
|
|
let traits = UITraitCollection(
|
|
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
|
|
)
|
|
let definition = try XCTUnwrap(
|
|
NativeMessageActionDefinition(
|
|
arguments: [
|
|
"id": "followThread", "title": "Follow thread",
|
|
"symbol": "bell", "group": "utility",
|
|
]
|
|
)
|
|
)
|
|
let row = NativeMessageActionRowControl(
|
|
definition: definition,
|
|
foregroundColor: .label,
|
|
destructiveColor: .systemRed,
|
|
compatibleWith: traits,
|
|
onSelected: {}
|
|
)
|
|
let fittingSize = row.systemLayoutSizeFitting(
|
|
CGSize(width: 288, height: UIView.layoutFittingCompressedSize.height),
|
|
withHorizontalFittingPriority: .required,
|
|
verticalFittingPriority: .fittingSizeLevel
|
|
)
|
|
row.frame = CGRect(origin: .zero, size: fittingSize)
|
|
row.layoutIfNeeded()
|
|
let labelFrame = row.convert(
|
|
row.actionTitleLabel.bounds,
|
|
from: row.actionTitleLabel
|
|
)
|
|
|
|
XCTAssertGreaterThan(fittingSize.height, 48)
|
|
XCTAssertGreaterThan(labelFrame.height, 0)
|
|
XCTAssertGreaterThanOrEqual(
|
|
labelFrame.minY,
|
|
NativeMessageActionSurfaceLayout.rowVerticalPadding
|
|
)
|
|
XCTAssertLessThanOrEqual(
|
|
labelFrame.maxY,
|
|
fittingSize.height - NativeMessageActionSurfaceLayout.rowVerticalPadding
|
|
)
|
|
XCTAssertEqual(row.actionTitleLabel.numberOfLines, 0)
|
|
XCTAssertFalse(row.actionTitleLabel.adjustsFontSizeToFitWidth)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMessageActionSurfaceUsesSystemMaterial() {
|
|
let effect = NativeMessageActionSurfaceAppearance.backdropEffect(
|
|
reduceTransparency: false
|
|
)
|
|
if #available(iOS 26.0, *) {
|
|
XCTAssertTrue(effect is UIGlassEffect)
|
|
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 33)
|
|
} else {
|
|
XCTAssertTrue(effect is UIBlurEffect)
|
|
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 12)
|
|
}
|
|
XCTAssertNil(
|
|
NativeMessageActionSurfaceAppearance.backdropEffect(
|
|
reduceTransparency: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testNativeMessageActionListDoesNotHideDialogSiblings() {
|
|
XCTAssertFalse(
|
|
NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
|
|
)
|
|
}
|
|
|
|
func testNativeMessageActionSurfaceMatchesFlutterInterfaceStyle() {
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "dark"),
|
|
.dark
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "light"),
|
|
.light
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "system"),
|
|
.unspecified
|
|
)
|
|
}
|
|
|
|
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
|
|
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
|
let context = try XCTUnwrap(
|
|
CGContext(
|
|
data: nil,
|
|
width: 1,
|
|
height: 1,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: 4,
|
|
space: colorSpace,
|
|
bitmapInfo: bitmapInfo.rawValue
|
|
)
|
|
)
|
|
context.setFillColor(
|
|
try XCTUnwrap(CGColor(colorSpace: colorSpace, components: [red, green, blue, 1]))
|
|
)
|
|
context.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
return UIImage(cgImage: try XCTUnwrap(context.makeImage()))
|
|
}
|
|
|
|
private func sRGBPixel(from image: UIImage) throws -> [UInt8] {
|
|
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.sRGB))
|
|
var bytes = [UInt8](repeating: 0, count: 4)
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
|
let context = try bytes.withUnsafeMutableBytes { bytes in
|
|
try XCTUnwrap(
|
|
CGContext(
|
|
data: bytes.baseAddress,
|
|
width: 1,
|
|
height: 1,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: 4,
|
|
space: colorSpace,
|
|
bitmapInfo: bitmapInfo.rawValue
|
|
)
|
|
)
|
|
}
|
|
context.interpolationQuality = .none
|
|
context.draw(try XCTUnwrap(image.cgImage), in: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
return bytes
|
|
}
|
|
|
|
private func fixtureData(named name: String, extension fileExtension: String) throws -> Data {
|
|
let url = try XCTUnwrap(
|
|
Bundle(for: RunnerTests.self).url(forResource: name, withExtension: fileExtension))
|
|
return try Data(contentsOf: url)
|
|
}
|
|
}
|
|
|
|
private final class KeyboardDismissalSpyWindow: UIWindow {
|
|
private(set) var didForceEndEditing = false
|
|
|
|
override func endEditing(_ force: Bool) -> Bool {
|
|
didForceEndEditing = force
|
|
return true
|
|
}
|
|
}
|
|
|
|
private enum RelayImagePolicyError: Error {
|
|
case invalidPng
|
|
case invalidJpeg
|
|
case metadataForbidden
|
|
}
|
|
|
|
private let pngSignature = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
private let allowedPngAncillaryChunks: Set<String> = [
|
|
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
|
|
]
|
|
|
|
private func assertMatchesRelayImageMetadataPolicy(_ data: Data, mimeType: String) throws {
|
|
switch mimeType {
|
|
case "image/png":
|
|
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
var offset = pngSignature.count
|
|
while offset < data.count {
|
|
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
|
|
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
|
|
guard payloadLength <= data.count - offset - 12 else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
let typeBytes = data[(offset + 4)..<(offset + 8)]
|
|
guard let type = String(bytes: typeBytes, encoding: .ascii) else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
let chunkEnd = offset + payloadLength + 12
|
|
let isAncillary = typeBytes[typeBytes.startIndex] & 0x20 != 0
|
|
if isAncillary, !allowedPngAncillaryChunks.contains(type) {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
offset = chunkEnd
|
|
if type == "IEND" {
|
|
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
|
|
return
|
|
}
|
|
}
|
|
throw RelayImagePolicyError.invalidPng
|
|
case "image/jpeg":
|
|
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
var offset = 2
|
|
var inScan = false
|
|
while offset < data.count {
|
|
if inScan, data[offset] != 0xFF {
|
|
offset += 1
|
|
continue
|
|
}
|
|
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
|
|
while offset < data.count, data[offset] == 0xFF { offset += 1 }
|
|
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
|
|
let marker = data[offset]
|
|
offset += 1
|
|
if inScan, marker == 0x00 { continue }
|
|
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
|
|
if marker == 0xD9 {
|
|
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
|
|
return
|
|
}
|
|
guard marker != 0xD8, data.count - offset >= 2 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
let length = Int(try readUInt16BigEndian(data, at: offset))
|
|
guard length >= 2, length <= data.count - offset else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
let payload = (offset + 2)..<(offset + length)
|
|
if marker == 0xE0 {
|
|
guard
|
|
payload.count >= 14,
|
|
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
|
0x4A, 0x46, 0x49, 0x46, 0x00,
|
|
]),
|
|
payload.count
|
|
== 14 + 3 * Int(data[payload.lowerBound + 12]) * Int(data[payload.lowerBound + 13])
|
|
else {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
} else if marker == 0xEE {
|
|
guard
|
|
payload.count == 12,
|
|
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
|
0x41, 0x64, 0x6F, 0x62, 0x65,
|
|
])
|
|
else {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
} else if (0xE1...0xED).contains(marker) || marker == 0xEF || marker == 0xFE {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
offset += length
|
|
inScan = marker == 0xDA
|
|
}
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
default:
|
|
XCTFail("Unsupported test MIME type: \(mimeType)")
|
|
}
|
|
}
|
|
|
|
private func pngChunkTypes(_ data: Data) throws -> [String] {
|
|
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
var result: [String] = []
|
|
var offset = pngSignature.count
|
|
while offset < data.count {
|
|
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
|
|
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
|
|
guard payloadLength <= data.count - offset - 12 else { throw RelayImagePolicyError.invalidPng }
|
|
guard let type = String(bytes: data[(offset + 4)..<(offset + 8)], encoding: .ascii) else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
result.append(type)
|
|
offset += payloadLength + 12
|
|
if type == "IEND" { return result }
|
|
}
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
|
|
private func jpegMetadataMarkers(_ data: Data) throws -> [UInt8] {
|
|
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
var result: [UInt8] = []
|
|
var offset = 2
|
|
var inScan = false
|
|
while offset < data.count {
|
|
if inScan, data[offset] != 0xFF {
|
|
offset += 1
|
|
continue
|
|
}
|
|
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
|
|
while offset < data.count, data[offset] == 0xFF { offset += 1 }
|
|
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
|
|
let marker = data[offset]
|
|
offset += 1
|
|
if inScan, marker == 0x00 { continue }
|
|
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
|
|
if marker == 0xD9 { return result }
|
|
guard marker != 0xD8, data.count - offset >= 2 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
let length = Int(try readUInt16BigEndian(data, at: offset))
|
|
guard length >= 2, length <= data.count - offset else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
if (0xE0...0xEF).contains(marker) || marker == 0xFE {
|
|
result.append(marker)
|
|
}
|
|
offset += length
|
|
inScan = marker == 0xDA
|
|
}
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
|
|
private func readUInt16BigEndian(_ data: Data, at offset: Int) throws -> UInt16 {
|
|
guard data.count - offset >= 2 else { throw RelayImagePolicyError.invalidJpeg }
|
|
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
|
}
|
|
|
|
private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 {
|
|
guard data.count - offset >= 4 else { throw RelayImagePolicyError.invalidPng }
|
|
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
|
|
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
|
|
}
|
|
|
|
private actor NativeEmojiDownloadProbe {
|
|
private struct MilestoneWaiter {
|
|
let count: Int
|
|
let continuation: CheckedContinuation<Void, Never>
|
|
}
|
|
|
|
private var active = 0
|
|
private var peakActive = 0
|
|
private var started = 0
|
|
private var releaseContinuations: [CheckedContinuation<Void, Never>] = []
|
|
private var milestoneWaiters: [MilestoneWaiter] = []
|
|
|
|
func holdDownload() async {
|
|
active += 1
|
|
started += 1
|
|
peakActive = max(peakActive, active)
|
|
resumeReachedMilestones()
|
|
await withCheckedContinuation { continuation in
|
|
releaseContinuations.append(continuation)
|
|
}
|
|
active -= 1
|
|
}
|
|
|
|
func waitUntilStarted(_ count: Int) async {
|
|
guard started < count else { return }
|
|
await withCheckedContinuation { continuation in
|
|
milestoneWaiters.append(
|
|
MilestoneWaiter(count: count, continuation: continuation)
|
|
)
|
|
}
|
|
}
|
|
|
|
func releaseOne() {
|
|
guard !releaseContinuations.isEmpty else { return }
|
|
releaseContinuations.removeFirst().resume()
|
|
}
|
|
|
|
func releaseAll() {
|
|
let continuations = releaseContinuations
|
|
releaseContinuations.removeAll()
|
|
for continuation in continuations {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
func snapshot() -> (active: Int, peakActive: Int, started: Int) {
|
|
(active, peakActive, started)
|
|
}
|
|
|
|
private func resumeReachedMilestones() {
|
|
let reached = milestoneWaiters.filter { $0.count <= started }
|
|
milestoneWaiters.removeAll { $0.count <= started }
|
|
for waiter in reached {
|
|
waiter.continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger {
|
|
var metrics: [String: Any]?
|
|
var avatarBounds: [String: Any]?
|
|
var actions: [String] = []
|
|
func send(onChannel channel: String, message: Data?) {
|
|
guard let message else { return }
|
|
let call = FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message)
|
|
if call.method == "metrics" { metrics = call.arguments as? [String: Any] }
|
|
if call.method == "avatarBounds" { avatarBounds = call.arguments as? [String: Any] }
|
|
if call.method == "action", let action = call.arguments as? String { actions.append(action) }
|
|
}
|
|
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
|
|
send(onChannel: channel, message: message)
|
|
callback?(nil)
|
|
}
|
|
private var handler: FlutterBinaryMessageHandler?
|
|
|
|
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], reply: @escaping (Any?) -> Void) {
|
|
let codec = FlutterStandardMethodCodec.sharedInstance()
|
|
let message = codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))
|
|
handler?(message) { data in reply(data.flatMap { codec.decodeEnvelope($0) }) }
|
|
}
|
|
|
|
func configure(_ arguments: [String: Any]) {
|
|
let message = FlutterStandardMethodCodec.sharedInstance().encode(
|
|
FlutterMethodCall(methodName: "configure", arguments: arguments)
|
|
)
|
|
handler?(message) { _ in }
|
|
}
|
|
|
|
func scroll(to offset: Double) {
|
|
let message = FlutterStandardMethodCodec.sharedInstance().encode(
|
|
FlutterMethodCall(methodName: "scroll", arguments: offset)
|
|
)
|
|
handler?(message) { _ in }
|
|
}
|
|
}
|