| index e60eca2..f74b276 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -1,3 +1,4 @@ |
| +import UIKit |
| import XCTest |
| |
| final class CallsPhoneUITests: XCTestCase { |
| @@ -396,4 +397,108 @@ final class CallsPhoneUITests: XCTestCase { |
| XCTAssertTrue(app.buttons["moreControls"].isHittable) |
| XCTAssertLessThan(app.buttons["moreControls"].frame.midX, app.buttons["leave"].frame.midX) |
| } |
| + |
| + // MARK: Glyphs (0.4.1) |
| + |
| + /// Paul, on the 0.4.0 bar: "Are these vertically aligned?" Every |
| + /// symbol a call control draws goes through CallGlyph and its optical |
| + /// table; the Mac's tests measure it drawn by AppKit, and this measures |
| + /// it as the iPhone draws it: the round bar buttons, the microphone |
| + /// beside the menu, and every button in the controls sheet, each |
| + /// symbol's ink within half a point of its button's centre line. |
| + @MainActor func testControlGlyphsAreOnTheirCentreLines() throws { |
| + XCUIDevice.shared.orientation = .portrait |
| + var report: [String] = [] |
| + var failures: [String] = [] |
| + func measure(_ app: XCUIApplication, _ ids: [String], _ label: String) { |
| + let shot = XCUIScreen.main.screenshot() |
| + for id in ids { |
| + let button = app.buttons[id].firstMatch |
| + guard button.exists else { |
| + failures.append("\(label): \(id) is not there") |
| + continue |
| + } |
| + guard let offset = GlyphInk.offset(in: shot.image, frame: button.frame) else { |
| + failures.append("\(label): \(id) drew no symbol") |
| + continue |
| + } |
| + report.append(String(format: "%@ %@: %+.2f pt", label, id, offset)) |
| + if abs(offset) > 0.5 { failures.append(String(format: "%@ %@: %+.2f pt", label, id, offset)) } |
| + } |
| + } |
| + let bar = launchCall( |
| + "focus", |
| + ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "black", "-CallsMicBesideMenu", "YES"], |
| + dark: true) |
| + XCTAssertTrue(bar.buttons["moreControls"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + measure(bar, ["moreControls", "mute", "leave"], "bar") |
| + bar.terminate() |
| + for dark in [false, true] { |
| + let sheet = launchCall("focus", ["-CallsFixtureControls", "shown", "-CallsFixtureMore", "YES"], dark: dark) |
| + XCTAssertTrue(sheet.buttons["devices"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1.5) |
| + measure( |
| + sheet, ["mute", "camera", "flipCamera", "devices", "micModeQuick", "videoLayout", "Share meeting link", "openChat"], |
| + dark ? "sheet dark" : "sheet light") |
| + sheet.terminate() |
| + } |
| + let attachment = XCTAttachment(string: report.joined(separator: "\n")) |
| + attachment.name = "glyph centroids" |
| + attachment.lifetime = .keepAlways |
| + add(attachment) |
| + try FileManager.default.createDirectory(at: snapshotDirectory, withIntermediateDirectories: true) |
| + try report.joined(separator: "\n").write( |
| + to: snapshotDirectory.appendingPathComponent("iphone-glyph-centroids.txt"), atomically: true, |
| + encoding: .utf8) |
| + XCTAssert(failures.isEmpty, failures.joined(separator: "\n") + "\n" + report.joined(separator: "\n")) |
| + } |
| +} |
| + |
| +/// Where a button's symbol's ink is: the pixels of its frame weighted by |
| +/// how far their colour is from the button's own background, sampled |
| +/// just inside its left edge at mid-height (a round button's glass, a |
| +/// tinted square's fill, a red capsule), as the tests on the Mac do. |
| +enum GlyphInk { |
| + static func offset(in image: UIImage, frame: CGRect) -> CGFloat? { |
| + guard let cg = image.cgImage else { return nil } |
| + let scale = CGFloat(cg.width) / image.size.width |
| + let rect = CGRect( |
| + x: (frame.minX * scale).rounded(), y: (frame.minY * scale).rounded(), |
| + width: (frame.width * scale).rounded(), height: (frame.height * scale).rounded()) |
| + guard let crop = cg.cropping(to: rect) else { return nil } |
| + let w = crop.width, h = crop.height |
| + var px = [UInt8](repeating: 0, count: w * h * 4) |
| + guard |
| + let ctx = CGContext( |
| + data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, |
| + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) |
| + else { return nil } |
| + ctx.draw(crop, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| + // The background: the median of a short column just inside the |
| + // left edge, clear of the symbol and of a rounded corner. |
| + let x = max(2, Int(4 * scale)) |
| + var samples: [(Int, Int, Int)] = [] |
| + for y in (h / 2 - Int(3 * scale))...(h / 2 + Int(3 * scale)) { |
| + let i = (y * w + x) * 4 |
| + samples.append((Int(px[i]), Int(px[i + 1]), Int(px[i + 2]))) |
| + } |
| + samples.sort { $0.0 + $0.1 + $0.2 < $1.0 + $1.1 + $1.2 } |
| + let bg = samples[samples.count / 2] |
| + var total = 0.0, moment = 0.0 |
| + // Only the middle of the button, where the symbol is: a square's |
| + // rounded corners and a circle's edge stay out of it. |
| + let inset = Int(8 * scale) |
| + for y in inset..<(h - inset) { |
| + for x in inset..<(w - inset) { |
| + let i = (y * w + x) * 4 |
| + let d = abs(Int(px[i]) - bg.0) + abs(Int(px[i + 1]) - bg.1) + abs(Int(px[i + 2]) - bg.2) |
| + let weight = Double(max(0, d - 60)) |
| + total += weight |
| + moment += weight * (Double(y) + 0.5) |
| + } |
| + } |
| + guard total > 0 else { return nil } |
| + return CGFloat(moment / total) / scale - frame.height / 2 + (rect.minY / scale - frame.minY) |
| + } |
| } |