import AppKit import SwiftUI import XCTest @testable import CallsKit /// Paul, on the 0.4.0 bar: "Are these vertically aligned? Particularly /// the share icon." They were not: SF Symbols sit on a text baseline, and /// the share symbol's ink was 1.25 pt below its neighbours'. These render /// each control symbol as the controls draw it and measure where its ink /// is, so a table entry that drifts, a symbol added without one, or a /// style that puts the symbol back on a baseline fails here. @MainActor final class CallGlyphTests: XCTestCase { /// Half a point either way of the centre line. static let tolerance: CGFloat = 0.5 /// Every size the Mac's controls draw at: `bar`, `round` and /// `action`, drawn by AppKit as the screen draws them. The iPhone's /// sizes are drawn by UIKit at 3×, which this Mac cannot do; the /// iPhone's call-screen UI tests measure those on the simulator's own /// screenshots, against the same table. static let sizes: [(String, CallGlyphMetrics)] = [ ("bar", .bar), ("round", .round), ("action", .action), ] /// Every symbol in the table, at every size and weight the controls /// use on either platform: its ink's centroid on the square's centre. func testEverySymbolsInkIsOnTheCentreLine() throws { var failures: [String] = [] var worst: [String: CGFloat] = [:] for (name, metrics) in Self.sizes { for symbol in CallGlyph.opticalOffsets.keys.sorted() { let ink = try XCTUnwrap( Ink.measure(CallGlyph(symbol).environment(\.callGlyphMetrics, metrics)), "\(symbol) drew nothing at \(name): not a symbol this system has") XCTAssertEqual(ink.size.width, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square") XCTAssertEqual(ink.size.height, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square") if abs(ink.centroidOffset) > abs(worst[symbol] ?? 0) { worst[symbol] = ink.centroidOffset } if abs(ink.centroidOffset) > Self.tolerance { failures.append(String(format: "%@ at %@: %+.2f pt", symbol, name, ink.centroidOffset)) } } } for (symbol, offset) in worst.sorted(by: { $0.key < $1.key }) { print(String(format: "CallGlyph worst %@: %+.2f pt", symbol, offset)) } XCTAssert(failures.isEmpty, "ink off the centre line:\n" + failures.joined(separator: "\n") + "\n" + Self.table()) } /// The bar's own button, as the full-window call draws it: the style /// puts the symbol in the middle of the button, not on a baseline. func testTheBarButtonCentresItsSymbol() throws { for symbol in ["mic.fill", "square.and.arrow.up", "rectangle.bottomthird.inset.filled", "phone.down.fill"] { for tone in [CallOverlayButtonStyle.Tone.normal, .leave] { let button = Button("Control", glyph: symbol) {} .buttonStyle(CallActionStyle(overlay: tone)) .foregroundStyle(.white) // White on the red of Leave: count only what is whiter than it. let ink = try XCTUnwrap(Ink.measure(button, whiteOnly: tone == .leave), symbol) XCTAssertEqual(ink.size.height, 44, accuracy: 0.01) XCTAssertLessThanOrEqual( abs(ink.centroidOffset), Self.tolerance, String(format: "%@ (%@) is %+.2f pt off the button's centre", symbol, "\(tone)", ink.centroidOffset)) } } } /// Without the table the share symbol is where Paul found it, low: so /// the test above is measuring something the table fixes. func testTheShareSymbolIsLowWithoutItsCorrection() throws { var raw = CallGlyph("square.and.arrow.up") raw.corrected = false let ink = try XCTUnwrap(Ink.measure(raw.environment(\.callGlyphMetrics, .bar))) XCTAssertGreaterThan(ink.centroidOffset, 0.75, "square.and.arrow.up is not low uncorrected any more") XCTAssertLessThan(CallGlyph.opticalOffset("square.and.arrow.up"), 0, "and the table lifts it") } /// A fresh table from the symbols as this system draws them, over all /// the sizes: the middle of the range each symbol's /// correction can take, which is what to paste into /// `CallGlyph.opticalOffsets`. static func table() -> String { var lines = ["measured offsets (ems, negative up):"] for symbol in CallGlyph.opticalOffsets.keys.sorted() { var ems: [CGFloat] = [] for (_, metrics) in sizes { var raw = CallGlyph(symbol) raw.corrected = false if let ink = Ink.measure(raw.environment(\.callGlyphMetrics, metrics)) { ems.append(-ink.centroidOffset / metrics.points) } } let low = ems.min() ?? 0, high = ems.max() ?? 0 lines.append(String(format: " \"%@\": %.3f, // %.3f to %.3f", symbol, (low + high) / 2, low, high)) } return lines.joined(separator: "\n") } /// With CALLS_GLYPH_TUNE=1, the table to use. The pixel grid makes /// each symbol's worst offset a step function of its correction, so /// the smallest correction that wins can sit on the edge of a step, /// where a thousandth of an em flips it. This takes the middle of the /// widest run of corrections (in 2/1000 em steps) whose worst size is /// within 0.3 pt of the centre line, or, failing that, within 0.15 pt /// of the best any correction does. func testTuneTheTable() throws { try XCTSkipUnless(ProcessInfo.processInfo.environment["CALLS_GLYPH_TUNE"] == "1") var lines = ["tuned offsets (ems, negative up):"] for symbol in CallGlyph.opticalOffsets.keys.sorted() { let steps = Array(stride(from: -140, through: 100, by: 2)) let worsts: [CGFloat] = steps.map { step in var worst: CGFloat = 0 for (_, metrics) in Self.sizes { var glyph = CallGlyph(symbol) glyph.trial = CGFloat(step) / 1000 if let ink = Ink.measure(glyph.environment(\.callGlyphMetrics, metrics)) { worst = max(worst, abs(ink.centroidOffset)) } else { worst = .infinity } } return worst } let threshold = max(0.3, (worsts.min() ?? 0) + 0.15) var bestRun = (start: 0, length: 0), start = -1 for (i, worst) in worsts.enumerated() { if worst <= threshold { if start < 0 { start = i } if i - start + 1 > bestRun.length { bestRun = (start, i - start + 1) } } else { start = -1 } } let middle = bestRun.start + bestRun.length / 2 lines.append(String( format: " \"%@\": %.3f, // worst %.2f pt; %.3f to %.3f all within %.2f", symbol, CGFloat(steps[middle]) / 1000, worsts[middle], CGFloat(steps[bestRun.start]) / 1000, CGFloat(steps[bestRun.start + bestRun.length - 1]) / 1000, threshold)) } print(lines.joined(separator: "\n")) } func testPrintTheTable() { print(Self.table()) } } /// Where a view's ink is, as a window draws it: hosted in an AppKit /// window and drawn by AppKit (`cacheDisplay`), which places a symbol on /// the same pixels the screen does. An `ImageRenderer` does not: it /// rounded every half pixel the other way, and put the bar's symbols a /// pixel higher than the window it was meant to stand for. Weighted by /// alpha, or, over a coloured fill, by whiteness. struct Ink { /// The rendered size, in points. var size: CGSize /// The ink's centroid below the view's centre line, in points /// (negative: above it). var centroidOffset: CGFloat @MainActor static func measure(_ view: some View, whiteOnly: Bool = false) -> Ink? { let host = NSHostingView(rootView: view.environment(\.colorScheme, .dark)) let size = host.fittingSize guard size.width > 0, size.height > 0 else { return nil } host.frame = NSRect(origin: .zero, size: size) let window = NSWindow( contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.backgroundColor = .clear window.isOpaque = false window.contentView = host host.layoutSubtreeIfNeeded() defer { window.close() } guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } host.cacheDisplay(in: host.bounds, to: rep) guard let image = rep.cgImage else { return nil } let w = image.width, h = image.height var pixels = [UInt8](repeating: 0, count: w * h * 4) guard let context = CGContext( data: &pixels, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } context.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) var total = 0.0, moment = 0.0 for y in 0.. 0 else { return nil } // Row 0 of the drawn bitmap is the top of the view. let scale = Double(h) / Double(size.height) let centroid = moment / total / scale return Ink(size: size, centroidOffset: CGFloat(centroid - Double(size.height) / 2)) } } /// A filmstrip tile's name label (0.4.1): on a narrow tile it stays /// inside the tile, the name truncating and "Muted" giving way to the /// slashed microphone alone, and on a wide one it says it all. @MainActor final class TileNameLabelTests: XCTestCase { private func width(_ label: TileNameLabel, in proposed: CGFloat) -> CGFloat { let host = NSHostingController(rootView: label) return host.sizeThatFits(in: CGSize(width: proposed, height: 100)).width } func testANarrowTileTruncatesTheNameAndKeepsTheMark() { let label = TileNameLabel(label: "Ana García Fernández", muted: true) let wide = width(label, in: 1000) let narrow = width(label, in: 100) XCTAssertLessThanOrEqual(narrow, 100, "the label stays inside a narrow tile") XCTAssertLessThan(narrow, wide) // What the mark takes with the word, and without it. let withWord = wide - width(TileNameLabel(label: "Ana García Fernández", muted: false), in: 1000) XCTAssertGreaterThan(withWord, 40, "a wide tile shows the word Muted as well as the symbol") let unmutedNarrow = width(TileNameLabel(label: "Ana García Fernández", muted: false), in: 100) XCTAssertLessThanOrEqual(unmutedNarrow, 100) } func testAShortNameIsNotTruncated() { let label = TileNameLabel(label: "Li", muted: true) XCTAssertEqual(width(label, in: 1000), width(label, in: 160), accuracy: 0.5) } }