Skip to content

Commit ea2dd55

Browse files
Merge pull request #202 from superhighfives/claude/analyze-test-coverage-RP2pY
claude/analyze-test-coverage-RP2pY
2 parents 63d77e6 + 0245c1f commit ea2dd55

5 files changed

Lines changed: 344 additions & 0 deletions

File tree

Pika.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
CC2000000000000000000801 /* APCAComplianceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000800 /* APCAComplianceTests.swift */; };
2626
CC2000000000000000000901 /* NotificationNamesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000900 /* NotificationNamesTests.swift */; };
2727
CC2000000000000000000A01 /* CGFloatFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000A00 /* CGFloatFormatTests.swift */; };
28+
CC2000000000000000000B01 /* ColorPairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000B00 /* ColorPairTests.swift */; };
29+
CC2000000000000000000C01 /* ClosestVectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000C00 /* ClosestVectorTests.swift */; };
30+
CC2000000000000000000D01 /* SequenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000D00 /* SequenceTests.swift */; };
31+
CC2000000000000000000E01 /* ExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000E00 /* ExporterTests.swift */; };
2832

2933
220D5E9428DB154300B6285E /* AppModeButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9328DB154300B6285E /* AppModeButtons.swift */; };
3034
220D5E9828DB158400B6285E /* AppModeToggleGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */; };
@@ -206,6 +210,10 @@
206210
CC2000000000000000000800 /* APCAComplianceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APCAComplianceTests.swift; sourceTree = "<group>"; };
207211
CC2000000000000000000900 /* NotificationNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNamesTests.swift; sourceTree = "<group>"; };
208212
CC2000000000000000000A00 /* CGFloatFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGFloatFormatTests.swift; sourceTree = "<group>"; };
213+
CC2000000000000000000B00 /* ColorPairTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPairTests.swift; sourceTree = "<group>"; };
214+
CC2000000000000000000C00 /* ClosestVectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosestVectorTests.swift; sourceTree = "<group>"; };
215+
CC2000000000000000000D00 /* SequenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceTests.swift; sourceTree = "<group>"; };
216+
CC2000000000000000000E00 /* ExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExporterTests.swift; sourceTree = "<group>"; };
209217

210218
220D5E9328DB154300B6285E /* AppModeButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppModeButtons.swift; sourceTree = "<group>"; };
211219
220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppModeToggleGroup.swift; sourceTree = "<group>"; };
@@ -502,6 +510,10 @@
502510
CC2000000000000000000800 /* APCAComplianceTests.swift */,
503511
CC2000000000000000000900 /* NotificationNamesTests.swift */,
504512
CC2000000000000000000A00 /* CGFloatFormatTests.swift */,
513+
CC2000000000000000000B00 /* ColorPairTests.swift */,
514+
CC2000000000000000000C00 /* ClosestVectorTests.swift */,
515+
CC2000000000000000000D00 /* SequenceTests.swift */,
516+
CC2000000000000000000E00 /* ExporterTests.swift */,
505517
);
506518
path = PikaTests;
507519
sourceTree = "<group>";
@@ -1015,6 +1027,10 @@
10151027
CC2000000000000000000801 /* APCAComplianceTests.swift in Sources */,
10161028
CC2000000000000000000901 /* NotificationNamesTests.swift in Sources */,
10171029
CC2000000000000000000A01 /* CGFloatFormatTests.swift in Sources */,
1030+
CC2000000000000000000B01 /* ColorPairTests.swift in Sources */,
1031+
CC2000000000000000000C01 /* ClosestVectorTests.swift in Sources */,
1032+
CC2000000000000000000D01 /* SequenceTests.swift in Sources */,
1033+
CC2000000000000000000E01 /* ExporterTests.swift in Sources */,
10181034
);
10191035
runOnlyForDeploymentPostprocessing = 0;
10201036
};

