oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import AppKit
2import SwiftUI
3import 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.
154struct 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