diff --git a/Package.resolved b/Package.resolved index 24b4f86..c36ddcf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "46989693916f56d1186bd59ac15124caef896560", - "version" : "1.3.1" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" } }, { diff --git a/Package.swift b/Package.swift index 06f553e..6b36696 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .macOS(.v13), ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), .package(url: "https://github.com/artem-y/swifty-test-assertions.git", from: "0.1.1"), ], targets: [ diff --git a/README.md b/README.md index 76d460b..a2204b8 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,38 @@ A tool that finds Xcode color assets by their hex codes. The idea behind this to ### ⚠️Disclaimer: For now, the tool only supports searching for exact values of color components as ints, floats or hexadecimals, without conversion between settings like content type (sRGB, Display P3, Gray Gamma 2.2 etc.), and ignoring some other settings like Gamut etc. ## Usage -The tool can be used like this from terminal: +By default, the tool can be used from the terminal to search for matches to a given color code: ``` hexcode #ffa500 ``` ...where `#ffa500` is a hex color code, with or without `#`, case-insensitive. -This way `hexcode` will recursively search for the color assets matching the hex rgb value, starting from current directory. The output will be one or more matching color set names, or a message notifying that it haven't found an asset with the given color. The command also has some very simple error handling and might exit with error. +When used this way, `hexcode` will recursively search for the color assets matching the hex rgb value, starting from the current directory. The output will be one or more matching color set names, or a message in case it haven't found an asset with the given color. Color names include path to their color asset, relative to the project. The command also has some very simple error handling and might exit with error. More arguments and options will be added in the future with new features, they can be found using the `--help` flag. -#### Examples +#### Default usage examples Color found: -hexcode_usage_color_found +hexcode_usage_color_found_1 Color not found: -hexcode_usage_no_such_color +hexcode_usage_no_such_color + +### Find Duplicates +Hexcode can also check a project or a directory for duplicated color assets. +```zsh +hexcode find-duplicates +``` +Output example when there are duplicates: +``` +#24658F MyProject/Assets.xcassets/AccentColor +#24658F MyProject/Colors.xcassets/defaultAccent +-- +#999999 MyProject/Assets.xcassets/appColor/gray +#999999 MyProject/Colors.xcassets/neutralGray +``` +Output when duplicates not found: +``` +No duplicates found +``` ## Installation 1. Clone the repository to your machine diff --git a/Sources/hexcode/Hexcode.swift b/Sources/hexcode/Commands/FindColor.swift similarity index 64% rename from Sources/hexcode/Hexcode.swift rename to Sources/hexcode/Commands/FindColor.swift index 7189d36..2268cfc 100644 --- a/Sources/hexcode/Hexcode.swift +++ b/Sources/hexcode/Commands/FindColor.swift @@ -1,15 +1,13 @@ import ArgumentParser -@main -struct Hexcode: AsyncParsableCommand { +struct FindColor: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "hexcode", + commandName: "find-color", abstract: """ - hexcode is a tool that finds Xcode color assets \ + Default subcommand that finds Xcode color assets \ by their hexadecimal codes. - """, - version: "hexcode 0.1.1" + """ ) @Argument @@ -29,6 +27,6 @@ struct Hexcode: AsyncParsableCommand { } func run() async throws { - try await HexcodeApp().run(colorHex: colorHex, in: directory) + try await HexcodeApp().runFindColor(colorHex: colorHex, in: directory) } } diff --git a/Sources/hexcode/Commands/FindDuplicates.swift b/Sources/hexcode/Commands/FindDuplicates.swift new file mode 100644 index 0000000..5086a18 --- /dev/null +++ b/Sources/hexcode/Commands/FindDuplicates.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct FindDuplicates: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "find-duplicates", + abstract: "Finds duplicate Xcode color assets." + ) + + @Option + var directory: String? + + func run() async throws { + try await HexcodeApp().runFindDuplicates(in: directory) + } +} diff --git a/Sources/hexcode/Commands/Hexcode.swift b/Sources/hexcode/Commands/Hexcode.swift new file mode 100644 index 0000000..b1e8f9d --- /dev/null +++ b/Sources/hexcode/Commands/Hexcode.swift @@ -0,0 +1,20 @@ +import ArgumentParser + +@main +struct Hexcode: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "hexcode", + abstract: """ + hexcode is a tool that finds Xcode color assets \ + by their hexadecimal codes. + """, + usage: "hexcode [--directory ]", + version: "hexcode 0.2.0", + subcommands: [ + FindColor.self, + FindDuplicates.self, + ], + defaultSubcommand: FindColor.self + ) +} diff --git a/Sources/hexcode/Controllers/AssetCollector.swift b/Sources/hexcode/Controllers/AssetCollector.swift index 21d799b..3da5a24 100644 --- a/Sources/hexcode/Controllers/AssetCollector.swift +++ b/Sources/hexcode/Controllers/AssetCollector.swift @@ -25,7 +25,10 @@ final class AssetCollector: AssetCollecting { } let paths = try fileManager.contentsOfDirectory(atPath: directory) - let namedColorSets = await self.findColorSets(at: paths.map { "\(directory)/\($0)"}) + let namedColorSets = await self.findColorSets( + at: paths.map { "\(directory)/\($0)"}, + in: directory + ) return namedColorSets.sorted(by: { $0.name < $1.name }) } } @@ -51,6 +54,7 @@ extension AssetCollector { extension AssetCollector { private func findColorSets( at paths: [String], + in searchRootDirectory: String, alreadyFoundColorSets: [NamedColorSet] = [] ) async -> [NamedColorSet] { let colorSets = await withTaskGroup(of: [NamedColorSet].self) { group in @@ -60,13 +64,18 @@ extension AssetCollector { switch contentAtPath { case .colorSet(let colorSet): - return self.makeNamedColorset(from: colorSet, at: path) + return self.makeNamedColorset( + from: colorSet, + at: path, + in: searchRootDirectory + ) case .otherDirectory(let subpaths): guard !subpaths.isEmpty else { return [] } let fullSubpaths = subpaths.map { "\(path)/\($0)" } let colorSetsFromSubdirectory = await self.findColorSets( at: fullSubpaths, + in: searchRootDirectory, alreadyFoundColorSets: alreadyFoundColorSets ) return colorSetsFromSubdirectory @@ -83,16 +92,21 @@ extension AssetCollector { return colorSets } - private func makeNamedColorset(from colorSet: ColorSet, at path: String) -> [NamedColorSet] { - let assetName = getAssetName(from: path) + private func makeNamedColorset( + from colorSet: ColorSet, + at path: String, + in searchRootDirectory: String + ) -> [NamedColorSet] { + + let assetName = getAssetName(from: path, in: searchRootDirectory) let namedColorSet = NamedColorSet(name: assetName, colorSet: colorSet) return [namedColorSet] } private func determineContentType(at path: String) -> PathContentType? { var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { return nil } - guard isDirectory.boolValue else { return .file } + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), + isDirectory.boolValue else { return .file } if !path.hasSuffix(".colorset"), let subpaths = try? contents(at: path) { return .otherDirectory(subpaths: subpaths) @@ -105,24 +119,17 @@ extension AssetCollector { try fileManager.contentsOfDirectory(atPath: directory) } - private func getAssetName(from path: String) -> String { - makeURL(from: path) + private func getAssetName(from path: String, in searchRootDirectory: String) -> String { + let trimmedPath = String(path.trimmingPrefix(searchRootDirectory + "/")) + return URL(filePath: trimmedPath) .deletingPathExtension() - .lastPathComponent - } - - private func makeURL(from path: String) -> URL { - if #available(macOS 13.0, *) { - return URL(filePath: path) - } else { - return URL(fileURLWithPath: path) - } + .relativeString } private func readColorSet(at path: String) -> ColorSet? { let path = path + "/Contents.json" - guard let fileData = fileManager.contents(atPath: path) else { return nil } - guard let colorSet = try? JSONDecoder().decode(ColorSet.self, from: fileData) else { return nil } + guard let fileData = fileManager.contents(atPath: path), + let colorSet = try? JSONDecoder().decode(ColorSet.self, from: fileData) else { return nil } return colorSet } } diff --git a/Sources/hexcode/Controllers/ColorFinder.swift b/Sources/hexcode/Controllers/ColorFinder.swift index 2fffed7..b2c3c16 100644 --- a/Sources/hexcode/Controllers/ColorFinder.swift +++ b/Sources/hexcode/Controllers/ColorFinder.swift @@ -5,6 +5,12 @@ protocol ColorFinding { /// - parameter colorSets: Color sets to check for matching hex color code. /// - returns: Names of color sets with matching colors. Empty if none found. func find(_ hex: String, in colorSets: [NamedColorSet]) -> [String] + + + /// Searches the collection of named color sets for colors matching the same hex equivalent. + /// - Parameter colorSets: Color sets to check for duplicate hex values. + /// - Returns: Hexadecimal color codes with arrays of matching color duplicates. Empty if none found. + func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] } final class ColorFinder: ColorFinding { @@ -21,12 +27,69 @@ final class ColorFinder: ColorFinding { guard !appearances.isEmpty else { return nil } - guard appearances.count < colors.count else { - return namedSet.name + + return makeColorName(from: appearances, of: namedSet) + } + } + + func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] { + + var duplicates: [String: [String]] = [:] + let colorSetCount = colorSets.count + + for currentColorSetIndex in 0.. String { appearances.joined(separator: ", ") } + + private func makeColorName(from appearances: [String], of namedColorSet: NamedColorSet) -> String { + if appearances.count < namedColorSet.colorSet.colors.count { + return "\(namedColorSet.name) (\(joined(appearances)))" + } else { + return namedColorSet.name + } + } } diff --git a/Sources/hexcode/HexcodeApp.swift b/Sources/hexcode/HexcodeApp.swift index 45f3020..b2694fb 100644 --- a/Sources/hexcode/HexcodeApp.swift +++ b/Sources/hexcode/HexcodeApp.swift @@ -21,11 +21,12 @@ final class HexcodeApp { self.assetCollector = assetCollector } - /// Entry point for `hexcode` app logic. - /// - parameter colorHex: Raw input argument for hexadecimal color code. - /// - parameter directory: Optional custom directory from user input. Defaults to current directory. + /// Entry point for the default `find-color` subcommand logic. + /// - Parameters: + /// - colorHex: Raw input argument for hexadecimal color code. + /// - directory: Optional custom directory from user input. Defaults to current directory. /// - throws: All unhandled errors that can be thrown out to standard output. - func run(colorHex: String, in directory: String? = nil) async throws { + func runFindColor(colorHex: String, in directory: String? = nil) async throws { let directory = directory ?? fileManager.currentDirectoryPath let colorAssets = try await assetCollector.collectAssets(in: directory) let foundColors = colorFinder.find(colorHex, in: colorAssets) @@ -37,4 +38,37 @@ final class HexcodeApp { foundColors.forEach { output($0) } } + + + /// Entry point for the `find-duplicates` subcommand logic. + /// - Parameter directory: Optional custom directory from user input. Defaults to current directory. + /// - throws: All unhandled errors that can be thrown out to standard output. + func runFindDuplicates(in directory: String? = nil) async throws { + let directory = directory ?? fileManager.currentDirectoryPath + let colorAssets = try await assetCollector.collectAssets(in: directory) + let foundDuplicates = colorFinder.findDuplicates(in: colorAssets) + + if foundDuplicates.isEmpty { + output("No duplicates found") + return + } + + var hasMoreThanOneDuplicate = false + foundDuplicates + .sorted { $0.key < $1.key } + .forEach { duplicate in + + if hasMoreThanOneDuplicate { + output("--") + } + + duplicate.value.forEach { color in + output("#\(duplicate.key) \(color)") + } + + if !hasMoreThanOneDuplicate { + hasMoreThanOneDuplicate = true + } + } + } } diff --git a/Tests/hexcodeTests/AssetCollectorTests.swift b/Tests/hexcodeTests/AssetCollectorTests.swift index 5f47358..50f5b66 100644 --- a/Tests/hexcodeTests/AssetCollectorTests.swift +++ b/Tests/hexcodeTests/AssetCollectorTests.swift @@ -83,16 +83,20 @@ final class AssetCollectorTests: XCTestCase { let otherColorsDir = catalogPath + "/OtherColors" setMockDirectory(at: otherColorsDir, with: ["more_colors", "greenColorHex.colorset"]) setMockAsset(at: "\(otherColorsDir)/greenColorHex.colorset", with: ColorSetJSON.green) + var greenColorHex: NamedColorSet = .greenColorHex + greenColorHex.name = "OtherColors/greenColorHex" let moreColorsDir = otherColorsDir + "/more_colors" setMockDirectory(at: moreColorsDir, with: ["blueColorHex.colorset"]) setMockAsset(at: "\(moreColorsDir)/blueColorHex.colorset", with: ColorSetJSON.blue) + var blueColorHex: NamedColorSet = .blueColorHex + blueColorHex.name = "OtherColors/more_colors/blueColorHex" // When let assets = try await sut.collectAssets(in: catalogPath) // Then - XCTAssertEqual(assets, [.blueColorHex, .greenColorHex, .redColorHex]) + XCTAssertEqual(assets, [greenColorHex, blueColorHex, .redColorHex]) } } diff --git a/Tests/hexcodeTests/ColorFinderTests.swift b/Tests/hexcodeTests/ColorFinderTests.swift index 05cb36b..c44a268 100644 --- a/Tests/hexcodeTests/ColorFinderTests.swift +++ b/Tests/hexcodeTests/ColorFinderTests.swift @@ -128,4 +128,171 @@ final class ColorFinderTests: XCTestCase { // Then XCTAssertEqual(colors, ["cyanColorHex"]) } + + // MARK: - Test find duplicates + + func test_findDuplicates_inColorSetsWithMulticolorDuplicate_findsExpectedDuplicateValues() { + // When + let duplicates = sut.findDuplicates(in: [.defaultTextColorHex, .brandBlackColorHex]) + + // Then + XCTAssertEqual(duplicates, ["171717": ["brandBlackColorHex", "defaultTextHex (Any, Light)"]]) + } + + func test_findDuplicates_inColorSetsWithSingularDuplicate_findsExpectedDuplicateValues() { + // Given + let duplicatedWhite = NamedColorSet( + name: "duplicatedWhite", + colorSet: .Universal.Singular.white + ) + let colorSets: [NamedColorSet] = [.whiteColorHex, .blackColorHex, duplicatedWhite] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + XCTAssertEqual(colors, ["FFFFFF": ["duplicatedWhite", "whiteColorHex"]]) + } + + func test_findDuplicates_inColorSetsWithMultipleDuplicates_returnsSortedExpectedDuplicateValues() { + // Given + var yellow: NamedColorSet = .sunflowerColorHex + yellow.name = "yellow" + + var snowWhite: NamedColorSet = .whiteColorHex + snowWhite.name = "snowWhite" + + let colorSets: [NamedColorSet] = [ + .blueColorHex, + .sunflowerColorHex, + .whiteColorHex, + .blackColorHex, + yellow, + .redColorHex, + snowWhite, + .greenColorHex, + .defaultTextColorHex, + ] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + XCTAssertEqual(colors, [ + "F4EA2F": ["sunflowerHex (Dark)", "yellow (Dark)"], + "FFFFFF": ["snowWhite", "whiteColorHex"], + "E8DE2A": ["sunflowerHex (Any)", "yellow (Any)"], + ]) + } + + func test_findDuplicates_inColorSetWithNoDuplicates_returnsEmptyDictionary() { + // Given + let colorSets: [NamedColorSet] = [.redColorHex, .greenColorHex, .blueColorHex] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + AssertEmpty(colors) + } + + func test_findDuplicates_inColorSetWithInvalidColors_returnsEmptyDictionary() { + // Given + let invalidColor = ColorSet( + colors: [ + .init( + color: .init( + colorSpace: .srgb, + components: .init( + alpha: "0", + red: "r", + green: "g", + blue: "b" + ) + ), + idiom: .iPhone + ) + ], + info: .init(author: "-", version: 1.0) + ) + let colorSets: [NamedColorSet] = [ + .init(name: "one", colorSet: invalidColor), + .init(name: "two", colorSet: invalidColor), + ] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + AssertEmpty(colors) + } + + // MARK: - Test performance + +#if !CI + func test_performance_findDuplicates_measureSpeed() { + // Given + let repeatingColorSets: [NamedColorSet] = Array(repeating: .blueColorHex, count: 10) + + Array(repeating: .redColorHex, count: 10) + + Array(repeating: .greenColorHex, count: 10) + + Array(repeating: .blackColorHex, count: 10) + + Array(repeating: .whiteColorHex, count: 10) + + Array(repeating: .cyanColorHex, count: 10) + + Array(repeating: .brandBlackColorHex, count: 10) + + Array(repeating: .defaultTextColorHex, count: 10) + + Array(repeating: .sunflowerColorHex, count: 10) + + Array(repeating: .cyanColorHex, count: 10) + + let allDifferentColorSets: [NamedColorSet] = (0..<255).map { + NamedColorSet( + name: "\($0) colorset", + colorSet: ColorSet( + colors: [ + ColorAsset( + color: .init( + colorSpace: .srgb, + components: .init( + alpha: "0xFF", + red: String(format: "0x%02X", $0), + green: "0x00", + blue: "0xFF" + ) + ), + idiom: .iPhone + ) + ], + info: .init(author: "author", version: 1.0) + ) + ) + } + + var repeatingColorSetResults: [String: [String]] = [:] + var allDifferentColorSetResults: [String: [String]] = [:] + + // Then + self.measure { + // When + repeatingColorSetResults = sut.findDuplicates(in: repeatingColorSets) + allDifferentColorSetResults = sut.findDuplicates(in: allDifferentColorSets) + } + + // Then + XCTAssertEqual( + repeatingColorSetResults, + [ + "000000": Array(repeating: "blackColorHex", count: 10), + "FFFFFF": Array(repeating: "whiteColorHex", count: 10), + "FF0000": Array(repeating: "redColorHex", count: 10), + "00FF00": Array(repeating: "greenColorHex", count: 10), + "E7E7E7": Array(repeating: "defaultTextHex (Dark)", count: 10), + "F4EA2F": Array(repeating: "sunflowerHex (Dark)", count: 10), + "00FFFF": Array(repeating: "cyanColorHex", count: 20), + "0000FF": Array(repeating: "blueColorHex", count: 10), + "171717": Array(repeating: "brandBlackColorHex", count: 10) + Array(repeating: "defaultTextHex (Any, Light)", count: 10), + "E8DE2A": Array(repeating: "sunflowerHex (Any)", count: 10) + ] + ) + AssertEmpty(allDifferentColorSetResults) + } +#endif } diff --git a/Tests/hexcodeTests/HexcodeAppTests.swift b/Tests/hexcodeTests/HexcodeAppTests.swift index 620a1da..cc103dd 100644 --- a/Tests/hexcodeTests/HexcodeAppTests.swift +++ b/Tests/hexcodeTests/HexcodeAppTests.swift @@ -35,77 +35,77 @@ final class HexcodeAppTests: XCTestCase { XCTAssertEqual(mocks.assetCollector.calls, [.setFileManager(mocks.fileManager)]) } - // MARK: - Test run + // MARK: - Test runFindColor - func test_run_withoutDirectory_runsInCurrentDirectoryFromFileManager() async throws { + func test_runFindColor_withoutDirectory_runsInCurrentDirectoryFromFileManager() async throws { // Given let currentDirectory = "/currentDirectory" mocks.fileManager.results.currentDirectoryPath = currentDirectory // When - try await sut.run(colorHex: "") + try await sut.runFindColor(colorHex: "") // Then XCTAssertEqual(mocks.fileManager.calls, [.getCurrentDirectoryPath]) XCTAssertEqual(mocks.assetCollector.calls, [.collectAssetsIn(directory: currentDirectory)]) } - func test_run_inDirectory_runsInProvidedDirectory() async throws { + func test_runFindColor_inDirectory_runsInProvidedDirectory() async throws { // Given let searchDirectory = "/searchDirectory" // When - try await sut.run(colorHex: "", in: searchDirectory) + try await sut.runFindColor(colorHex: "", in: searchDirectory) // Then XCTAssertEqual(mocks.assetCollector.calls, [.collectAssetsIn(directory: searchDirectory)]) } - func test_run_whenAssetCollectorThrowsNotADirectoryError_rethrowsError() async throws { + func test_runFindColor_whenAssetCollectorThrowsNotADirectoryError_rethrowsError() async throws { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.notADirectory) await AssertAsync( - try await sut.run(colorHex: blackHexStub), // When + try await sut.runFindColor(colorHex: blackHexStub), // When throwsError: AssetCollector.Error.notADirectory // Then ) } - func test_run_whenAssetCollectorThrowsDirectoryNotFound_rethrowsError() async throws { + func test_runFindColor_whenAssetCollectorThrowsDirectoryNotFound_rethrowsError() async throws { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.directoryNotFound) await AssertAsync( - try await sut.run(colorHex: blackHexStub), // When + try await sut.runFindColor(colorHex: blackHexStub), // When throwsError: AssetCollector.Error.directoryNotFound // Then ) } - func test_run_whenAssetCollectorThrowsNotADirectoryError_doesNotLookForColors() async { + func test_runFindColor_whenAssetCollectorThrowsNotADirectoryError_doesNotLookForColors() async { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.notADirectory) // When - try? await sut.run(colorHex: blackHexStub) + try? await sut.runFindColor(colorHex: blackHexStub) // Then AssertEmpty(mocks.colorFinder.calls) AssertEmpty(mocks.outputs) } - func test_run_whenAssetCollectorThrowsDirectoryNotFound_doesNotLookForColors() async { + func test_runFindColor_whenAssetCollectorThrowsDirectoryNotFound_doesNotLookForColors() async { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.directoryNotFound) // When - try? await sut.run(colorHex: blackHexStub) + try? await sut.runFindColor(colorHex: blackHexStub) // Then AssertEmpty(mocks.colorFinder.calls) AssertEmpty(mocks.outputs) } - func test_run_whenCollectedAssets_callsColorFinderWithCollectedAssets() async throws { + func test_runFindColor_whenCollectedAssets_callsColorFinderWithCollectedAssets() async throws { // Given let expectedColorSets: [NamedColorSet] = [ .blueColorHex, @@ -116,7 +116,7 @@ final class HexcodeAppTests: XCTestCase { mocks.assetCollector.results.collectAssets = .success(expectedColorSets) // When - try await sut.run(colorHex: colorHex) + try await sut.runFindColor(colorHex: colorHex) // Then XCTAssertEqual( @@ -125,13 +125,13 @@ final class HexcodeAppTests: XCTestCase { ) } - func test_run_whenDidNotCollectAssets_callsColorFinderWithEmptyArray() async throws { + func test_runFindColor_whenDidNotCollectAssets_callsColorFinderWithEmptyArray() async throws { // Given let colorHex = "#F1F2F3" mocks.assetCollector.results.collectAssets = .success([]) // When - try await sut.run(colorHex: colorHex) + try await sut.runFindColor(colorHex: colorHex) // Then XCTAssertEqual( @@ -140,40 +140,92 @@ final class HexcodeAppTests: XCTestCase { ) } - func test_run_whenSingleColorAssetIsFound_outputsAssetName() async throws { + func test_runFindColor_whenSingleColorAssetIsFound_outputsAssetName() async throws { // Given let expectedOutput = "white" mocks.colorFinder.results.find = [expectedOutput] // When - try await sut.run(colorHex: "") + try await sut.runFindColor(colorHex: "") // Then XCTAssertEqual(mocks.outputs, [expectedOutput]) } - func test_run_whenMultipleColorAssetIsFound_outputsAllAssetNames() async throws { + func test_runFindColor_whenMultipleColorAssetIsFound_outputsAllAssetNames() async throws { // Given let expectedOutputs = ["red", "green", "blue"] mocks.colorFinder.results.find = expectedOutputs // When - try await sut.run(colorHex: "") + try await sut.runFindColor(colorHex: "") // Then XCTAssertEqual(mocks.outputs, expectedOutputs) } - func test_run_whenNoColorAssetsFound_outputsNoColorsFoundMessage() async throws { + func test_runFindColor_whenNoColorAssetsFound_outputsNoColorsFoundMessage() async throws { // Given mocks.colorFinder.results.find = [] // When - try await sut.run(colorHex: blackHexStub) + try await sut.runFindColor(colorHex: blackHexStub) // Then XCTAssertEqual(mocks.outputs, ["No \(blackHexStub) color found"]) } + + // MARK: Test runFindDuplicates + + func test_runFindDuplicates_whenSingleDuplicateFound_outputsDuplicateHexAndAssetNames() async throws { + // Given + mocks.colorFinder.results.findDuplicates = ["000000": ["snowWhite", "white"]] + + // When + try await sut.runFindDuplicates() + + // Then + XCTAssertEqual(mocks.outputs, [ + "#000000 snowWhite", + "#000000 white", + ]) + } + + func test_runFindDuplicates_whenNoDuplicateFound_outputsNoDuplicatesFoundMessage() async throws { + // Given + mocks.colorFinder.results.findDuplicates = [:] + + // When + try await sut.runFindDuplicates() + + // Then + XCTAssertEqual(mocks.outputs, ["No duplicates found"]) + } + + func test_runFindDuplicates_whenMultipleDuplicatesFound_outputsDuplicatesWithSeparator() async throws { + // Given + mocks.colorFinder.results.findDuplicates = [ + "FF00FF": ["magenta", "accentColor"], + "000000": ["snowWhite", "white"], + "FFFFFF": ["black", "darkestHex", "text (Any, Light)"] + ] + + // When + try await sut.runFindDuplicates() + + // Then + XCTAssertEqual(mocks.outputs, [ + "#000000 snowWhite", + "#000000 white", + "--", + "#FF00FF magenta", + "#FF00FF accentColor", + "--", + "#FFFFFF black", + "#FFFFFF darkestHex", + "#FFFFFF text (Any, Light)", + ]) + } } // MARK: - Private diff --git a/Tests/hexcodeTests/Mocks/ColorFinderMock.swift b/Tests/hexcodeTests/Mocks/ColorFinderMock.swift index d46e6b6..3ddaa5e 100644 --- a/Tests/hexcodeTests/Mocks/ColorFinderMock.swift +++ b/Tests/hexcodeTests/Mocks/ColorFinderMock.swift @@ -3,10 +3,12 @@ final class ColorFinderMock { enum Call: Equatable { case find(hex: String, colorSets: [NamedColorSet]) + case findDuplicates(in: [NamedColorSet]) } struct CallResults { var find: [String] = [] + var findDuplicates: [String: [String]] = [:] } private(set) var calls: [Call] = [] @@ -25,4 +27,9 @@ extension ColorFinderMock: ColorFinding { calls.append(.find(hex: hex, colorSets: colorSets)) return results.find } + + func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] { + calls.append(.findDuplicates(in: colorSets)) + return results.findDuplicates + } } diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/lavenderHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/lavenderHex.colorset/Contents.json new file mode 100644 index 0000000..3e8a33b --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/lavenderHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFA", + "green" : "0xE6", + "red" : "0xE6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/palePurpleHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/palePurpleHex.colorset/Contents.json new file mode 100644 index 0000000..3e8a33b --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/palePurpleHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFA", + "green" : "0xE6", + "red" : "0xE6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/caribbeanSeaHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/caribbeanSeaHex.colorset/Contents.json new file mode 100644 index 0000000..327d63c --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/caribbeanSeaHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9D", + "green" : "0x81", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/darkSunflowerDuplicateHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/darkSunflowerDuplicateHex.colorset/Contents.json new file mode 100644 index 0000000..09404b3 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/darkSunflowerDuplicateHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2F", + "green" : "0xEA", + "red" : "0xF4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/lavender.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/lavender.colorset/Contents.json new file mode 100644 index 0000000..9bbfd2e --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/lavender.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "250", + "green" : "230", + "red" : "230" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/sunflowerDuplicateHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/sunflowerDuplicateHex.colorset/Contents.json new file mode 100644 index 0000000..425a691 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/sunflowerDuplicateHex.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2A", + "green" : "0xDE", + "red" : "0xE8" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2F", + "green" : "0xEA", + "red" : "0xF4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift b/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift index 30bb714..fd147cc 100644 --- a/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift +++ b/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift @@ -7,6 +7,7 @@ extension ColorSet { enum Singular { static let white = makeColorSet(red: "0xFF", green: "0xFF", blue: "0xFF") static let black = makeColorSet(red: "0x00", green: "0x00", blue: "0x00") + static let brandBlack = makeColorSet(red: "0x17", green: "0x17", blue: "0x17") static let red = makeColorSet(red: "0xFF", green: "0x00", blue: "0x00") static let green = makeColorSet(red: "0x00", green: "0xFF", blue: "0x00") static let blue = makeColorSet(red: "0x00", green: "0x00", blue: "0xFF") diff --git a/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift b/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift index 17e79fd..6a67ed3 100644 --- a/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift +++ b/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift @@ -36,6 +36,11 @@ extension NamedColorSet { colorSet: .Universal.Multiple.defaultText ) + static let brandBlackColorHex: Self = NamedColorSet( + name: "brandBlackColorHex", + colorSet: .Universal.Singular.brandBlack + ) + static let cyanColorHex: Self = NamedColorSet( name: "cyanColorHex", colorSet: .Universal.Multiple.cyan diff --git a/Tests/hexcodeTests/hexcodeEndToEndTests.swift b/Tests/hexcodeTests/hexcodeEndToEndTests.swift index a699122..c80d2f6 100644 --- a/Tests/hexcodeTests/hexcodeEndToEndTests.swift +++ b/Tests/hexcodeTests/hexcodeEndToEndTests.swift @@ -72,7 +72,7 @@ final class HexcodeEndToEndTests: XCTestCase { let (output, error) = try runHexcode(arguments: arguments) // Then - XCTAssertEqual(output, "blueColorHex\n") + XCTAssertEqual(output, "OtherColors/more_colors/blueColorHex\n") XCTAssertEqual(error, "") } @@ -166,6 +166,64 @@ final class HexcodeEndToEndTests: XCTestCase { XCTAssertEqual(output, "") XCTAssert(error.contains("Error: Color hex contains invalid symbols\n")) } + + func test_hexcode_findDuplicates_withMultipleDuplicates_findsAllDuplicates() throws { + let arguments = ["find-duplicates", "--directory=\(resourcePath)/AssetsWithDuplicates.xcassets"] + + let (output, error) = try runHexcode(arguments: arguments) + + XCTAssertEqual( + output, + """ + #E6E6FA OtherColors/lavenderHex + #E6E6FA OtherColors/palePurpleHex + #E6E6FA lavender + -- + #F4EA2F darkSunflowerDuplicateHex + #F4EA2F sunflowerDuplicateHex (Dark)\n + """ + ) + XCTAssertEqual(error, "") + } + + func test_hexcode_findDuplicates_withSingleDuplicate_outputsDuplicateWithoutSeparator() throws { + let arguments = ["find-duplicates", "--directory=\(resourcePath)/AssetsWithDuplicates.xcassets/OtherColors"] + + let (output, error) = try runHexcode(arguments: arguments) + + XCTAssertEqual( + output, + """ + #E6E6FA lavenderHex + #E6E6FA palePurpleHex\n + """ + ) + XCTAssertEqual(error, "") + } + + func test_hexcode_findDuplicates_inDicectoryWithoutDuplicates_outputsNoDuplicatesFoundMessage() throws { + // Given + let arguments = ["find-duplicates", "--directory=\(resourcePath)/AssetsInSubdirectories.xcassets"] + + // When + let (output, error) = try runHexcode(arguments: arguments) + + // Then + XCTAssertEqual(output, "No duplicates found\n") + XCTAssertEqual(error, "") + } + + func test_hexcode_findDuplicates_inDicectoryWithoutColorAssets_outputsNoDuplicatesFoundMessage() throws { + // Given + let arguments = ["find-duplicates", "--directory=\(resourcePath)/FakeContentFolder"] + + // When + let (output, error) = try runHexcode(arguments: arguments) + + // Then + XCTAssertEqual(output, "No duplicates found\n") + XCTAssertEqual(error, "") + } } // MARK: - Private