mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
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>
231 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|