PikaTests/ClosestVectorTests.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
@testable import Pika
2+
import XCTest
3+
4+
/// Tests the Euclidean-distance nearest-neighbour lookup used to resolve a
5+
/// picked color to its closest named color. Regressions here would show up
6+
/// as wrong color names in the UI and in exported JSON.
7+
final class ClosestVectorTests: XCTestCase {
8+
// MARK: - diff()
9+
10+
func test_diff_identicalVectors_isZero() {
11+
let cv = ClosestVector([[0, 0, 0]])
12+
XCTAssertEqual(cv.diff([128, 64, 32], [128, 64, 32]), 0)
13+
}
14+
15+
func test_diff_isSquaredEuclidean() {
16+
let cv = ClosestVector([[0, 0, 0]])
17+
// (10-0)^2 + (0-0)^2 + (0-0)^2 = 100
18+
XCTAssertEqual(cv.diff([10, 0, 0], [0, 0, 0]), 100)
19+
// (1-4)^2 + (2-6)^2 + (3-15)^2 = 9 + 16 + 144 = 169
20+
XCTAssertEqual(cv.diff([1, 2, 3], [4, 6, 15]), 169)
21+
}
22+
23+
func test_diff_isSymmetric() {
24+
let cv = ClosestVector([[0, 0, 0]])
25+
XCTAssertEqual(cv.diff([12, 34, 56], [78, 90, 12]),
26+
cv.diff([78, 90, 12], [12, 34, 56]))
27+
}
28+
29+
func test_diff_handlesNegativeDifferences() {
30+
let cv = ClosestVector([[0, 0, 0]])
31+
// Subtractions squared — sign of the difference must not matter.
32+
XCTAssertEqual(cv.diff([0, 0, 0], [10, 10, 10]),
33+
cv.diff([10, 10, 10], [0, 0, 0]))
34+
}
35+
36+
// MARK: - compare()
37+
38+
func test_compare_exactMatch_returnsThatIndex() {
39+
let cv = ClosestVector([[255, 0, 0], [0, 255, 0], [0, 0, 255]])
40+
let green = NSColor(red: 0, green: 1, blue: 0, alpha: 1).usingColorSpace(.sRGB)!
41+
XCTAssertEqual(cv.compare(green), 1)
42+
}
43+
44+
func test_compare_returnsNearestNeighbour() {
45+
// Pure red is closer to [250, 5, 5] than to either green or blue.
46+
let cv = ClosestVector([[0, 255, 0], [250, 5, 5], [0, 0, 255]])
47+
let red = NSColor(red: 1, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)!
48+
XCTAssertEqual(cv.compare(red), 1)
49+
}
50+
51+
func test_compare_tie_returnsFirstIndexEncountered() {
52+
// Two entries equidistant from pure black — the loop uses strict `<`,
53+
// so the first entry inserted wins.
54+
let cv = ClosestVector([[10, 0, 0], [0, 10, 0]])
55+
let black = NSColor(red: 0, green: 0, blue: 0, alpha: 1).usingColorSpace(.sRGB)!
56+
XCTAssertEqual(cv.compare(black), 0)
57+
}
58+
59+
func test_compare_singleEntryList_alwaysReturnsZero() {
60+
let cv = ClosestVector([[128, 128, 128]])
61+
let white = NSColor(red: 1, green: 1, blue: 1, alpha: 1).usingColorSpace(.sRGB)!
62+
XCTAssertEqual(cv.compare(white), 0)
63+
}
64+
65+
func test_compare_normalizesColorToSRGB() {
66+
// A Display P3 red should still resolve to the sRGB-red bucket after
67+
// the internal `usingColorSpace(.sRGB)` normalisation.
68+
let cv = ClosestVector([[0, 0, 0], [255, 0, 0], [0, 255, 0]])
69+
let p3Red = NSColor(colorSpace: .displayP3, components: [1.0, 0.0, 0.0, 1.0], count: 4)
70+
XCTAssertEqual(cv.compare(p3Red), 1)
71+
}
72+
}

