|
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 |