Files
buzz/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift
Tom Brow bf3bdd0eae fix(mobile): match browser GIF timing in the iOS reaction sheet (#9437)
The two follow-ups from the pre-review of [#9432 GIF frame
timing](https://github.com/block/buzz/pull/9432), rebased onto main now
that it has landed.

**iOS reaction details sheet.** The sheet decodes custom emoji natively
(`NativeMessageImageLoader.decode`) and timed each frame as `max(0.02,
delay)`, so with [#9432 GIF frame
timing](https://github.com/block/buzz/pull/9432) a GIF with 0 or 1 cs
delays plays at 100 ms per frame in the timeline chip but 20 ms per
frame in the sheet opened from it. The decoder now shows frames of 10 ms
or less for 100 ms, as browsers and `MediaImageProvider` do, and keeps
longer frames as written. The 20 ms floor goes with it: browsers keep 11
to 19 ms frames as written too.

**Provider-level test.** The existing tests call
`withBrowserFrameTiming` directly, so removing the provider's call to it
left the suite green. A new test resolves a GIF through
`MediaImageProvider` with a mock HTTP client and pumps the scheduler's
app frames at explicit timestamps: the 10 ms first frame is still
showing at 50 ms and is replaced at 100 ms.

### Regressions

None expected. The sheet already summed the frame delays into one
`UIImage.animatedImage` duration, so only the per-frame value changes,
and only for frames of 10 ms or less, which ran at 20 ms before. Still
images and longer frames decode as before. The Dart test drives frame
callbacks with explicit timestamps rather than waiting on wall-clock
time.

### Validation

Added tests: `NativeMessageImageLoaderDecodeTests` in RunnerTests (a 0,
1, 2, 5 cs GIF decodes to a 0.27 s animation; a one-frame GIF stays
still) and `withBrowserFrameTiming is applied to the frames
MediaImageProvider plays` in `media_image_test.dart`, which fails when
the provider's call is removed (the second frame shows at 50 ms).

Full mobile suite: 4,780 tests passed. `flutter analyze`: no issues.
RunnerTests `NativeMessageImageLoaderDecodeTests` and
`NativeReactionDetailsTests`: all five pass on an iPhone 17 simulator
(Xcode build-for-testing of the Debug simulator app). No full-app run:
the change is one delay value in a decoder and is covered by the unit
tests above.

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: judge <7615ec4b7ddf99f0bb48e913297f99f8f772d289a1c9ef54e1d906dd55a93ba8@sprout-oss.stage.blox.sqprod.co>
2026-11-13 02:01:12 +00:00

231 lines
11 KiB
Swift

import UIKit
import XCTest
@testable import Buzz
final class NativeMessageImageLoaderTests: XCTestCase {
private func loader(limit: Int = 1) -> NativeMessageImageLoader {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MessageImageProtocol.self]
return NativeMessageImageLoader(configuration: configuration, maximumConcurrent: limit)
}
@MainActor func testRejectsDeclaredOversizeWithoutFinishingBody() async {
let done = expectation(description: "rejected")
MessageImageProtocol.start = { transport in
transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse(
url: transport.request.url!, statusCode: 200, httpVersion: nil,
headerFields: ["Content-Length": "\(NativeMessageImageLoader.maximumBytes + 1)", "Content-Type": "image/png"]
)!, cacheStoragePolicy: .notAllowed)
// Let URLProtocol deliver its headers without finishing the body. A
// completion-handler byte check would hang here, rather than reject.
transport.client?.urlProtocol(transport, didLoad: Data(count: 1024))
}
let loader = loader()
_ = loader.load(URLRequest(url: URL(string: "https://example.com/declared")!)) { image in
XCTAssertNil(image)
done.fulfill()
}
await fulfillment(of: [done], timeout: 2)
}
@MainActor func testRejectsChunkedOversizeWithoutWaitingForCompletion() async {
let done = expectation(description: "stream cancelled")
MessageImageProtocol.start = { transport in
transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse(
url: transport.request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
)!, cacheStoragePolicy: .notAllowed)
transport.client?.urlProtocol(transport, didLoad: Data(count: NativeMessageImageLoader.maximumBytes))
transport.client?.urlProtocol(transport, didLoad: Data([0]))
// No finish callback: the streaming byte guard must terminate the load.
}
let loader = loader()
_ = loader.load(URLRequest(url: URL(string: "https://example.com/chunked")!)) { image in
XCTAssertNil(image)
done.fulfill()
}
await fulfillment(of: [done], timeout: 2)
}
@MainActor func testDeduplicatesAndCancellationReleasesAdmission() async {
let first = expectation(description: "first admitted")
let second = expectation(description: "second admitted after cancellation")
var started = [String]()
MessageImageProtocol.start = { transport in
DispatchQueue.main.async {
started.append(transport.request.url!.path)
if started.count == 1 { first.fulfill() } else { second.fulfill() }
}
}
let loader = loader()
let request = URLRequest(url: URL(string: "https://example.com/shared")!)
let cancelFirst = loader.load(request) { _ in XCTFail("cancelled subscriber called") }
let cancelDuplicate = loader.load(request) { _ in XCTFail("cancelled duplicate called") }
let cancelQueued = loader.load(URLRequest(url: URL(string: "https://example.com/queued")!)) { _ in
XCTFail("cancelled queued subscriber called")
}
let cancelNext = loader.load(URLRequest(url: URL(string: "https://example.com/next")!)) { _ in }
await fulfillment(of: [first], timeout: 2)
XCTAssertEqual(started, ["/shared"])
cancelFirst()
cancelQueued()
// One subscriber remains: cancelling the first must not cancel its peer.
let turn = expectation(description: "cancellation processed")
DispatchQueue.main.async { turn.fulfill() }
await fulfillment(of: [turn], timeout: 2)
XCTAssertEqual(started, ["/shared"])
cancelDuplicate()
await fulfillment(of: [second], timeout: 2)
XCTAssertEqual(started, ["/shared", "/next"])
cancelNext()
}
@MainActor func testCachesDecodedImages() async {
let done = expectation(description: "decoded")
let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).pngData { _ in }
var requests = 0
MessageImageProtocol.start = { transport in
requests += 1
transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse(
url: transport.request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
)!, cacheStoragePolicy: .notAllowed)
transport.client?.urlProtocol(transport, didLoad: image)
transport.client?.urlProtocolDidFinishLoading(transport)
}
let loader = loader()
let request = URLRequest(url: URL(string: "https://example.com/cached")!)
_ = loader.load(request) { image in XCTAssertNotNil(image); done.fulfill() }
await fulfillment(of: [done], timeout: 2)
var cached = false
_ = loader.load(request) { image in cached = image != nil }
XCTAssertTrue(cached)
XCTAssertEqual(requests, 1)
}
}
final class NativeMessageImageLoaderDecodeTests: XCTestCase {
/// A 1x1 GIF with one frame per delay, in centiseconds.
private func gif(_ delays: [Int]) -> Data {
var bytes: [UInt8] = Array("GIF89a".utf8)
bytes += [1, 0, 1, 0, 0x80, 0, 0] // 1x1, two-colour global table
bytes += [0, 0, 0, 0xff, 0xff, 0xff]
for delay in delays {
bytes += [0x21, 0xf9, 4, 0, UInt8(delay & 0xff), UInt8(delay >> 8), 0, 0] // frame delay
bytes += [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0] // 1x1 image
bytes += [2, 2, 0x44, 1, 0] // one pixel
}
bytes.append(0x3b)
return Data(bytes)
}
func testShowsFramesOfTenMillisecondsOrLessForOneHundred() throws {
// Browsers and the Flutter timeline show 0 and 1 cs frames for 100 ms;
// longer frames keep their own delay.
let image = try XCTUnwrap(NativeMessageImageLoader.decode(gif([0, 1, 2, 5])))
XCTAssertEqual(image.images?.count, 4)
XCTAssertEqual(image.duration, 0.1 + 0.1 + 0.02 + 0.05, accuracy: 0.001)
}
func testStillImageIsNotAnimated() throws {
let image = try XCTUnwrap(NativeMessageImageLoader.decode(gif([5])))
XCTAssertNil(image.images)
}
}
private final class MessageImageProtocol: URLProtocol {
static var start: ((MessageImageProtocol) -> Void)?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() { Self.start?(self) }
override func stopLoading() {}
}
final class NativeReactionDetailsTests: XCTestCase {
@MainActor func testInlineEmojiAvatarRendersEmojiAndBackground() {
let controller = NativeReactionDetailsViewController(data: [
"reactions": [["emoji": "❤️", "label": "Heart", "users": ["person"]]],
"profiles": ["person": ["name": "Kenny", "avatarEmoji": "😊", "avatarColor": NSNumber(value: UInt32(0xFFFF6B9A))]],
])
let cell = controller.tableView(UITableView(), cellForRowAt: IndexPath(row: 0, section: 0))
let row = cell.contentView.subviews.compactMap { $0 as? UIStackView }.first!
let avatar = row.arrangedSubviews[0] as! NativeMessageGlyph
XCTAssertEqual(avatar.subviews.compactMap { $0 as? UILabel }.first?.text, "😊")
XCTAssertEqual(avatar.backgroundColor, UIColor(red: 1, green: 107.0 / 255, blue: 154.0 / 255, alpha: 1))
controller.updateProfiles(["person": ["name": "Kenny", "avatarEmoji": "🎉", "avatarColor": NSNumber(value: UInt32(0xFFFFE75C))]])
let updated = controller.tableView(UITableView(), cellForRowAt: IndexPath(row: 0, section: 0))
let updatedRow = updated.contentView.subviews.compactMap { $0 as? UIStackView }.first!
XCTAssertEqual(updatedRow.arrangedSubviews[0].subviews.compactMap { $0 as? UILabel }.first?.text, "🎉")
}
@MainActor func testShortcodeReactionWithoutImageNeverShowsText() {
let glyph = NativeMessageGlyph(
data: ["emoji": ":ghost:", "isShortcode": true], size: 26, isReaction: true)
XCTAssertNil(glyph.subviews.compactMap { $0 as? UILabel }.first?.text)
XCTAssertNotNil(glyph.subviews.compactMap { $0 as? UIImageView }.first?.image)
}
@MainActor func testTogglesListEveryReactorAndRelabelUpdatedProfiles() {
let controller = NativeReactionDetailsViewController(data: [
"reactions": [
["emoji": "❤️", "label": "Heart", "count": 2, "users": ["human", "agent"]],
["emoji": "🔥", "label": "Fire", "count": 1, "users": ["agent"]],
],
"profiles": ["human": ["name": "Honey"], "agent": ["name": "Honey (agent)"]],
])
var toggled: [String] = []
controller.onToggle = { toggled.append($0) }
controller.loadViewIfNeeded()
let table = UITableView()
func labels() -> [String] {
(0..<controller.tableView(table, numberOfRowsInSection: 0)).map {
controller.tableView(table, cellForRowAt: IndexPath(row: $0, section: 0)).accessibilityLabel ?? ""
}
}
func descendants(_ view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
func tap(_ label: String) {
let button = descendants(controller.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == label }
XCTAssertNotNil(button, label)
button?.sendActions(for: .primaryActionTriggered)
}
XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart", "Honey (agent), Fire"])
tap("Fire 1")
tap("Heart 2")
XCTAssertEqual(toggled, ["🔥", "❤️"])
controller.updateProfiles(["agent": ["name": "Helper (agent)"]])
XCTAssertEqual(labels(), ["Honey, Heart", "Helper (agent), Heart", "Helper (agent), Fire"])
}
}
final class NativeMessageLayoutTests: XCTestCase {
private func descendants(_ view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
@MainActor func testReactionFiltersFitAccessibilityText() {
let parent = UIViewController()
let controller = NativeReactionDetailsViewController(data: [
"reactions": [["emoji": "❤️", "label": "Heart", "count": 12345, "users": ["a"]]],
])
parent.addChild(controller)
parent.setOverrideTraitCollection(UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge), forChild: controller)
parent.view.addSubview(controller.view)
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
let filter = descendants(controller.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == "Heart 12345" }
XCTAssertNotNil(filter)
let viewport = descendants(controller.view).compactMap { $0 as? UIScrollView }
.first { !($0 is UITableView) }
XCTAssertNotNil(viewport)
let needed = filter?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height ?? 0
XCTAssertGreaterThan(needed, 48)
XCTAssertGreaterThanOrEqual(viewport?.bounds.height ?? 0, needed)
XCTAssertGreaterThanOrEqual(filter?.bounds.height ?? 0, needed)
}
}