PikaTests/ColorPairTests.swift

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import Defaults
2+
@testable import Pika
3+
import XCTest
4+
5+
/// Tests the hex-to-NSColor conversion and round-trip behaviour of ColorPair.
6+
/// ColorPair is the on-disk representation of a swatch in the auto-history
7+
/// and saved palettes, so drift here would corrupt user-visible history.
8+
final class ColorPairTests: XCTestCase {
9+
override func setUp() {
10+
super.setUp()
11+
Defaults[.colorSpace] = .sRGB
12+
}
13+
14+
// MARK: - Construction and identity
15+
16+
func test_equality_ignoresIdAndDate() {
17+
let a = ColorPair(id: UUID(), foregroundHex: "#ff0000", backgroundHex: "#00ff00", date: Date())
18+
let b = ColorPair(id: UUID(), foregroundHex: "#ff0000", backgroundHex: "#00ff00",
19+
date: Date(timeIntervalSince1970: 0))
20+
XCTAssertEqual(a, b)
21+
}
22+
23+
func test_equality_distinguishesByHex() {
24+
let a = ColorPair(id: UUID(), foregroundHex: "#ff0000", backgroundHex: "#00ff00", date: Date())
25+
let b = ColorPair(id: UUID(), foregroundHex: "#ff0001", backgroundHex: "#00ff00", date: Date())
26+
XCTAssertNotEqual(a, b)
27+
}
28+
29+
// MARK: - foregroundColor / backgroundColor
30+
31+
func test_foregroundColor_parsesHashedHex() {
32+
let pair = ColorPair(id: UUID(), foregroundHex: "#ff0000", backgroundHex: "#000000", date: Date())
33+
let color = pair.foregroundColor.usingColorSpace(.sRGB)!
34+
XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001)
35+
XCTAssertEqual(color.greenComponent, 0.0, accuracy: 0.001)
36+
XCTAssertEqual(color.blueComponent, 0.0, accuracy: 0.001)
37+
}
38+
39+
func test_backgroundColor_parsesUnhashedHex() {
40+
let pair = ColorPair(id: UUID(), foregroundHex: "#000000", backgroundHex: "0000ff", date: Date())
41+
let color = pair.backgroundColor.usingColorSpace(.sRGB)!
42+
XCTAssertEqual(color.redComponent, 0.0, accuracy: 0.001)
43+
XCTAssertEqual(color.greenComponent, 0.0, accuracy: 0.001)
44+
XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
45+
}
46+
47+
func test_hexRoundTrip_noChannelDrift() {
48+
// Stored hex should reconstruct to a color whose re-serialized hex matches.
49+
let original = "#3a7bd5"
50+
let pair = ColorPair(id: UUID(), foregroundHex: original, backgroundHex: "#000000", date: Date())
51+
XCTAssertEqual(pair.foregroundColor.toHexString(style: .css), original)
52+
}
53+
54+
// MARK: - Malformed input
55+
56+
func test_invalidLength_fallsBackToBlack() {
57+
let pair = ColorPair(id: UUID(), foregroundHex: "#abc", backgroundHex: "#000000", date: Date())
58+
let color = pair.foregroundColor.usingColorSpace(.sRGB)!
59+
XCTAssertEqual(color.redComponent, 0.0, accuracy: 0.001)
60+
XCTAssertEqual(color.greenComponent, 0.0, accuracy: 0.001)
61+
XCTAssertEqual(color.blueComponent, 0.0, accuracy: 0.001)
62+
}
63+
64+
func test_emptyHex_fallsBackToBlack() {
65+
let pair = ColorPair(id: UUID(), foregroundHex: "", backgroundHex: "#000000", date: Date())
66+
let color = pair.foregroundColor.usingColorSpace(.sRGB)!
67+
XCTAssertEqual(color.redComponent, 0.0, accuracy: 0.001)
68+
XCTAssertEqual(color.greenComponent, 0.0, accuracy: 0.001)
69+
XCTAssertEqual(color.blueComponent, 0.0, accuracy: 0.001)
70+
}
71+
72+
func test_nonHexCharacters_fallBackToBlack() {
73+
let pair = ColorPair(id: UUID(), foregroundHex: "#zzzzzz", backgroundHex: "#000000", date: Date())
74+
let color = pair.foregroundColor.usingColorSpace(.sRGB)!
75+
XCTAssertEqual(color.redComponent, 0.0, accuracy: 0.001)
76+
XCTAssertEqual(color.greenComponent, 0.0, accuracy: 0.001)
77+
XCTAssertEqual(color.blueComponent, 0.0, accuracy: 0.001)
78+
}
79+
80+
// MARK: - Codable
81+
82+
func test_isCodable_roundTripsThroughJSON() throws {
83+
let original = ColorPair(
84+
id: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
85+
foregroundHex: "#123456",
86+
backgroundHex: "#abcdef",
87+
date: Date(timeIntervalSince1970: 1_700_000_000)
88+
)
89+
let data = try JSONEncoder().encode(original)
90+
let decoded = try JSONDecoder().decode(ColorPair.self, from: data)
91+
XCTAssertEqual(decoded.id, original.id)
92+
XCTAssertEqual(decoded.foregroundHex, original.foregroundHex)
93+
XCTAssertEqual(decoded.backgroundHex, original.backgroundHex)
94+
XCTAssertEqual(decoded.date.timeIntervalSince1970, original.date.timeIntervalSince1970, accuracy: 0.001)
95+
}
96+
97+
// MARK: - Palette
98+
99+
func test_palette_isAutoHistory_whenNameIsNil() {
100+
let palette = Palette(id: UUID(), name: nil, pairs: [], createdAt: Date())
101+
XCTAssertTrue(palette.isAutoHistory)
102+
}
103+
104+
func test_palette_isAutoHistory_falseWhenNamed() {
105+
let palette = Palette(id: UUID(), name: "Brand", pairs: [], createdAt: Date())
106+
XCTAssertFalse(palette.isAutoHistory)
107+
}
108+
109+
func test_maxHistory_is20() {
110+
// Changing this bound without auditing the history/undo stack truncation
111+
// logic in Eyedroppers would silently drop user data.
112+
XCTAssertEqual(ColorPair.maxHistory, 20)
113+
}
114+
}

