feature/xcodegen-modernize-runner
swift 288 lines 10.5 KB
Raw
1 #if os(macOS)
2 import CoreGraphics
3 import Foundation
4 import ImageIO
5 import XCTest
6
7 @testable import XCRSHostInspect
8
9 /// The fixture is an 8x8 PNG whose top-left 4x4 quadrant is red and whose
10 /// remaining pixels are blue, which is what pins the coordinate conventions:
11 /// rows count down from the top, columns count right from the left.
12 final class HostInspectCommandTests: XCTestCase {
13 private var outputDirectory: URL {
14 FileManager.default.temporaryDirectory
15 .appendingPathComponent("xcrs-host-inspect-tests", isDirectory: true)
16 }
17
18 override func setUpWithError() throws {
19 try super.setUpWithError()
20 try FileManager.default.createDirectory(
21 at: outputDirectory,
22 withIntermediateDirectories: true
23 )
24 }
25
26 func testColumnProbeReadsFromTheTop() throws {
27 let probe = try decode(
28 HostProbeResult.self,
29 from: try invoke(["probe", "--image", try fixture().path, "--column", "1"])
30 )
31
32 XCTAssertEqual(probe.axis, .column)
33 XCTAssertEqual(probe.runs.map(\.color), ["#f80000", "#0000f8"])
34 XCTAssertEqual(probe.runs.map(\.start), [0, 4])
35 XCTAssertEqual(probe.runs.map(\.end), [4, 8])
36 XCTAssertEqual(probe.runs.map(\.width), [4, 4])
37 }
38
39 func testRowProbeReadsFromTheLeft() throws {
40 let probe = try decode(
41 HostProbeResult.self,
42 from: try invoke(["probe", "--image", try fixture().path, "--row", "1"])
43 )
44
45 XCTAssertEqual(probe.axis, .row)
46 XCTAssertEqual(probe.runs.map(\.color), ["#f80000", "#0000f8"])
47 XCTAssertEqual(probe.runs.map(\.start), [0, 4])
48 }
49
50 func testProbeHonoursFromAndTo() throws {
51 let probe = try decode(
52 HostProbeResult.self,
53 from: try invoke(
54 ["probe", "--image", try fixture().path, "--column", "1", "--from", "4", "--to", "8"]
55 )
56 )
57
58 XCTAssertEqual(probe.from, 4)
59 XCTAssertEqual(probe.to, 8)
60 XCTAssertEqual(probe.runs.map(\.color), ["#0000f8"])
61 XCTAssertEqual(probe.runs.map(\.start), [4])
62 XCTAssertEqual(probe.runs.map(\.end), [8])
63 }
64
65 func testCropKeepsTheTopLeftOrigin() throws {
66 let source = try fixture().path
67
68 let topLeft = try temporaryFile(named: "crop-top-left.png")
69 let result = try decode(
70 HostCropResult.self,
71 from: try invoke(
72 ["crop", "--image", source, "--rect", "0,0,4,4", "--out", topLeft.path]
73 )
74 )
75 XCTAssertEqual(result.pixelWidth, 4)
76 XCTAssertEqual(result.pixelHeight, 4)
77 XCTAssertEqual(try Set(pixelColors(of: topLeft)), ["#ff0000"])
78
79 let bottomRight = try temporaryFile(named: "crop-bottom-right.png")
80 _ = try invoke(
81 ["crop", "--image", source, "--rect", "4,4,4,4", "--out", bottomRight.path]
82 )
83 XCTAssertEqual(try Set(pixelColors(of: bottomRight)), ["#0000ff"])
84 }
85
86 func testScaleConvertsUnitsToPixels() throws {
87 // At --scale 4 the 8x8 fixture is 2x2 units, so the quadrant boundary
88 // falls on the unit boundary at 1.
89 let probe = try decode(
90 HostProbeResult.self,
91 from: try invoke(
92 ["probe", "--image", try fixture().path, "--column", "0.5", "--scale", "4"]
93 )
94 )
95
96 XCTAssertEqual(probe.image.pixelWidth, 8)
97 XCTAssertEqual(probe.image.width, 2)
98 XCTAssertEqual(probe.image.height, 2)
99 XCTAssertEqual(probe.position, 0.5)
100 XCTAssertEqual(probe.runs.map(\.start), [0, 1])
101 XCTAssertEqual(probe.runs.map(\.end), [1, 2])
102 XCTAssertEqual(probe.runs.map(\.color), ["#f80000", "#0000f8"])
103 }
104
105 func testScaleRejectsZero() {
106 let result = HostInspectCommand.invoke(
107 arguments: ["probe", "--image", "x.png", "--column", "1", "--scale", "0"]
108 )
109 XCTAssertEqual(result.exitCode, 2)
110 XCTAssertTrue(result.standardError.contains("--scale"))
111 }
112
113 func testProbeNeedsAnAxis() {
114 let result = HostInspectCommand.invoke(arguments: ["probe", "--image", "x.png"])
115 XCTAssertEqual(result.exitCode, 2)
116 XCTAssertEqual(result.standardOutput, "")
117 XCTAssertTrue(result.standardError.contains("--row or --column"))
118 XCTAssertTrue(result.standardError.contains("xcrs-host-inspect"), "expected the usage text")
119 }
120
121 func testProbeRejectsBothAxes() {
122 let result = HostInspectCommand.invoke(
123 arguments: ["probe", "--image", "x.png", "--row", "1", "--column", "1"]
124 )
125 XCTAssertEqual(result.exitCode, 2)
126 XCTAssertTrue(result.standardError.contains("either --row or --column"))
127 }
128
129 func testMissingImageIsAUsageError() {
130 let result = HostInspectCommand.invoke(arguments: ["ocr"])
131 XCTAssertEqual(result.exitCode, 2)
132 XCTAssertTrue(result.standardError.contains("--image is required"))
133 }
134
135 func testBadRectIsAUsageError() {
136 let result = HostInspectCommand.invoke(
137 arguments: ["crop", "--image", "x.png", "--rect", "1,2,3", "--out", "o.png"]
138 )
139 XCTAssertEqual(result.exitCode, 2)
140 XCTAssertTrue(result.standardError.contains("--rect expects x,y,w,h"))
141 }
142
143 func testUnknownOptionIsAUsageError() {
144 let result = HostInspectCommand.invoke(arguments: ["windows", "--nope"])
145 XCTAssertEqual(result.exitCode, 2)
146 XCTAssertTrue(result.standardError.contains("unknown option --nope"))
147 }
148
149 func testUnknownCommandIsAUsageError() {
150 let result = HostInspectCommand.invoke(arguments: ["snapshot"])
151 XCTAssertEqual(result.exitCode, 2)
152 XCTAssertTrue(result.standardError.contains("unknown command snapshot"))
153 }
154
155 func testHelpPrintsUsage() {
156 let result = HostInspectCommand.invoke(arguments: ["help"])
157 XCTAssertEqual(result.exitCode, 0)
158 XCTAssertTrue(result.standardOutput.contains("xcrs-host-inspect"))
159 XCTAssertTrue(result.standardError.isEmpty)
160 }
161
162 func testUnreadableImageFails() {
163 let result = HostInspectCommand.invoke(
164 arguments: [
165 "crop", "--image", "/nonexistent/missing.png", "--rect", "0,0,1,1", "--out",
166 temporaryFile(named: "unused.png").path,
167 ]
168 )
169 XCTAssertEqual(result.exitCode, 1)
170 XCTAssertTrue(result.standardError.contains("cannot open an image"))
171 }
172
173 func testCropOutsideTheImageFails() throws {
174 let result = HostInspectCommand.invoke(
175 arguments: [
176 "crop", "--image", try fixture().path, "--rect", "6,6,4,4", "--out",
177 temporaryFile(named: "outside.png").path,
178 ]
179 )
180 XCTAssertEqual(result.exitCode, 1)
181 XCTAssertTrue(result.standardError.contains("outside the 8x8 px image"))
182 }
183
184 func testWindowsReturnsAWindowList() throws {
185 let list = try decode(
186 HostWindowList.self,
187 from: try invoke(["windows"])
188 )
189 XCTAssertTrue(
190 list.windows.allSatisfy { $0.bounds.width >= 0 && $0.bounds.height >= 0 },
191 "window bounds should never be negative"
192 )
193
194 let filtered = try decode(
195 HostWindowList.self,
196 from: try invoke(["windows", "--owner", "no-such-app-2a3f"])
197 )
198 XCTAssertTrue(filtered.windows.isEmpty, "the owner filter should exclude everything")
199 }
200
201 func testQuantizeCollapsesLowOrderBits() {
202 XCTAssertEqual(
203 HostInspect.quantize(red: 0xFF, green: 0x00, blue: 0x00),
204 HostInspect.quantize(red: 0xF8, green: 0x07, blue: 0x07)
205 )
206 XCTAssertEqual(
207 HostInspect.hexColor(HostInspect.quantize(red: 0xFF, green: 0x00, blue: 0x00)),
208 "#f80000"
209 )
210 }
211
212 func testGroupRunsCollapsesNeighbours() {
213 let runs = HostInspect.groupRuns([1, 1, 2, 2, 2, 1])
214 XCTAssertEqual(runs.map { $0.start }, [0, 2, 5])
215 XCTAssertEqual(runs.map { $0.end }, [2, 5, 6])
216 XCTAssertEqual(runs.map { $0.key }, [1, 2, 1])
217 }
218
219 func testInlineOptionValuesAreAccepted() throws {
220 let probe = try decode(
221 HostProbeResult.self,
222 from: try invoke(["probe", "--image=\(try fixture().path)", "--column=1"])
223 )
224 XCTAssertEqual(probe.runs.count, 2)
225 }
226
227 private func invoke(_ arguments: [String]) throws -> String {
228 let result = HostInspectCommand.invoke(arguments: arguments)
229 XCTAssertEqual(result.exitCode, 0, result.standardError)
230 return result.standardOutput
231 }
232
233 private func decode<T: Decodable>(_ type: T.Type, from json: String) throws -> T {
234 try JSONDecoder().decode(type, from: Data(json.utf8))
235 }
236
237 private func fixture() throws -> URL {
238 try XCTUnwrap(
239 Bundle.module.url(
240 forResource: "top-left-red.png",
241 withExtension: nil,
242 subdirectory: "Fixtures"
243 ),
244 "the top-left-red.png fixture is missing from the test bundle"
245 )
246 }
247
248 private func temporaryFile(named name: String) -> URL {
249 outputDirectory.appendingPathComponent(name)
250 }
251
252 /// Reads every pixel of a PNG as raw hex, without quantisation, so the
253 /// assertions above only fail for a real orientation or colour mistake.
254 private func pixelColors(of url: URL) throws -> [String] {
255 let source = try XCTUnwrap(CGImageSourceCreateWithURL(url as CFURL, nil))
256 let image = try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil))
257 var buffer = [UInt8](repeating: 0, count: image.width * image.height * 4)
258 buffer.withUnsafeMutableBytes { raw in
259 guard
260 let context = CGContext(
261 data: raw.baseAddress,
262 width: image.width,
263 height: image.height,
264 bitsPerComponent: 8,
265 bytesPerRow: image.width * 4,
266 space: CGColorSpaceCreateDeviceRGB(),
267 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
268 )
269 else {
270 return
271 }
272 context.draw(
273 image,
274 in: CGRect(x: 0, y: 0, width: image.width, height: image.height)
275 )
276 }
277 return stride(from: 0, to: buffer.count, by: 4).map { index in
278 String(
279 format: "#%02x%02x%02x",
280 buffer[index],
281 buffer[index + 1],
282 buffer[index + 2]
283 )
284 }
285 }
286 }
287
288 #endif