| 1 | import AppKit |
| 2 | import SwiftUI |
| 3 | import XCTest |
| 4 | |
| 5 | @testable import CallsKit |
| 6 | |
| 7 | /// Paul, on the 0.4.0 bar: "Are these vertically aligned? Particularly |
| 8 | /// the share icon." They were not: SF Symbols sit on a text baseline, and |
| 9 | /// the share symbol's ink was 1.25 pt below its neighbours'. These render |
| 10 | /// each control symbol as the controls draw it and measure where its ink |
| 11 | /// is, so a table entry that drifts, a symbol added without one, or a |
| 12 | /// style that puts the symbol back on a baseline fails here. |
| 13 | @MainActor final class CallGlyphTests: XCTestCase { |
| 14 | /// Half a point either way of the centre line. |
| 15 | static let tolerance: CGFloat = 0.5 |
| 16 | |
| 17 | /// Every size the Mac's controls draw at: `bar`, `round` and |
| 18 | /// `action`, drawn by AppKit as the screen draws them. The iPhone's |
| 19 | /// sizes are drawn by UIKit at 3×, which this Mac cannot do; the |
| 20 | /// iPhone's call-screen UI tests measure those on the simulator's own |
| 21 | /// screenshots, against the same table. |
| 22 | static let sizes: [(String, CallGlyphMetrics)] = [ |
| 23 | ("bar", .bar), ("round", .round), ("action", .action), |
| 24 | ] |
| 25 | |
| 26 | /// Every symbol in the table, at every size and weight the controls |
| 27 | /// use on either platform: its ink's centroid on the square's centre. |
| 28 | func testEverySymbolsInkIsOnTheCentreLine() throws { |
| 29 | var failures: [String] = [] |
| 30 | var worst: [String: CGFloat] = [:] |
| 31 | for (name, metrics) in Self.sizes { |
| 32 | for symbol in CallGlyph.opticalOffsets.keys.sorted() { |
| 33 | let ink = try XCTUnwrap( |
| 34 | Ink.measure(CallGlyph(symbol).environment(\.callGlyphMetrics, metrics)), |
| 35 | "\(symbol) drew nothing at \(name): not a symbol this system has") |
| 36 | XCTAssertEqual(ink.size.width, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square") |
| 37 | XCTAssertEqual(ink.size.height, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square") |
| 38 | if abs(ink.centroidOffset) > abs(worst[symbol] ?? 0) { worst[symbol] = ink.centroidOffset } |
| 39 | if abs(ink.centroidOffset) > Self.tolerance { |
| 40 | failures.append(String(format: "%@ at %@: %+.2f pt", symbol, name, ink.centroidOffset)) |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | for (symbol, offset) in worst.sorted(by: { $0.key < $1.key }) { |
| 45 | print(String(format: "CallGlyph worst %@: %+.2f pt", symbol, offset)) |
| 46 | } |
| 47 | XCTAssert(failures.isEmpty, "ink off the centre line:\n" + failures.joined(separator: "\n") + "\n" + Self.table()) |
| 48 | } |
| 49 | |
| 50 | /// The bar's own button, as the full-window call draws it: the style |
| 51 | /// puts the symbol in the middle of the button, not on a baseline. |
| 52 | func testTheBarButtonCentresItsSymbol() throws { |
| 53 | for symbol in ["mic.fill", "square.and.arrow.up", "rectangle.bottomthird.inset.filled", "phone.down.fill"] { |
| 54 | for tone in [CallOverlayButtonStyle.Tone.normal, .leave] { |
| 55 | let button = Button("Control", glyph: symbol) {} |
| 56 | .buttonStyle(CallActionStyle(overlay: tone)) |
| 57 | .foregroundStyle(.white) |
| 58 | // White on the red of Leave: count only what is whiter than it. |
| 59 | let ink = try XCTUnwrap(Ink.measure(button, whiteOnly: tone == .leave), symbol) |
| 60 | XCTAssertEqual(ink.size.height, 44, accuracy: 0.01) |
| 61 | XCTAssertLessThanOrEqual( |
| 62 | abs(ink.centroidOffset), Self.tolerance, |
| 63 | String(format: "%@ (%@) is %+.2f pt off the button's centre", symbol, "\(tone)", ink.centroidOffset)) |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// Without the table the share symbol is where Paul found it, low: so |
| 69 | /// the test above is measuring something the table fixes. |
| 70 | func testTheShareSymbolIsLowWithoutItsCorrection() throws { |
| 71 | var raw = CallGlyph("square.and.arrow.up") |
| 72 | raw.corrected = false |
| 73 | let ink = try XCTUnwrap(Ink.measure(raw.environment(\.callGlyphMetrics, .bar))) |
| 74 | XCTAssertGreaterThan(ink.centroidOffset, 0.75, "square.and.arrow.up is not low uncorrected any more") |
| 75 | XCTAssertLessThan(CallGlyph.opticalOffset("square.and.arrow.up"), 0, "and the table lifts it") |
| 76 | } |
| 77 | |
| 78 | /// A fresh table from the symbols as this system draws them, over all |
| 79 | /// the sizes: the middle of the range each symbol's |
| 80 | /// correction can take, which is what to paste into |
| 81 | /// `CallGlyph.opticalOffsets`. |
| 82 | static func table() -> String { |
| 83 | var lines = ["measured offsets (ems, negative up):"] |
| 84 | for symbol in CallGlyph.opticalOffsets.keys.sorted() { |
| 85 | var ems: [CGFloat] = [] |
| 86 | for (_, metrics) in sizes { |
| 87 | var raw = CallGlyph(symbol) |
| 88 | raw.corrected = false |
| 89 | if let ink = Ink.measure(raw.environment(\.callGlyphMetrics, metrics)) { |
| 90 | ems.append(-ink.centroidOffset / metrics.points) |
| 91 | } |
| 92 | } |
| 93 | let low = ems.min() ?? 0, high = ems.max() ?? 0 |
| 94 | lines.append(String(format: " \"%@\": %.3f, // %.3f to %.3f", symbol, (low + high) / 2, low, high)) |
| 95 | } |
| 96 | return lines.joined(separator: "\n") |
| 97 | } |
| 98 | |
| 99 | /// With CALLS_GLYPH_TUNE=1, the table to use. The pixel grid makes |
| 100 | /// each symbol's worst offset a step function of its correction, so |
| 101 | /// the smallest correction that wins can sit on the edge of a step, |
| 102 | /// where a thousandth of an em flips it. This takes the middle of the |
| 103 | /// widest run of corrections (in 2/1000 em steps) whose worst size is |
| 104 | /// within 0.3 pt of the centre line, or, failing that, within 0.15 pt |
| 105 | /// of the best any correction does. |
| 106 | func testTuneTheTable() throws { |
| 107 | try XCTSkipUnless(ProcessInfo.processInfo.environment["CALLS_GLYPH_TUNE"] == "1") |
| 108 | var lines = ["tuned offsets (ems, negative up):"] |
| 109 | for symbol in CallGlyph.opticalOffsets.keys.sorted() { |
| 110 | let steps = Array(stride(from: -140, through: 100, by: 2)) |
| 111 | let worsts: [CGFloat] = steps.map { step in |
| 112 | var worst: CGFloat = 0 |
| 113 | for (_, metrics) in Self.sizes { |
| 114 | var glyph = CallGlyph(symbol) |
| 115 | glyph.trial = CGFloat(step) / 1000 |
| 116 | if let ink = Ink.measure(glyph.environment(\.callGlyphMetrics, metrics)) { |
| 117 | worst = max(worst, abs(ink.centroidOffset)) |
| 118 | } else { |
| 119 | worst = .infinity |
| 120 | } |
| 121 | } |
| 122 | return worst |
| 123 | } |
| 124 | let threshold = max(0.3, (worsts.min() ?? 0) + 0.15) |
| 125 | var bestRun = (start: 0, length: 0), start = -1 |
| 126 | for (i, worst) in worsts.enumerated() { |
| 127 | if worst <= threshold { |
| 128 | if start < 0 { start = i } |
| 129 | if i - start + 1 > bestRun.length { bestRun = (start, i - start + 1) } |
| 130 | } else { |
| 131 | start = -1 |
| 132 | } |
| 133 | } |
| 134 | let middle = bestRun.start + bestRun.length / 2 |
| 135 | lines.append(String( |
| 136 | format: " \"%@\": %.3f, // worst %.2f pt; %.3f to %.3f all within %.2f", symbol, |
| 137 | CGFloat(steps[middle]) / 1000, worsts[middle], CGFloat(steps[bestRun.start]) / 1000, |
| 138 | CGFloat(steps[bestRun.start + bestRun.length - 1]) / 1000, threshold)) |
| 139 | } |
| 140 | print(lines.joined(separator: "\n")) |
| 141 | } |
| 142 | |
| 143 | func testPrintTheTable() { |
| 144 | print(Self.table()) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /// Where a view's ink is, as a window draws it: hosted in an AppKit |
| 149 | /// window and drawn by AppKit (`cacheDisplay`), which places a symbol on |
| 150 | /// the same pixels the screen does. An `ImageRenderer` does not: it |
| 151 | /// rounded every half pixel the other way, and put the bar's symbols a |
| 152 | /// pixel higher than the window it was meant to stand for. Weighted by |
| 153 | /// alpha, or, over a coloured fill, by whiteness. |
| 154 | struct Ink { |
| 155 | /// The rendered size, in points. |
| 156 | var size: CGSize |
| 157 | /// The ink's centroid below the view's centre line, in points |
| 158 | /// (negative: above it). |
| 159 | var centroidOffset: CGFloat |
| 160 | |
| 161 | @MainActor static func measure(_ view: some View, whiteOnly: Bool = false) -> Ink? { |
| 162 | let host = NSHostingView(rootView: view.environment(\.colorScheme, .dark)) |
| 163 | let size = host.fittingSize |
| 164 | guard size.width > 0, size.height > 0 else { return nil } |
| 165 | host.frame = NSRect(origin: .zero, size: size) |
| 166 | let window = NSWindow( |
| 167 | contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) |
| 168 | window.isReleasedWhenClosed = false |
| 169 | window.backgroundColor = .clear |
| 170 | window.isOpaque = false |
| 171 | window.contentView = host |
| 172 | host.layoutSubtreeIfNeeded() |
| 173 | defer { window.close() } |
| 174 | guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } |
| 175 | host.cacheDisplay(in: host.bounds, to: rep) |
| 176 | guard let image = rep.cgImage else { return nil } |
| 177 | let w = image.width, h = image.height |
| 178 | var pixels = [UInt8](repeating: 0, count: w * h * 4) |
| 179 | guard |
| 180 | let context = CGContext( |
| 181 | data: &pixels, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, |
| 182 | space: CGColorSpaceCreateDeviceRGB(), |
| 183 | bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) |
| 184 | else { return nil } |
| 185 | context.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| 186 | var total = 0.0, moment = 0.0 |
| 187 | for y in 0..<h { |
| 188 | for x in 0..<w { |
| 189 | let i = (y * w + x) * 4 |
| 190 | let weight: Double |
| 191 | if whiteOnly { |
| 192 | // The least of the three channels: white is high in all of |
| 193 | // them, red is not. |
| 194 | weight = max(0, Double(min(pixels[i], pixels[i + 1], pixels[i + 2])) - 60) |
| 195 | } else { |
| 196 | weight = Double(pixels[i + 3]) |
| 197 | } |
| 198 | total += weight |
| 199 | moment += weight * (Double(y) + 0.5) |
| 200 | } |
| 201 | } |
| 202 | guard total > 0 else { return nil } |
| 203 | // Row 0 of the drawn bitmap is the top of the view. |
| 204 | let scale = Double(h) / Double(size.height) |
| 205 | let centroid = moment / total / scale |
| 206 | return Ink(size: size, centroidOffset: CGFloat(centroid - Double(size.height) / 2)) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | /// A filmstrip tile's name label (0.4.1): on a narrow tile it stays |
| 211 | /// inside the tile, the name truncating and "Muted" giving way to the |
| 212 | /// slashed microphone alone, and on a wide one it says it all. |
| 213 | @MainActor final class TileNameLabelTests: XCTestCase { |
| 214 | private func width(_ label: TileNameLabel, in proposed: CGFloat) -> CGFloat { |
| 215 | let host = NSHostingController(rootView: label) |
| 216 | return host.sizeThatFits(in: CGSize(width: proposed, height: 100)).width |
| 217 | } |
| 218 | |
| 219 | func testANarrowTileTruncatesTheNameAndKeepsTheMark() { |
| 220 | let label = TileNameLabel(label: "Ana García Fernández", muted: true) |
| 221 | let wide = width(label, in: 1000) |
| 222 | let narrow = width(label, in: 100) |
| 223 | XCTAssertLessThanOrEqual(narrow, 100, "the label stays inside a narrow tile") |
| 224 | XCTAssertLessThan(narrow, wide) |
| 225 | // What the mark takes with the word, and without it. |
| 226 | let withWord = wide - width(TileNameLabel(label: "Ana García Fernández", muted: false), in: 1000) |
| 227 | XCTAssertGreaterThan(withWord, 40, "a wide tile shows the word Muted as well as the symbol") |
| 228 | let unmutedNarrow = width(TileNameLabel(label: "Ana García Fernández", muted: false), in: 100) |
| 229 | XCTAssertLessThanOrEqual(unmutedNarrow, 100) |
| 230 | } |
| 231 | |
| 232 | func testAShortNameIsNotTruncated() { |
| 233 | let label = TileNameLabel(label: "Li", muted: true) |
| 234 | XCTAssertEqual(width(label, in: 1000), width(label, in: 160), accuracy: 0.5) |
| 235 | } |
| 236 | } |
| 237 | |