PikaTests/ExporterTests.swift

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
@testable import Pika
2+
import XCTest
3+
4+
/// Tests the palette-to-JSON export contract. Users rely on this format when
5+
/// exporting swatches, so the shape and field names are a public contract.
6+
///
7+
/// `Exporter.toText` and `Exporter.toJSON` take `Eyedropper` values whose
8+
/// initialiser force-unwraps `loadColors()`, which reads `ColorNames.json`
9+
/// from `Bundle.main`. That bundle is not populated in the XCTest host, so
10+
/// those paths are out of reach from a unit test without an app-bundle
11+
/// fixture. Coverage for them belongs in an integration/UI test target.
12+
final class ExporterTests: XCTestCase {
13+
private func makePair(fg: String, bg: String, date: Date) -> ColorPair {
14+
ColorPair(id: UUID(), foregroundHex: fg, backgroundHex: bg, date: date)
15+
}
16+
17+
private func decode(_ json: String) throws -> [String: Any] {
18+
let data = try XCTUnwrap(json.data(using: .utf8))
19+
let obj = try JSONSerialization.jsonObject(with: data)
20+
return try XCTUnwrap(obj as? [String: Any])
21+
}
22+
23+
// MARK: - paletteToJSON: named palette
24+
25+
func test_paletteToJSON_namedPalette_usesProvidedName() throws {
26+
let pair = makePair(fg: "#ff0000", bg: "#00ff00", date: Date(timeIntervalSince1970: 0))
27+
let json = Exporter.paletteToJSON(pairs: [pair], name: "Brand")
28+
let parsed = try decode(json)
29+
XCTAssertEqual(parsed["name"] as? String, "Brand")
30+
}
31+
32+
func test_paletteToJSON_namedPalette_omitsDateField() throws {
33+
// Named palettes intentionally strip creation dates — users shouldn't
34+
// see internal timestamps in shareable exports.
35+
let pair = makePair(fg: "#ff0000", bg: "#00ff00", date: Date(timeIntervalSince1970: 1_700_000_000))
36+
let json = Exporter.paletteToJSON(pairs: [pair], name: "Brand")
37+
let parsed = try decode(json)
38+
let colors = try XCTUnwrap(parsed["colors"] as? [[String: String]])
39+
XCTAssertEqual(colors.count, 1)
40+
XCTAssertNil(colors[0]["date"])
41+
XCTAssertEqual(colors[0]["foreground"], "#ff0000")
42+
XCTAssertEqual(colors[0]["background"], "#00ff00")
43+
}
44+
45+
// MARK: - paletteToJSON: auto-history (nil name)
46+
47+
func test_paletteToJSON_nilName_fallsBackToColorHistoryLabel() throws {
48+
let pair = makePair(fg: "#ff0000", bg: "#00ff00", date: Date(timeIntervalSince1970: 0))
49+
let json = Exporter.paletteToJSON(pairs: [pair], name: nil)
50+
let parsed = try decode(json)
51+
XCTAssertEqual(parsed["name"] as? String, "Color History")
52+
}
53+
54+
func test_paletteToJSON_nilName_includesISO8601Dates() throws {
55+
let date = Date(timeIntervalSince1970: 1_700_000_000)
56+
let pair = makePair(fg: "#123456", bg: "#abcdef", date: date)
57+
let json = Exporter.paletteToJSON(pairs: [pair], name: nil)
58+
let parsed = try decode(json)
59+
let colors = try XCTUnwrap(parsed["colors"] as? [[String: String]])
60+
XCTAssertEqual(colors.count, 1)
61+
62+
let dateString = try XCTUnwrap(colors[0]["date"])
63+
let parsedDate = try XCTUnwrap(ISO8601DateFormatter().date(from: dateString))
64+
XCTAssertEqual(parsedDate.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 1.0)
65+
}
66+
67+
// MARK: - paletteToJSON: ordering and shape
68+
69+
func test_paletteToJSON_preservesOrderOfPairs() throws {
70+
let pairs = [
71+
makePair(fg: "#111111", bg: "#222222", date: Date(timeIntervalSince1970: 0)),
72+
makePair(fg: "#333333", bg: "#444444", date: Date(timeIntervalSince1970: 1)),
73+
makePair(fg: "#555555", bg: "#666666", date: Date(timeIntervalSince1970: 2)),
74+
]
75+
let json = Exporter.paletteToJSON(pairs: pairs, name: "Ordered")
76+
let parsed = try decode(json)
77+
let colors = try XCTUnwrap(parsed["colors"] as? [[String: String]])
78+
XCTAssertEqual(colors.map { $0["foreground"] }, ["#111111", "#333333", "#555555"])
79+
XCTAssertEqual(colors.map { $0["background"] }, ["#222222", "#444444", "#666666"])
80+
}
81+
82+
func test_paletteToJSON_emptyPairs_producesEmptyColorsArray() throws {
83+
let json = Exporter.paletteToJSON(pairs: [], name: "Empty")
84+
let parsed = try decode(json)
85+
let colors = try XCTUnwrap(parsed["colors"] as? [[String: String]])
86+
XCTAssertEqual(parsed["name"] as? String, "Empty")
87+
XCTAssertTrue(colors.isEmpty)
88+
}
89+
90+
func test_paletteToJSON_outputIsPrettyPrintedAndSorted() throws {
91+
// The implementation asks JSONSerialization for both `.prettyPrinted`
92+
// and `.sortedKeys`. Preserve that contract so CLI diffs of exported
93+
// palettes stay stable.
94+
let pair = makePair(fg: "#ff0000", bg: "#00ff00", date: Date(timeIntervalSince1970: 0))
95+
let json = Exporter.paletteToJSON(pairs: [pair], name: "Brand")
96+
XCTAssertTrue(json.contains("\n"), "Output should be pretty-printed across multiple lines")
97+
// `colors` sorts alphabetically before `name` when sortedKeys is on.
98+
let colorsRange = try XCTUnwrap(json.range(of: "\"colors\""))
99+
let nameRange = try XCTUnwrap(json.range(of: "\"name\""))
100+
XCTAssertLessThan(colorsRange.lowerBound, nameRange.lowerBound,
101+
"Keys should be alphabetically sorted")
102+
}
103+
}

PikaTests/SequenceTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
@testable import Pika
2+
import XCTest
3+
4+
/// Tests the generic `unique()` helper on Sequence. Used by history and
5+
/// palette code to de-duplicate color entries while preserving order.
6+
final class SequenceTests: XCTestCase {
7+
func test_unique_emptySequence_returnsEmpty() {
8+
let result: [Int] = [].unique()
9+
XCTAssertEqual(result, [])
10+
}
11+
12+
func test_unique_allDistinct_preservesOrderAndCount() {
13+
XCTAssertEqual([1, 2, 3, 4].unique(), [1, 2, 3, 4])
14+
}
15+
16+
func test_unique_removesDuplicates_preservingFirstOccurrence() {
17+
XCTAssertEqual([1, 2, 1, 3, 2, 4].unique(), [1, 2, 3, 4])
18+
}
19+
20+
func test_unique_allIdentical_returnsSingleElement() {
21+
XCTAssertEqual([7, 7, 7, 7].unique(), [7])
22+
}
23+
24+
func test_unique_onStrings() {
25+
XCTAssertEqual(["a", "b", "a", "c", "b"].unique(), ["a", "b", "c"])
26+
}
27+
28+
func test_unique_onHexLikeStrings_caseSensitive() {
29+
// #FF0000 and #ff0000 are different hashables — de-duplication must
30+
// respect case so we don't silently merge distinct entries.
31+
XCTAssertEqual(["#ff0000", "#FF0000", "#ff0000"].unique(), ["#ff0000", "#FF0000"])
32+
}
33+
34+
func test_unique_doesNotMutateSource() {
35+
let source = [1, 1, 2, 2, 3]
36+
_ = source.unique()
37+
XCTAssertEqual(source, [1, 1, 2, 2, 3])
38+
}
39+
}

0 commit comments

Comments
 (0)