From fda86abbc3c9da965b63651da802c0b7b91541da Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Thu, 19 Mar 2026 18:18:27 +0100 Subject: [PATCH 01/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Migrate=20tests=20fr?= =?UTF-8?q?om=20XCTest=20to=20Swift=20Testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace XCTest with Swift Testing framework across all test files, using @Suite structs, @Test functions, and #expect macros. Remove XCTestManifests.swift as macro-based test discovery makes it obsolete. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ACKLocalization+Plurals.swift | 126 +++++++++--------- .../ACKLocalizationTests.swift | 37 +++-- .../LocRowTests.swift | 115 +++++++++------- .../XCTestManifests.swift | 9 -- 4 files changed, 155 insertions(+), 132 deletions(-) delete mode 100644 Tests/ACKLocalizationCoreTests/XCTestManifests.swift diff --git a/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift b/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift index 782ba42..edd0b42 100644 --- a/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift +++ b/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift @@ -1,42 +1,43 @@ -import XCTest +import Foundation +import Testing @testable import ACKLocalizationCore -final class ACKLocalizationPluralsTests: XCTestCase { - private var ackLocalization: ACKLocalization! - private var sheetsAPI: SheetsAPIServiceMock! - - // MARK: - Setup - - override func setUp() { - super.setUp() +@Suite +struct ACKLocalizationPluralsTests { + private let ackLocalization: ACKLocalization + private let sheetsAPI: SheetsAPIServiceMock + init() { sheetsAPI = SheetsAPIServiceMock() ackLocalization = ACKLocalization(sheetsAPI: sheetsAPI) } - + // MARK: - Tests - - func testEmptyTranslations() { + + @Test + func emptyTranslations() throws { let rows: [LocRow] = [] - - let plurals = try! ackLocalization.buildPlurals(from: rows) - - XCTAssertEqual(plurals.count, 0) + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 0) } - - func testNoPlurals() { + + @Test + func noPlurals() throws { let rows: [LocRow] = [ LocRow(key: "key1", value: "value1"), LocRow(key: "key2", value: "value2"), LocRow(key: "key3", value: "value3") ] - - let plurals = try! ackLocalization.buildPlurals(from: rows) - - XCTAssertEqual(plurals.count, 0) + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 0) } - - func testOnePlural() { + + @Test + func onePlural() throws { let rows = [ LocRow(key: "key##{zero}", value: "zero"), LocRow(key: "key##{one}", value: "one"), @@ -44,58 +45,63 @@ final class ACKLocalizationPluralsTests: XCTestCase { LocRow(key: "key##{many}", value: "many"), LocRow(key: "key##{other}", value: "other") ] - - let plurals = try! ackLocalization.buildPlurals(from: rows) - - XCTAssertEqual(plurals.count, 1) - XCTAssertEqual(Array(plurals.values)[0].translations.count, rows.count) + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 1) + #expect(Array(plurals.values)[0].translations.count == rows.count) } - - func testMultiplePlurals() { + + @Test + func multiplePlurals() throws { let rows = [ LocRow(key: "key##{zero}", value: "zero"), LocRow(key: "key2##{one}", value: "one") ] - - let plurals = try! ackLocalization.buildPlurals(from: rows) - - XCTAssertEqual(plurals.count, 2) + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 2) for plural in plurals.values { - XCTAssertEqual(plural.translations.count, 1) + #expect(plural.translations.count == 1) } } - - func testMissingTranslationKey() { + + @Test + func missingTranslationKey() { let rows = [ LocRow(key: "##{zero}", value: "zero") ] - - XCTAssertThrowsError(try ackLocalization.buildPlurals(from: rows)) { error in - XCTAssertEqual(error as? PluralError, PluralError.missingTranslationKey(rows[0].key)) + + #expect(throws: PluralError.missingTranslationKey(rows[0].key)) { + try ackLocalization.buildPlurals(from: rows) } } - - func testMissingPluralRule() { + + @Test + func missingPluralRule() { let rows = [ LocRow(key: "key##{}", value: "zero") ] - - XCTAssertThrowsError(try ackLocalization.buildPlurals(from: rows)) { error in - XCTAssertEqual(error as? PluralError, PluralError.missingPluralRule(rows[0].key)) + + #expect(throws: PluralError.missingPluralRule(rows[0].key)) { + try ackLocalization.buildPlurals(from: rows) } } - func testInvalidPluralRuleKey() { + @Test + func invalidPluralRuleKey() { let rows = [ LocRow(key: "key##{zeroone}", value: "zero") ] - - XCTAssertThrowsError(try ackLocalization.buildPlurals(from: rows)) { error in - XCTAssertEqual(error as? PluralError, PluralError.invalidPluralRule(rows[0].key)) + + #expect(throws: PluralError.invalidPluralRule(rows[0].key)) { + try ackLocalization.buildPlurals(from: rows) } } - - func testPluralWithStringFormatSpecifier() throws { + + @Test + func pluralWithStringFormatSpecifier() throws { // Given let rows = [ LocRow(key: "key##{many}", value: "%d many"), @@ -112,17 +118,17 @@ final class ACKLocalizationPluralsTests: XCTestCase { ] ) let expectedResultEncoded = try encoder.encode(expectedResult) - + // When let plurals = try ackLocalization.buildPlurals(from: rows) - let encodedData = try encoder.encode(plurals.first?.value) - + // Then - XCTAssertEqual(encodedData, expectedResultEncoded) + #expect(encodedData == expectedResultEncoded) } - - func testPluralWithIntegerFormatSpecifier() throws { + + @Test + func pluralWithIntegerFormatSpecifier() throws { // Given let rows = [ LocRow(key: "key##{many}", value: "%s many"), @@ -143,9 +149,9 @@ final class ACKLocalizationPluralsTests: XCTestCase { // When let plurals = try ackLocalization.buildPlurals(from: rows) let encodedData = try encoder.encode(plurals.first?.value) - + // Then - XCTAssertEqual(encodedData, expectedResultEncoded) + #expect(encodedData == expectedResultEncoded) } } diff --git a/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift b/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift index e1dceaa..09c1088 100644 --- a/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift +++ b/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift @@ -1,10 +1,12 @@ @testable import ACKLocalizationCore -import XCTest +import Testing -final class ACKLocalizationTests: XCTestCase { +@Suite +struct ACKLocalizationTests { let localization = ACKLocalization() - func test_transform_emptyRow() throws { + @Test + func transformEmptyRow() throws { let mappedValues = try localization.transformValues( .init( values: [ @@ -15,35 +17,42 @@ final class ACKLocalizationTests: XCTestCase { with: ["cs": "cs"], keyColumnName: "keys" ) - - XCTAssertEqual(Array(mappedValues.keys), ["cs"]) - XCTAssertEqual( - mappedValues.values.flatMap { $0 }, - [LocRow(key: "key", value: "")] + + #expect(Array(mappedValues.keys) == ["cs"]) + #expect( + mappedValues.values.flatMap { $0 } + == [LocRow(key: "key", value: "")] ) } - func testForDuplicateKeys() throws { + @Test + func forDuplicateKeys() throws { let locRow = [ LocRow(key: "key_1", value: "value1"), LocRow(key: "key_1", value: "value2"), LocRow(key: "key_2", value: "value3") ] - XCTAssertThrowsError(try localization.checkDuplicateKeys(form: locRow)) + #expect(throws: (any Error).self) { + try localization.checkDuplicateKeys(form: locRow) + } } - func testForUniqueKeys() throws { + @Test + func forUniqueKeys() throws { let locRow = [ LocRow(key: "key_1", value: "value1"), LocRow(key: "key_2", value: "value2"), LocRow(key: "key_3", value: "value3") ] - XCTAssertNoThrow(try localization.checkDuplicateKeys(form: locRow)) + #expect(throws: Never.self) { + try localization.checkDuplicateKeys(form: locRow) + } } - func testRemovingSuffix() { + @Test + func removingSuffix() { let fileName = "Localizable.strings" - XCTAssertEqual("Localizable", fileName.removingSuffix(".strings")) + #expect("Localizable" == fileName.removingSuffix(".strings")) } func test_valueRange_decodesIntegerCellValues() throws { diff --git a/Tests/ACKLocalizationCoreTests/LocRowTests.swift b/Tests/ACKLocalizationCoreTests/LocRowTests.swift index 49600d8..7ab1d5a 100644 --- a/Tests/ACKLocalizationCoreTests/LocRowTests.swift +++ b/Tests/ACKLocalizationCoreTests/LocRowTests.swift @@ -1,84 +1,101 @@ -import XCTest +import Testing @testable import ACKLocalizationCore -final class LocRowTests: XCTestCase { - func testBasicRow() { +@Suite +struct LocRowTests { + @Test + func basicRow() { let locRow = LocRow(key: "key", value: "value") - XCTAssertEqual(#""key" = "value";"#, locRow.localizableRow) + #expect(#""key" = "value";"# == locRow.localizableRow) } - - func testIntegerRow() { + + @Test + func integerRow() { let locRow = LocRow(key: "int_key", value: "int value %d") - XCTAssertEqual(#""int_key" = "int value %d";"#, locRow.localizableRow) + #expect(#""int_key" = "int value %d";"# == locRow.localizableRow) } - - func testAlternativeIntegerRow() { + + @Test + func alternativeIntegerRow() { let locRow = LocRow(key: "int_key", value: "int value %i") - XCTAssertEqual(#""int_key" = "int value %i";"#, locRow.localizableRow) + #expect(#""int_key" = "int value %i";"# == locRow.localizableRow) } - - func testFloatRow() { + + @Test + func floatRow() { let locRow = LocRow(key: "float_key", value: "float value %f") - XCTAssertEqual(#""float_key" = "float value %f";"#, locRow.localizableRow) + #expect(#""float_key" = "float value %f";"# == locRow.localizableRow) } - - func testOneDecimalFloatRow() { + + @Test + func oneDecimalFloatRow() { let locRow = LocRow(key: "float_key", value: "float value with one decimal %.1f") - XCTAssertEqual(#""float_key" = "float value with one decimal %.1f";"#, locRow.localizableRow) + #expect(#""float_key" = "float value with one decimal %.1f";"# == locRow.localizableRow) } - - func testThreeDecimalFloatRow() { + + @Test + func threeDecimalFloatRow() { let locRow = LocRow(key: "float_key", value: "float value with three decimals %.3f") - XCTAssertEqual(#""float_key" = "float value with three decimals %.3f";"#, locRow.localizableRow) + #expect(#""float_key" = "float value with three decimals %.3f";"# == locRow.localizableRow) } - - func testStringRow() { + + @Test + func stringRow() { let locRow = LocRow(key: "string_key", value: "string value %s") - XCTAssertEqual(#""string_key" = "string value %@";"#, locRow.localizableRow) + #expect(#""string_key" = "string value %@";"# == locRow.localizableRow) } - - func testCocoaStringRow() { + + @Test + func cocoaStringRow() { let locRow = LocRow(key: "string_key", value: "string value %@") - XCTAssertEqual(#""string_key" = "string value %@";"#, locRow.localizableRow) + #expect(#""string_key" = "string value %@";"# == locRow.localizableRow) } - - func testPercentIsEscaped() { + + @Test + func percentIsEscaped() { let locRow = LocRow(key: "percent_key", value: "%d % percent") - XCTAssertEqual(#""percent_key" = "%d %% percent";"#, locRow.localizableRow) + #expect(#""percent_key" = "%d %% percent";"# == locRow.localizableRow) } - - func testKeyQuotesAreEscaped() { + + @Test + func keyQuotesAreEscaped() { let locRow = LocRow(key: "abc\"abc", value: "quotes_value") - XCTAssertEqual(#""abc\"abc" = "quotes_value";"#, locRow.localizableRow) + #expect(#""abc\"abc" = "quotes_value";"# == locRow.localizableRow) } - - func testValueQuotesAreEscaped() { + + @Test + func valueQuotesAreEscaped() { let locRow = LocRow(key: "quotes_key", value: "abc\"abc") - XCTAssertEqual(#""quotes_key" = "abc\"abc";"#, locRow.localizableRow) + #expect(#""quotes_key" = "abc\"abc";"# == locRow.localizableRow) } - - func testNewLineIsEscaped() { + + @Test + func newLineIsEscaped() { let locRow = LocRow(key: "nl_key", value: "abc\nabc") - XCTAssertEqual(#""nl_key" = "abc\nabc";"#, locRow.localizableRow) + #expect(#""nl_key" = "abc\nabc";"# == locRow.localizableRow) } - - func testIntPositionArgumentsAreReplaced() { + + @Test + func intPositionArgumentsAreReplaced() { let locRow = LocRow(key: "pos_arg_key", value: "%1$d people will arrive in %2$d minutes") - XCTAssertEqual(#""pos_arg_key" = "%1$d people will arrive in %2$d minutes";"#, locRow.localizableRow) + #expect(#""pos_arg_key" = "%1$d people will arrive in %2$d minutes";"# == locRow.localizableRow) } - - func testFloatPositionArgumentsAreReplaced() { + + @Test + func floatPositionArgumentsAreReplaced() { let locRow = LocRow(key: "pos_arg_key", value: "%1$f people will arrive in %2$f minutes") - XCTAssertEqual(#""pos_arg_key" = "%1$f people will arrive in %2$f minutes";"#, locRow.localizableRow) + #expect(#""pos_arg_key" = "%1$f people will arrive in %2$f minutes";"# == locRow.localizableRow) } - - func testStringPositionArgumentsAreReplaced() { + + @Test + func stringPositionArgumentsAreReplaced() { let locRow = LocRow(key: "pos_arg_key", value: "%1$s people will arrive in %2$s minutes") - XCTAssertEqual(#""pos_arg_key" = "%1$@ people will arrive in %2$@ minutes";"#, locRow.localizableRow) + #expect(#""pos_arg_key" = "%1$@ people will arrive in %2$@ minutes";"# == locRow.localizableRow) } - - func testCocoaStringPositionArgumentsAreReplaced() { + + @Test + func cocoaStringPositionArgumentsAreReplaced() { let locRow = LocRow(key: "pos_arg_key", value: "%1$@ people will arrive in %2$@ minutes") - XCTAssertEqual(#""pos_arg_key" = "%1$@ people will arrive in %2$@ minutes";"#, locRow.localizableRow) + #expect(#""pos_arg_key" = "%1$@ people will arrive in %2$@ minutes";"# == locRow.localizableRow) } } diff --git a/Tests/ACKLocalizationCoreTests/XCTestManifests.swift b/Tests/ACKLocalizationCoreTests/XCTestManifests.swift deleted file mode 100644 index a236330..0000000 --- a/Tests/ACKLocalizationCoreTests/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(ACKLocalizationTests.allTests), - ] -} -#endif From fa18c605446d4a5979f92b151b119ad5c191a262 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Thu, 19 Mar 2026 18:28:57 +0100 Subject: [PATCH 02/14] =?UTF-8?q?=E2=9C=85=20Add=20comprehensive=20test=20?= =?UTF-8?q?coverage=20for=20core=20localization=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover previously untested areas: multi-language transforms, configuration decoding/migration, file I/O with plist prefix routing, ValueRange edge cases, GoogleError detection, and LocRow formatting edge cases (91 tests total). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ACKLocalizationCore/ACKLocalization.swift | 2 +- .../ACKLocalization+Plurals.swift | 64 ++++ .../ACKLocalizationTests.swift | 175 ++++++++++- .../ConfigurationTests.swift | 167 ++++++++++ .../GoogleErrorTests.swift | 82 +++++ .../LocRowTests.swift | 74 +++++ .../SaveMappedValuesTests.swift | 287 ++++++++++++++++++ .../ValueRangeTests.swift | 82 +++++ 8 files changed, 922 insertions(+), 11 deletions(-) create mode 100644 Tests/ACKLocalizationCoreTests/ConfigurationTests.swift create mode 100644 Tests/ACKLocalizationCoreTests/GoogleErrorTests.swift create mode 100644 Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift create mode 100644 Tests/ACKLocalizationCoreTests/ValueRangeTests.swift diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index 7788ca0..83ab04e 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -209,7 +209,7 @@ public final class ACKLocalization { else { return defaultFileName } - + return keyComponents[1] } diff --git a/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift b/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift index edd0b42..693824c 100644 --- a/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift +++ b/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift @@ -153,6 +153,70 @@ struct ACKLocalizationPluralsTests { // Then #expect(encodedData == expectedResultEncoded) } + + // MARK: - Partial plural rules + + @Test + func partialPluralRules() throws { + let rows = [ + LocRow(key: "items##{one}", value: "one item"), + LocRow(key: "items##{other}", value: "%d items") + ] + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 1) + #expect(plurals["items"]?.translations.count == 2) + } + + @Test + func allSixPluralRules() throws { + let rows = [ + LocRow(key: "k##{zero}", value: "zero"), + LocRow(key: "k##{one}", value: "one"), + LocRow(key: "k##{two}", value: "two"), + LocRow(key: "k##{few}", value: "few"), + LocRow(key: "k##{many}", value: "many"), + LocRow(key: "k##{other}", value: "other") + ] + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 1) + #expect(plurals["k"]?.translations.count == 6) + } + + // MARK: - Mixed plural and non-plural rows + + @Test + func mixedPluralAndNonPluralRows() throws { + let rows = [ + LocRow(key: "regular_key", value: "regular"), + LocRow(key: "plural_key##{one}", value: "one"), + LocRow(key: "plural_key##{other}", value: "other"), + LocRow(key: "another_regular", value: "another") + ] + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 1) + #expect(plurals["plural_key"]?.translations.count == 2) + } + + // MARK: - Dotted plural keys + + @Test + func dottedPluralKey() throws { + let rows = [ + LocRow(key: "section.count##{one}", value: "one"), + LocRow(key: "section.count##{other}", value: "other") + ] + + let plurals = try ackLocalization.buildPlurals(from: rows) + + #expect(plurals.count == 1) + #expect(plurals["section.count"]?.translations.count == 2) + } } // Well we used JSONSerialization for comparison of dict literal with expected Codable data, diff --git a/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift b/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift index 09c1088..69ef6bb 100644 --- a/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift +++ b/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift @@ -1,3 +1,4 @@ +import Foundation @testable import ACKLocalizationCore import Testing @@ -55,25 +56,29 @@ struct ACKLocalizationTests { #expect("Localizable" == fileName.removingSuffix(".strings")) } - func test_valueRange_decodesIntegerCellValues() throws { + @Test + func valueRangeDecodesIntegerCellValues() throws { let json = #"{"values":[["key","en"],["some_key",42]]}"# let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) - XCTAssertEqual(valueRange.values, [["key", "en"], ["some_key", "42"]]) + #expect(valueRange.values == [["key", "en"], ["some_key", "42"]]) } - func test_valueRange_decodesDoubleCellValues() throws { + @Test + func valueRangeDecodesDoubleCellValues() throws { let json = #"{"values":[["key","en"],["price_key",3.14]]}"# let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) - XCTAssertEqual(valueRange.values, [["key", "en"], ["price_key", "3.14"]]) + #expect(valueRange.values == [["key", "en"], ["price_key", "3.14"]]) } - func test_valueRange_decodesMixedCellValues() throws { + @Test + func valueRangeDecodesMixedCellValues() throws { let json = #"{"values":[["key","en"],["str_key","hello"],["int_key",7]]}"# let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) - XCTAssertEqual(valueRange.values, [["key", "en"], ["str_key", "hello"], ["int_key", "7"]]) + #expect(valueRange.values == [["key", "en"], ["str_key", "hello"], ["int_key", "7"]]) } - func test_transform_integerCellValue() throws { + @Test + func transformIntegerCellValue() throws { let json = #"{"values":[["keys","en"],["count",42]]}"# let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) let mappedValues = try localization.transformValues( @@ -81,9 +86,159 @@ struct ACKLocalizationTests { with: ["en": "en"], keyColumnName: "keys" ) - XCTAssertEqual( - mappedValues["en"], - [LocRow(key: "count", value: "42")] + #expect( + mappedValues["en"] + == [LocRow(key: "count", value: "42")] + ) + } + + // MARK: - transformValues: multiple languages + + @Test + func transformMultipleLanguages() throws { + let mappedValues = try localization.transformValues( + .init(values: [ + ["keys", "cs", "en"], + ["greeting", "Ahoj", "Hello"], + ["farewell", "Sbohem", "Goodbye"] + ]), + with: ["cs": "cs", "en": "en"], + keyColumnName: "keys" + ) + + #expect(mappedValues.keys.count == 2) + #expect(mappedValues["cs"] == [ + LocRow(key: "greeting", value: "Ahoj"), + LocRow(key: "farewell", value: "Sbohem") + ]) + #expect(mappedValues["en"] == [ + LocRow(key: "greeting", value: "Hello"), + LocRow(key: "farewell", value: "Goodbye") + ]) + } + + @Test + func transformMissingKeyColumn() throws { + #expect(throws: LocalizationError.self) { + try localization.transformValues( + .init(values: [ + ["id", "cs"], + ["key", "value"] + ]), + with: ["cs": "cs"], + keyColumnName: "keys" + ) + } + } + + @Test + func transformHeaderOnly() throws { + let mappedValues = try localization.transformValues( + .init(values: [["keys", "cs"]]), + with: ["cs": "cs"], + keyColumnName: "keys" + ) + + #expect(mappedValues.isEmpty) + } + + @Test + func transformSkipsEmptyKeys() throws { + let mappedValues = try localization.transformValues( + .init(values: [ + ["keys", "en"], + ["", "should be skipped"], + ["valid_key", "kept"] + ]), + with: ["en": "en"], + keyColumnName: "keys" + ) + + #expect(mappedValues["en"] == [LocRow(key: "valid_key", value: "kept")]) + } + + @Test + func transformIgnoresUnmappedColumns() throws { + let mappedValues = try localization.transformValues( + .init(values: [ + ["keys", "cs", "en", "notes"], + ["key1", "česky", "english", "some note"] + ]), + with: ["cs": "cs"], + keyColumnName: "keys" + ) + + #expect(mappedValues.keys.count == 1) + #expect(mappedValues["cs"] == [LocRow(key: "key1", value: "česky")]) + } + + @Test + func transformMappingColumnNotInSheet() throws { + let mappedValues = try localization.transformValues( + .init(values: [ + ["keys", "cs"], + ["key1", "česky"] + ]), + with: ["cs": "cs", "de": "de"], + keyColumnName: "keys" ) + + // "cs" should be mapped, "de" column doesn't exist so no rows for it + #expect(mappedValues["cs"] == [LocRow(key: "key1", value: "česky")]) + #expect(mappedValues["de"] == nil) + } + + @Test + func transformRowShorterThanHeader() throws { + let mappedValues = try localization.transformValues( + .init(values: [ + ["keys", "cs", "en"], + ["key1", "česky"], // missing "en" column value + ["key2", "česky2", "english2"] + ]), + with: ["cs": "cs", "en": "en"], + keyColumnName: "keys" + ) + + #expect(mappedValues["cs"] == [ + LocRow(key: "key1", value: "česky"), + LocRow(key: "key2", value: "česky2") + ]) + #expect(mappedValues["en"] == [ + LocRow(key: "key1", value: ""), + LocRow(key: "key2", value: "english2") + ]) + } + + // MARK: - removingSuffix edge cases + + @Test + func removingSuffixNoMatch() { + let fileName = "Localizable" + #expect("Localizable" == fileName.removingSuffix(".strings")) + } + + @Test + func removingSuffixMultipleDots() { + let fileName = "My.Custom.strings" + #expect("My.Custom" == fileName.removingSuffix(".strings")) + } + + // MARK: - checkDuplicateKeys edge cases + + @Test + func forEmptyRows() throws { + #expect(throws: Never.self) { + try localization.checkDuplicateKeys(form: []) + } + } + + @Test + func forSingleRow() throws { + #expect(throws: Never.self) { + try localization.checkDuplicateKeys(form: [ + LocRow(key: "only_key", value: "value") + ]) + } } } diff --git a/Tests/ACKLocalizationCoreTests/ConfigurationTests.swift b/Tests/ACKLocalizationCoreTests/ConfigurationTests.swift new file mode 100644 index 0000000..c49afac --- /dev/null +++ b/Tests/ACKLocalizationCoreTests/ConfigurationTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing +@testable import ACKLocalizationCore + +@Suite +struct ConfigurationTests { + private let decoder = JSONDecoder() + + // MARK: - V2 Configuration + + @Test + func decodesV2Configuration() throws { + let json = """ + { + "spreadsheetID": "abc123", + "keyColumnName": "keys", + "languageMapping": {"cs": "cs", "en": "en"}, + "destinations": {"Localizable": "~/Project"}, + "defaultFileName": "Localizable" + } + """ + + let config = try decoder.decode(Configuration.self, from: Data(json.utf8)) + + #expect(config.spreadsheetID == "abc123") + #expect(config.keyColumnName == "keys") + #expect(config.languageMapping == ["cs": "cs", "en": "en"]) + #expect(config.destinations == ["Localizable": "~/Project"]) + #expect(config.defaultFileName == "Localizable") + #expect(config.apiKey == nil) + #expect(config.serviceAccount == nil) + #expect(config.spreadsheetTabName == nil) + } + + @Test + func decodesV2WithAPIKey() throws { + let json = """ + { + "spreadsheetID": "abc123", + "keyColumnName": "keys", + "languageMapping": {"cs": "cs"}, + "destinations": {"Localizable": "~/Project"}, + "defaultFileName": "Localizable", + "apiKey": "my-api-key" + } + """ + + let config = try decoder.decode(Configuration.self, from: Data(json.utf8)) + + #expect(config.apiKey?.value == "my-api-key") + } + + @Test + func decodesV2WithServiceAccount() throws { + let json = """ + { + "spreadsheetID": "abc123", + "keyColumnName": "keys", + "languageMapping": {"cs": "cs"}, + "destinations": {"Localizable": "~/Project"}, + "defaultFileName": "Localizable", + "serviceAccount": "path/to/sa.json" + } + """ + + let config = try decoder.decode(Configuration.self, from: Data(json.utf8)) + + #expect(config.serviceAccount == "path/to/sa.json") + } + + @Test + func decodesV2WithSpreadsheetTabName() throws { + let json = """ + { + "spreadsheetID": "abc123", + "keyColumnName": "keys", + "languageMapping": {"cs": "cs"}, + "destinations": {"Localizable": "~/Project"}, + "defaultFileName": "Localizable", + "spreadsheetTabName": "Translations" + } + """ + + let config = try decoder.decode(Configuration.self, from: Data(json.utf8)) + + #expect(config.spreadsheetTabName == "Translations") + } + + @Test + func v2MissingRequiredFieldThrows() { + let json = """ + { + "spreadsheetID": "abc123", + "keyColumnName": "keys" + } + """ + + #expect(throws: (any Error).self) { + try decoder.decode(Configuration.self, from: Data(json.utf8)) + } + } + + // MARK: - V1 → V2 Migration + + @Test + func v1MigrationSetsDestinations() throws { + let v1 = ConfigurationV1( + apiKey: "test-key", + destinationDir: "~/MyApp", + keyColumnName: "keys", + languageMapping: ["cs": "cs"], + serviceAccount: nil, + spreadsheetID: "sheet-id", + spreadsheetTabName: nil, + stringsFileName: nil, + stringsDictFileName: nil + ) + + let v2 = Configuration(v1Config: v1) + + #expect(v2.defaultFileName == "Localizable") + #expect(v2.destinations == ["Localizable": "~/MyApp"]) + #expect(v2.spreadsheetID == "sheet-id") + #expect(v2.keyColumnName == "keys") + } + + @Test + func v1MigrationWithCustomStringsFileName() throws { + let v1 = ConfigurationV1( + apiKey: nil, + destinationDir: "~/MyApp", + keyColumnName: "keys", + languageMapping: ["en": "en"], + serviceAccount: "sa.json", + spreadsheetID: "sheet-id", + spreadsheetTabName: "Tab1", + stringsFileName: "MyStrings.strings", + stringsDictFileName: nil + ) + + let v2 = Configuration(v1Config: v1) + + #expect(v2.defaultFileName == "MyStrings") + #expect(v2.destinations == ["MyStrings": "~/MyApp"]) + #expect(v2.serviceAccount == "sa.json") + #expect(v2.spreadsheetTabName == "Tab1") + } + + @Test + func v1Decoding() throws { + let json = """ + { + "spreadsheetID": "abc123", + "keyColumnName": "keys", + "languageMapping": {"en": "en"}, + "destinationDir": "~/Project", + "apiKey": "my-key" + } + """ + + let config = try decoder.decode(ConfigurationV1.self, from: Data(json.utf8)) + + #expect(config.spreadsheetID == "abc123") + #expect(config.destinationDir == "~/Project") + #expect(config.apiKey?.value == "my-key") + } +} diff --git a/Tests/ACKLocalizationCoreTests/GoogleErrorTests.swift b/Tests/ACKLocalizationCoreTests/GoogleErrorTests.swift new file mode 100644 index 0000000..74a108d --- /dev/null +++ b/Tests/ACKLocalizationCoreTests/GoogleErrorTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import ACKLocalizationCore + +@Suite +struct GoogleErrorTests { + @Test + func isMissingTabForMatchingError() { + let error = GoogleError( + message: "Unable to parse range: NonExistent", + status: "INVALID_ARGUMENT", + code: 400 + ) + + #expect(error.isMissingTab) + } + + @Test + func isMissingTabFalseForDifferentCode() { + let error = GoogleError( + message: "Not found", + status: "INVALID_ARGUMENT", + code: 404 + ) + + #expect(!error.isMissingTab) + } + + @Test + func isMissingTabFalseForDifferentStatus() { + let error = GoogleError( + message: "Forbidden", + status: "PERMISSION_DENIED", + code: 400 + ) + + #expect(!error.isMissingTab) + } + + @Test + func decodesFromJSON() throws { + let json = """ + { + "message": "Some error", + "status": "INVALID_ARGUMENT", + "code": 400 + } + """ + + let error = try JSONDecoder().decode(GoogleError.self, from: Data(json.utf8)) + + #expect(error.message == "Some error") + #expect(error.status == "INVALID_ARGUMENT") + #expect(error.code == 400) + } + + @Test + func localizationErrorFromRequestErrorWithMissingTab() { + let googleError = GoogleError( + message: "Unable to parse range", + status: "INVALID_ARGUMENT", + code: 400 + ) + let requestError = RequestError(underlyingError: googleError) + let locError = LocalizationError(requestError) + + #expect(locError.code == .missingSheetTab) + } + + @Test + func localizationErrorFromRequestErrorWithoutMissingTab() { + let googleError = GoogleError( + message: "Forbidden", + status: "PERMISSION_DENIED", + code: 403 + ) + let requestError = RequestError(underlyingError: googleError) + let locError = LocalizationError(requestError) + + #expect(locError.code == nil) + } +} diff --git a/Tests/ACKLocalizationCoreTests/LocRowTests.swift b/Tests/ACKLocalizationCoreTests/LocRowTests.swift index 7ab1d5a..600b579 100644 --- a/Tests/ACKLocalizationCoreTests/LocRowTests.swift +++ b/Tests/ACKLocalizationCoreTests/LocRowTests.swift @@ -98,4 +98,78 @@ struct LocRowTests { let locRow = LocRow(key: "pos_arg_key", value: "%1$@ people will arrive in %2$@ minutes") #expect(#""pos_arg_key" = "%1$@ people will arrive in %2$@ minutes";"# == locRow.localizableRow) } + + // MARK: - Multiple format specifiers + + @Test + func multipleIntegerSpecifiers() { + let locRow = LocRow(key: "key", value: "%d of %d items") + #expect(#""key" = "%d of %d items";"# == locRow.localizableRow) + } + + @Test + func mixedSpecifiers() { + let locRow = LocRow(key: "key", value: "%d items for %@") + #expect(#""key" = "%d items for %@";"# == locRow.localizableRow) + } + + // MARK: - Unicode escape + + @Test + func unicodeEscapeIsConverted() { + let locRow = LocRow(key: "key", value: "\\u0041") + #expect(#""key" = "\U0041";"# == locRow.localizableRow) + } + + // MARK: - Edge cases + + @Test + func emptyKeyAndValue() { + let locRow = LocRow(key: "", value: "") + #expect(#""" = "";"# == locRow.localizableRow) + } + + @Test + func valueIsOnlyPercent() { + let locRow = LocRow(key: "key", value: "%") + #expect(#""key" = "%%";"# == locRow.localizableRow) + } + + @Test + func multipleNewlinesEscaped() { + let locRow = LocRow(key: "key", value: "line1\nline2\nline3") + #expect(#""key" = "line1\nline2\nline3";"# == locRow.localizableRow) + } + + @Test + func quotesInBothKeyAndValue() { + let locRow = LocRow(key: #"say "hi""#, value: #"she said "hello""#) + #expect(#""say \"hi\"" = "she said \"hello\"";"# == locRow.localizableRow) + } + + @Test + func percentAfterCocoaString() { + let locRow = LocRow(key: "key", value: "%@ 100%") + #expect(#""key" = "%@ 100%%";"# == locRow.localizableRow) + } + + // MARK: - isPlural + + @Test + func isPluralForRegularKey() { + let locRow = LocRow(key: "regular_key", value: "value") + #expect(!locRow.isPlural) + } + + @Test + func isPluralForPluralKey() { + let locRow = LocRow(key: "items_count##{one}", value: "one item") + #expect(locRow.isPlural) + } + + @Test + func isPluralForDottedPluralKey() { + let locRow = LocRow(key: "section.items_count##{other}", value: "items") + #expect(locRow.isPlural) + } } diff --git a/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift b/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift new file mode 100644 index 0000000..ecb75ab --- /dev/null +++ b/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift @@ -0,0 +1,287 @@ +import Foundation +import Testing +@testable import ACKLocalizationCore + +@Suite +struct SaveMappedValuesTests { + private let localization: ACKLocalization + private let sheetsAPI: SheetsAPIServiceMock + + init() { + sheetsAPI = SheetsAPIServiceMock() + localization = ACKLocalization(sheetsAPI: sheetsAPI) + } + + // MARK: - File writing + + @Test + func writesStringsFile() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let destPath = tempDir.path + + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "greeting", value: "Hello"), + LocRow(key: "farewell", value: "Goodbye") + ] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: ["Localizable": destPath] + ) + + let stringsPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("Localizable.strings") + .path + + #expect(FileManager.default.fileExists(atPath: stringsPath)) + + let content = try String(contentsOfFile: stringsPath, encoding: .utf8) + #expect(content.contains(#""greeting" = "Hello";"#)) + #expect(content.contains(#""farewell" = "Goodbye";"#)) + } + + @Test + func createsLprojDirectories() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mappedValues: MappedValues = [ + "en": [LocRow(key: "key", value: "en_value")], + "cs": [LocRow(key: "key", value: "cs_value")] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: ["Localizable": tempDir.path] + ) + + let enDir = tempDir.appendingPathComponent("en.lproj").path + let csDir = tempDir.appendingPathComponent("cs.lproj").path + + #expect(FileManager.default.fileExists(atPath: enDir)) + #expect(FileManager.default.fileExists(atPath: csDir)) + } + + @Test + func writesStringsDictForPlurals() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "items##{one}", value: "%d item"), + LocRow(key: "items##{other}", value: "%d items") + ] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: ["Localizable": tempDir.path] + ) + + let stringsDictPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("Localizable.stringsdict") + .path + + #expect(FileManager.default.fileExists(atPath: stringsDictPath)) + + // .strings should NOT exist since all rows are plurals + let stringsPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("Localizable.strings") + .path + + #expect(!FileManager.default.fileExists(atPath: stringsPath)) + } + + @Test + func separatesPluralsFromRegularKeys() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "greeting", value: "Hello"), + LocRow(key: "items##{one}", value: "%d item"), + LocRow(key: "items##{other}", value: "%d items") + ] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: ["Localizable": tempDir.path] + ) + + let stringsPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("Localizable.strings") + .path + let stringsDictPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("Localizable.stringsdict") + .path + + #expect(FileManager.default.fileExists(atPath: stringsPath)) + #expect(FileManager.default.fileExists(atPath: stringsDictPath)) + + let stringsContent = try String(contentsOfFile: stringsPath, encoding: .utf8) + #expect(stringsContent.contains(#""greeting" = "Hello";"#)) + #expect(!stringsContent.contains("items")) + } + + // MARK: - Plist prefix routing + + @Test + func plistPrefixRoutesToSeparateFile() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "greeting", value: "Hello"), + LocRow(key: "plist.InfoPlist.CFBundleDisplayName", value: "My App") + ] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: [ + "Localizable": tempDir.path, + "InfoPlist": tempDir.path + ] + ) + + let localizablePath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("Localizable.strings") + .path + let infoPlistPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("InfoPlist.strings") + .path + + #expect(FileManager.default.fileExists(atPath: localizablePath)) + #expect(FileManager.default.fileExists(atPath: infoPlistPath)) + + let localizableContent = try String(contentsOfFile: localizablePath, encoding: .utf8) + #expect(localizableContent.contains(#""greeting" = "Hello";"#)) + + let infoPlistContent = try String(contentsOfFile: infoPlistPath, encoding: .utf8) + #expect(infoPlistContent.contains(#""CFBundleDisplayName" = "My App";"#)) + } + + @Test + func plistPrefixStripsKeyPrefix() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "plist.InfoPlist.NSCameraUsageDescription", value: "Camera needed") + ] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: [ + "Localizable": tempDir.path, + "InfoPlist": tempDir.path + ] + ) + + let infoPlistPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("InfoPlist.strings") + .path + + let content = try String(contentsOfFile: infoPlistPath, encoding: .utf8) + // The key should be "NSCameraUsageDescription", not "plist.InfoPlist.NSCameraUsageDescription" + #expect(content.contains(#""NSCameraUsageDescription" = "Camera needed";"#)) + #expect(!content.contains("plist.InfoPlist")) + } + + // MARK: - Default file name suffix stripping + + @Test + func defaultFileNameStripsStringsSuffix() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mappedValues: MappedValues = [ + "en": [LocRow(key: "key", value: "value")] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable.strings", + destinations: ["Localizable": tempDir.path] + ) + + let stringsPath = tempDir + .appendingPathComponent("en.lproj") + .appendingPathComponent("Localizable.strings") + .path + + #expect(FileManager.default.fileExists(atPath: stringsPath)) + } + + // MARK: - Duplicate keys + + @Test + func duplicateKeysThrowsOnSave() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "key", value: "value1"), + LocRow(key: "key", value: "value2") + ] + ] + + #expect(throws: (any Error).self) { + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: ["Localizable": tempDir.path] + ) + } + } + + // MARK: - Empty mapped values + + @Test + func emptyMappedValuesDoesNotThrow() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + #expect(throws: Never.self) { + try localization.saveMappedValues( + [:], + defaultFileName: "Localizable", + destinations: ["Localizable": tempDir.path] + ) + } + } +} diff --git a/Tests/ACKLocalizationCoreTests/ValueRangeTests.swift b/Tests/ACKLocalizationCoreTests/ValueRangeTests.swift new file mode 100644 index 0000000..74532b6 --- /dev/null +++ b/Tests/ACKLocalizationCoreTests/ValueRangeTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import ACKLocalizationCore + +@Suite +struct ValueRangeTests { + // MARK: - firstIndex(columnName:) + + @Test + func firstIndexFindsColumn() { + let range = ValueRange(values: [["keys", "cs", "en"]]) + + #expect(range.firstIndex(columnName: "keys") == 0) + #expect(range.firstIndex(columnName: "cs") == 1) + #expect(range.firstIndex(columnName: "en") == 2) + } + + @Test + func firstIndexReturnsNilForMissingColumn() { + let range = ValueRange(values: [["keys", "cs"]]) + + #expect(range.firstIndex(columnName: "de") == nil) + } + + @Test + func firstIndexEmptyValues() { + let range = ValueRange(values: []) + + #expect(range.firstIndex(columnName: "keys") == nil) + } + + @Test + func firstIndexEmptyHeaderRow() { + let range = ValueRange(values: [[]]) + + #expect(range.firstIndex(columnName: "keys") == nil) + } + + // MARK: - Decoding edge cases + + @Test + func decodesEmptyValues() throws { + let json = #"{"values":[]}"# + let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) + + #expect(valueRange.values.isEmpty) + } + + @Test + func decodesEmptyRow() throws { + let json = #"{"values":[[]]}"# + let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) + + #expect(valueRange.values == [[]]) + } + + @Test + func decodesUnsupportedCellTypeAsEmpty() throws { + // Boolean values are not handled by any specific case, should fall through to empty string + let json = #"{"values":[["key", true]]}"# + let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) + + #expect(valueRange.values == [["key", ""]]) + } + + @Test + func decodesNegativeNumbers() throws { + let json = #"{"values":[["key","en"],["count",-5]]}"# + let valueRange = try JSONDecoder().decode(ValueRange.self, from: Data(json.utf8)) + + #expect(valueRange.values[1] == ["count", "-5"]) + } + + // MARK: - Init with values + + @Test + func initWithValues() { + let range = ValueRange(values: [["a", "b"], ["c", "d"]]) + + #expect(range.values == [["a", "b"], ["c", "d"]]) + } +} From 46c7c4f6663ee6d6841b87ac09497649d5b7294f Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Thu, 19 Mar 2026 18:46:26 +0100 Subject: [PATCH 03/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Introduce=20FileSyst?= =?UTF-8?q?em=20protocol=20to=20decouple=20file=20I/O=20from=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract filesystem operations behind an internal FileSystem protocol so SaveMappedValuesTests can use an in-memory mock instead of writing to temp directories on disk. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ACKLocalizationCore/ACKLocalization.swift | 28 +++- .../Services/FileSystem.swift | 32 ++++ .../Mocks/FileSystemMock.swift | 25 +++ .../SaveMappedValuesTests.swift | 145 +++++------------- 4 files changed, 115 insertions(+), 115 deletions(-) create mode 100644 Sources/ACKLocalizationCore/Services/FileSystem.swift create mode 100644 Tests/ACKLocalizationCoreTests/Mocks/FileSystemMock.swift diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index 83ab04e..61097bf 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -9,13 +9,22 @@ public typealias MappedValues = [String: [LocRow]] public final class ACKLocalization { /// Spreadsheet API used to fetch spreadsheet content private let sheetsAPI: SheetsAPIServicing - + + /// Filesystem abstraction for writing output files + private let fileSystem: FileSystem + private var fetchCancellable: Cancellable? - + // MARK: - Initializers - + public init(sheetsAPI: SheetsAPIServicing = SheetsAPIService()) { self.sheetsAPI = sheetsAPI + self.fileSystem = DefaultFileSystem() + } + + init(sheetsAPI: SheetsAPIServicing, fileSystem: FileSystem) { + self.sheetsAPI = sheetsAPI + self.fileSystem = fileSystem } // MARK: - Public interface @@ -250,7 +259,7 @@ public final class ACKLocalization { let dirPath = ((path as NSString).expandingTildeInPath as NSString) .appendingPathComponent(fileRows.language + ".lproj") - try? FileManager.default.createDirectory(atPath: dirPath, withIntermediateDirectories: true) + try? fileSystem.createDirectory(atPath: dirPath, withIntermediateDirectories: true) // Collection of plural rules for a given translation key. // Translation key is the base without the suffix ##{plural-rule} @@ -270,7 +279,7 @@ public final class ACKLocalization { let encoder = PropertyListEncoder() encoder.outputFormat = .xml let data = try encoder.encode(plurals) - try data.write(to: URL(fileURLWithPath: stringsDictPath)) + try fileSystem.writeData(data, to: URL(fileURLWithPath: stringsDictPath)) } } } @@ -381,9 +390,12 @@ public final class ACKLocalization { try checkDuplicateKeys(form: rows) - try rows.map { $0.localizableRow } - .joined(separator: "\n") - .write(toFile: file, atomically: true, encoding: .utf8) + try fileSystem.writeString( + rows.map { $0.localizableRow }.joined(separator: "\n"), + toFile: file, + atomically: true, + encoding: .utf8 + ) } /// Check if given `rows` have a duplicated keys diff --git a/Sources/ACKLocalizationCore/Services/FileSystem.swift b/Sources/ACKLocalizationCore/Services/FileSystem.swift new file mode 100644 index 0000000..a2e0b93 --- /dev/null +++ b/Sources/ACKLocalizationCore/Services/FileSystem.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Protocol abstracting filesystem operations for testability +protocol FileSystem { + /// Creates a directory at the given path + func createDirectory(atPath path: String, withIntermediateDirectories: Bool) throws + + /// Writes a string to a file + func writeString(_ string: String, toFile path: String, atomically: Bool, encoding: String.Encoding) throws + + /// Writes data to a file + func writeData(_ data: Data, to url: URL) throws +} + +/// Default implementation that delegates to real filesystem APIs +struct DefaultFileSystem: FileSystem { + + func createDirectory(atPath path: String, withIntermediateDirectories: Bool) throws { + try FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: withIntermediateDirectories + ) + } + + func writeString(_ string: String, toFile path: String, atomically: Bool, encoding: String.Encoding) throws { + try string.write(toFile: path, atomically: atomically, encoding: encoding) + } + + func writeData(_ data: Data, to url: URL) throws { + try data.write(to: url) + } +} diff --git a/Tests/ACKLocalizationCoreTests/Mocks/FileSystemMock.swift b/Tests/ACKLocalizationCoreTests/Mocks/FileSystemMock.swift new file mode 100644 index 0000000..32b9a36 --- /dev/null +++ b/Tests/ACKLocalizationCoreTests/Mocks/FileSystemMock.swift @@ -0,0 +1,25 @@ +import Foundation +@testable import ACKLocalizationCore + +final class FileSystemMock: FileSystem { + /// Directories that were created, keyed by path + private(set) var createdDirectories: [String] = [] + + /// String files written, keyed by path + private(set) var writtenStrings: [String: String] = [:] + + /// Data files written, keyed by URL path + private(set) var writtenData: [String: Data] = [:] + + func createDirectory(atPath path: String, withIntermediateDirectories: Bool) throws { + createdDirectories.append(path) + } + + func writeString(_ string: String, toFile path: String, atomically: Bool, encoding: String.Encoding) throws { + writtenStrings[path] = string + } + + func writeData(_ data: Data, to url: URL) throws { + writtenData[url.path] = data + } +} diff --git a/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift b/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift index ecb75ab..a78c94b 100644 --- a/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift +++ b/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift @@ -6,22 +6,18 @@ import Testing struct SaveMappedValuesTests { private let localization: ACKLocalization private let sheetsAPI: SheetsAPIServiceMock + private let fileSystem: FileSystemMock init() { sheetsAPI = SheetsAPIServiceMock() - localization = ACKLocalization(sheetsAPI: sheetsAPI) + fileSystem = FileSystemMock() + localization = ACKLocalization(sheetsAPI: sheetsAPI, fileSystem: fileSystem) } // MARK: - File writing @Test func writesStringsFile() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - - let destPath = tempDir.path - let mappedValues: MappedValues = [ "en": [ LocRow(key: "greeting", value: "Hello"), @@ -32,27 +28,20 @@ struct SaveMappedValuesTests { try localization.saveMappedValues( mappedValues, defaultFileName: "Localizable", - destinations: ["Localizable": destPath] + destinations: ["Localizable": "/output"] ) - let stringsPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("Localizable.strings") - .path + let stringsPath = "/output/en.lproj/Localizable.strings" - #expect(FileManager.default.fileExists(atPath: stringsPath)) + #expect(fileSystem.writtenStrings[stringsPath] != nil) - let content = try String(contentsOfFile: stringsPath, encoding: .utf8) + let content = fileSystem.writtenStrings[stringsPath]! #expect(content.contains(#""greeting" = "Hello";"#)) #expect(content.contains(#""farewell" = "Goodbye";"#)) } @Test func createsLprojDirectories() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - let mappedValues: MappedValues = [ "en": [LocRow(key: "key", value: "en_value")], "cs": [LocRow(key: "key", value: "cs_value")] @@ -61,22 +50,15 @@ struct SaveMappedValuesTests { try localization.saveMappedValues( mappedValues, defaultFileName: "Localizable", - destinations: ["Localizable": tempDir.path] + destinations: ["Localizable": "/output"] ) - let enDir = tempDir.appendingPathComponent("en.lproj").path - let csDir = tempDir.appendingPathComponent("cs.lproj").path - - #expect(FileManager.default.fileExists(atPath: enDir)) - #expect(FileManager.default.fileExists(atPath: csDir)) + #expect(fileSystem.createdDirectories.contains("/output/en.lproj")) + #expect(fileSystem.createdDirectories.contains("/output/cs.lproj")) } @Test func writesStringsDictForPlurals() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - let mappedValues: MappedValues = [ "en": [ LocRow(key: "items##{one}", value: "%d item"), @@ -87,31 +69,19 @@ struct SaveMappedValuesTests { try localization.saveMappedValues( mappedValues, defaultFileName: "Localizable", - destinations: ["Localizable": tempDir.path] + destinations: ["Localizable": "/output"] ) - let stringsDictPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("Localizable.stringsdict") - .path - - #expect(FileManager.default.fileExists(atPath: stringsDictPath)) + let stringsDictPath = "/output/en.lproj/Localizable.stringsdict" + let stringsPath = "/output/en.lproj/Localizable.strings" + #expect(fileSystem.writtenData[stringsDictPath] != nil) // .strings should NOT exist since all rows are plurals - let stringsPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("Localizable.strings") - .path - - #expect(!FileManager.default.fileExists(atPath: stringsPath)) + #expect(fileSystem.writtenStrings[stringsPath] == nil) } @Test func separatesPluralsFromRegularKeys() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - let mappedValues: MappedValues = [ "en": [ LocRow(key: "greeting", value: "Hello"), @@ -123,22 +93,16 @@ struct SaveMappedValuesTests { try localization.saveMappedValues( mappedValues, defaultFileName: "Localizable", - destinations: ["Localizable": tempDir.path] + destinations: ["Localizable": "/output"] ) - let stringsPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("Localizable.strings") - .path - let stringsDictPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("Localizable.stringsdict") - .path + let stringsPath = "/output/en.lproj/Localizable.strings" + let stringsDictPath = "/output/en.lproj/Localizable.stringsdict" - #expect(FileManager.default.fileExists(atPath: stringsPath)) - #expect(FileManager.default.fileExists(atPath: stringsDictPath)) + #expect(fileSystem.writtenStrings[stringsPath] != nil) + #expect(fileSystem.writtenData[stringsDictPath] != nil) - let stringsContent = try String(contentsOfFile: stringsPath, encoding: .utf8) + let stringsContent = fileSystem.writtenStrings[stringsPath]! #expect(stringsContent.contains(#""greeting" = "Hello";"#)) #expect(!stringsContent.contains("items")) } @@ -147,10 +111,6 @@ struct SaveMappedValuesTests { @Test func plistPrefixRoutesToSeparateFile() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - let mappedValues: MappedValues = [ "en": [ LocRow(key: "greeting", value: "Hello"), @@ -162,36 +122,26 @@ struct SaveMappedValuesTests { mappedValues, defaultFileName: "Localizable", destinations: [ - "Localizable": tempDir.path, - "InfoPlist": tempDir.path + "Localizable": "/output", + "InfoPlist": "/output" ] ) - let localizablePath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("Localizable.strings") - .path - let infoPlistPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("InfoPlist.strings") - .path + let localizablePath = "/output/en.lproj/Localizable.strings" + let infoPlistPath = "/output/en.lproj/InfoPlist.strings" - #expect(FileManager.default.fileExists(atPath: localizablePath)) - #expect(FileManager.default.fileExists(atPath: infoPlistPath)) + #expect(fileSystem.writtenStrings[localizablePath] != nil) + #expect(fileSystem.writtenStrings[infoPlistPath] != nil) - let localizableContent = try String(contentsOfFile: localizablePath, encoding: .utf8) + let localizableContent = fileSystem.writtenStrings[localizablePath]! #expect(localizableContent.contains(#""greeting" = "Hello";"#)) - let infoPlistContent = try String(contentsOfFile: infoPlistPath, encoding: .utf8) + let infoPlistContent = fileSystem.writtenStrings[infoPlistPath]! #expect(infoPlistContent.contains(#""CFBundleDisplayName" = "My App";"#)) } @Test func plistPrefixStripsKeyPrefix() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - let mappedValues: MappedValues = [ "en": [ LocRow(key: "plist.InfoPlist.NSCameraUsageDescription", value: "Camera needed") @@ -202,17 +152,14 @@ struct SaveMappedValuesTests { mappedValues, defaultFileName: "Localizable", destinations: [ - "Localizable": tempDir.path, - "InfoPlist": tempDir.path + "Localizable": "/output", + "InfoPlist": "/output" ] ) - let infoPlistPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("InfoPlist.strings") - .path + let infoPlistPath = "/output/en.lproj/InfoPlist.strings" - let content = try String(contentsOfFile: infoPlistPath, encoding: .utf8) + let content = fileSystem.writtenStrings[infoPlistPath]! // The key should be "NSCameraUsageDescription", not "plist.InfoPlist.NSCameraUsageDescription" #expect(content.contains(#""NSCameraUsageDescription" = "Camera needed";"#)) #expect(!content.contains("plist.InfoPlist")) @@ -222,10 +169,6 @@ struct SaveMappedValuesTests { @Test func defaultFileNameStripsStringsSuffix() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - let mappedValues: MappedValues = [ "en": [LocRow(key: "key", value: "value")] ] @@ -233,25 +176,17 @@ struct SaveMappedValuesTests { try localization.saveMappedValues( mappedValues, defaultFileName: "Localizable.strings", - destinations: ["Localizable": tempDir.path] + destinations: ["Localizable": "/output"] ) - let stringsPath = tempDir - .appendingPathComponent("en.lproj") - .appendingPathComponent("Localizable.strings") - .path - - #expect(FileManager.default.fileExists(atPath: stringsPath)) + let stringsPath = "/output/en.lproj/Localizable.strings" + #expect(fileSystem.writtenStrings[stringsPath] != nil) } // MARK: - Duplicate keys @Test func duplicateKeysThrowsOnSave() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - let mappedValues: MappedValues = [ "en": [ LocRow(key: "key", value: "value1"), @@ -263,7 +198,7 @@ struct SaveMappedValuesTests { try localization.saveMappedValues( mappedValues, defaultFileName: "Localizable", - destinations: ["Localizable": tempDir.path] + destinations: ["Localizable": "/output"] ) } } @@ -272,15 +207,11 @@ struct SaveMappedValuesTests { @Test func emptyMappedValuesDoesNotThrow() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tempDir) } - #expect(throws: Never.self) { try localization.saveMappedValues( [:], defaultFileName: "Localizable", - destinations: ["Localizable": tempDir.path] + destinations: ["Localizable": "/output"] ) } } From 2b2d955c1476555725e0830aa021c5fdb58548df Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 20 Mar 2026 00:30:13 +0100 Subject: [PATCH 04/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Replace=20internal?= =?UTF-8?q?=20Combine=20usage=20with=20async/await?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Combine API out of protocol definitions and core class into a dedicated CombineExtensions.swift that forwards to async/sync implementations. SheetsAPIServicing now defines only async methods, run() is fully async, and URLSessionExtensions.swift is removed. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/ACKLocalization/main.swift | 10 +- .../ACKLocalizationCore/ACKLocalization.swift | 374 ++++++------------ .../Extensions/CombineExtensions.swift | 186 +++++++++ .../Extensions/URLSessionExtensions.swift | 35 -- .../Services/SheetsAPIService.swift | 74 ++-- .../Mocks/SheetsAPIServiceMock.swift | 15 +- 6 files changed, 364 insertions(+), 330 deletions(-) create mode 100644 Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift delete mode 100644 Sources/ACKLocalizationCore/Extensions/URLSessionExtensions.swift diff --git a/Sources/ACKLocalization/main.swift b/Sources/ACKLocalization/main.swift index 57037f9..8b72f69 100644 --- a/Sources/ACKLocalization/main.swift +++ b/Sources/ACKLocalization/main.swift @@ -1,13 +1,5 @@ -// -// main.swift -// -// -// Created by Jakub Olejník on 11/12/2019. -// - import ACKLocalizationCore -import Foundation let localization = ACKLocalization() -localization.run() +await localization.run() diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index 61097bf..7c4351f 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import GoogleAuth @@ -13,8 +12,6 @@ public final class ACKLocalization { /// Filesystem abstraction for writing output files private let fileSystem: FileSystem - private var fetchCancellable: Cancellable? - // MARK: - Initializers public init(sheetsAPI: SheetsAPIServicing = SheetsAPIService()) { @@ -26,36 +23,38 @@ public final class ACKLocalization { self.sheetsAPI = sheetsAPI self.fileSystem = fileSystem } - + // MARK: - Public interface - + /// Main that loads configuration from _localization.json_, fetches access token and loads content of spreadsheet - public func run() { - callThrowingCode { + public func run() async { + do { let config = try loadConfiguration() - try run(configuration: config) + try await run(configuration: config) + } catch let error as LocalizationError { + displayError(error) + exit(1) + } catch { + print(error) + exit(1) } } - - public func run(configuration config: Configuration) throws { - let dispatchGroup = DispatchGroup() - - dispatchGroup.enter() - fetchCancellable = fetchSheetValues(config) - .flatMap { [weak self] in self?.transformValuesPublisher($0, with: config) ?? Fail(error: LocalizationError(message: "Unable to transform values")).eraseToAnyPublisher() } - .flatMap { [weak self] in self?.saveMappedValuesPublisher($0, config: config) ?? Fail(error: LocalizationError(message: "Unable to save mapped values")).eraseToAnyPublisher() } - .sink(receiveCompletion: { [weak self] result in - switch result { - case .failure(let error): - self?.displayError(error) - exit(1) - case .finished: self?.displaySuccess() - } - dispatchGroup.leave() - }) { _ in } - dispatchGroup.wait() + + public func run(configuration config: Configuration) async throws { + let values = try await fetchSheetValues(config) + let mapped = try transformValues( + values, + with: config.languageMapping, + keyColumnName: config.keyColumnName + ) + try saveMappedValues( + mapped, + defaultFileName: config.defaultFileName, + destinations: config.destinations + ) + displaySuccess() } - + /// Fetches content of given sheet from spreadsheet using given `serviceAccount` /// /// If not `spreadsheetTabName` is provided, the first in the spreadsheet is used @@ -63,137 +62,129 @@ public final class ACKLocalization { _ spreadsheetTabName: String?, spreadsheetId: String, serviceAccountPath: String? - ) -> AnyPublisher { - let sheetsAPI = self.sheetsAPI - - return fetchGoogleAccessToken(serviceAccountPath: serviceAccountPath) - .handleEvents(receiveOutput: { sheetsAPI.credentials = $0 }) - .map { _ in } - .flatMap { sheetsAPI.fetchSpreadsheet(spreadsheetId) } - .flatMap { sheetsAPI.fetchSheet(spreadsheetTabName, from: $0) } - .mapError(LocalizationError.init) - .eraseToAnyPublisher() + ) async throws -> ValueRange { + let token = try await fetchGoogleAccessToken(serviceAccountPath: serviceAccountPath) + sheetsAPI.credentials = token + let spreadsheet = try await sheetsAPI.fetchSpreadsheet(spreadsheetId) + return try await sheetsAPI.fetchSheet(spreadsheetTabName, from: spreadsheet) } - public func fetchSheetValues(_ spreadsheetTabName: String?, spreadsheetId: String, apiKey: APIKey) -> AnyPublisher { - let sheetsAPI = self.sheetsAPI + public func fetchSheetValues( + _ spreadsheetTabName: String?, + spreadsheetId: String, + apiKey: APIKey + ) async throws -> ValueRange { sheetsAPI.credentials = apiKey - - return sheetsAPI.fetchSpreadsheet(spreadsheetId) - .flatMap { sheetsAPI.fetchSheet(spreadsheetTabName, from: $0) } - .mapError(LocalizationError.init) - .eraseToAnyPublisher() + let spreadsheet = try await sheetsAPI.fetchSpreadsheet(spreadsheetId) + return try await sheetsAPI.fetchSheet(spreadsheetTabName, from: spreadsheet) + } + + /// Fetches sheet values from given `config` + public func fetchSheetValues(_ config: Configuration) async throws -> ValueRange { + if let serviceAccountPath = config.serviceAccount { + return try await fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + serviceAccountPath: serviceAccountPath + ) + } else if let apiKey = config.apiKey { + return try await fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + apiKey: apiKey + ) + } else if let serviceAccountPath = ProcessInfo.processInfo.environment[Constants.serviceAccountPath] { + return try await fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + serviceAccountPath: serviceAccountPath + ) + } else if let apiKey = ProcessInfo.processInfo.environment[Constants.apiKey] { + let apiKey = APIKey(value: apiKey) + return try await fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + apiKey: apiKey + ) + } else { + return try await fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + serviceAccountPath: nil + ) + } } - + /// Transforms given value range (content of spreadsheet) using given language mapping to `MappedValue` which can be written out to output file public func transformValues(_ valueRange: ValueRange, with mapping: LanguageMapping, keyColumnName: String) throws -> MappedValues { // check that we have any column, that contains string keys guard let keyColIndex = valueRange.firstIndex(columnName: keyColumnName) else { throw LocalizationError(message: "Unable to find column named `" + keyColumnName + "` in the first sheet row") } - + // spreadsheet contains only header row guard valueRange.values.count > 1 else { return [:] } - + var result = MappedValues() - + // skip first row as that is the header row valueRange.values[1...].forEach { rowValues in // skip rows which do not contain a key guard let key = rowValues[safe: keyColIndex], key.count > 0 else { return } - + mapping.forEach { sheetColName, langCode in // find index of current language guard let langIndex = valueRange.firstIndex(columnName: sheetColName) else { return } - + let value = LocRow(key: key, value: rowValues[safe: langIndex] ?? "") - + var langRows = result[langCode] ?? [] langRows.append(value) result[langCode] = langRows } } - + return result } - - /// Transforms given value range (content of spreadsheet) using given language mapping to `MappedValue` which can be written out to output file - public func transformValuesPublisher(_ valueRange: ValueRange, with mapping: LanguageMapping, keyColumnName: String) -> AnyPublisher { - Future { [weak self] promise in - guard let self = self else { - promise(.failure(LocalizationError(message: "Unable to transform values"))) - return - } - - do { - let transformedValues = try self.transformValues(valueRange, with: mapping, keyColumnName: keyColumnName) - promise(.success(transformedValues)) - } catch { - switch error { - case let localizationError as LocalizationError: promise(.failure(localizationError)) - default: promise(.failure(LocalizationError(message: error.localizedDescription))) - } - } - }.eraseToAnyPublisher() - } - - /// Transforms given value range (content of spreadsheet) using given config to `MappedValue` which can be written out to output file - public func transformValuesPublisher(_ valueRange: ValueRange, with config: Configuration) -> AnyPublisher { - transformValuesPublisher(valueRange, with: config.languageMapping, keyColumnName: config.keyColumnName) - } - + /// Builds plurals from `rows` of each language /// /// - Parameter `rows`: All translations of the selected language /// - Returns: Plural keys that are specified in `rows` func buildPlurals(from rows: [LocRow]) throws -> [String: PluralRuleWrapper] { var plurals: [String: PluralRuleWrapper] = [:] - + let regular = try NSRegularExpression(pattern: Constants.pluralPattern, options: []) - + try rows.forEach { - // Try to split the translation key into the actual key and the plural rule key - // based on the predefined regular expression let matches = regular.matches(in: $0.key, options: [], range: NSRange(location: 0, length: $0.key.utf16.count)) - - // Skip key which doesn't contain the translation rule - // There should be always exactly one match + guard let match = matches.first else { return } - - // Index 0 – range of the whole string - // Index 1 – range of the translation key + let translationKeyRange = match.range(at: 1) - - // Check if the actual translation key is presented + guard translationKeyRange.location != NSNotFound else { throw PluralError.missingTranslationKey($0.key) } - // Get the actual translation key from the `translationKeyRange` let translationKey = ($0.key as NSString).substring(with: translationKeyRange) - // Index 2 – range of the plural rule let pluralRuleRange = match.range(at: 2) - - // Check if the plural rule is presented + guard pluralRuleRange.location != NSNotFound else { throw PluralError.missingPluralRule($0.key) } - - // Get the plural rule from the `pluralRuleRange` + let pluralRuleString = ($0.key as NSString).substring(with: pluralRuleRange) - // Check if the plural rule is valid guard let pluralRuleKey = PluralRuleKey(rawValue: pluralRuleString) else { throw PluralError.invalidPluralRule($0.key) } - - // Load all translations for the given key + var currentTranslations = plurals[translationKey]?.translations ?? [] - - // Create new rule and add it to the other rules + let translation = PluralRule(key: pluralRuleKey, value: $0.value) currentTranslations.append(translation) plurals[translationKey] = PluralRuleWrapper(translations: currentTranslations) } - + return plurals } - + /// Saves given `mappedValues` to correct directory file public func saveMappedValues( _ mappedValues: MappedValues, @@ -205,13 +196,13 @@ public final class ACKLocalization { let fileName: String let rows: [LocRow] } - + let defaultFileName = defaultFileName.removingSuffix(".strings") .removingSuffix(".stringsdict") let rowsPerFile = mappedValues.flatMap { langCode, rows in let fileGroups = [String: [LocRow]](grouping: rows) { row in let keyComponents = row.key.components(separatedBy: ".") - + guard row.key.hasPrefix(Constants.plistKeyPrefix + "."), keyComponents.count > 2 @@ -221,14 +212,14 @@ public final class ACKLocalization { return keyComponents[1] } - + return fileGroups.map { fileName, rows in RowsPerFile( language: langCode, fileName: fileName, rows: rows.map { row in let keyComponents = row.key.components(separatedBy: ".") - + if row.key.hasPrefix(Constants.plistKeyPrefix + "."), keyComponents.count > 2 { return LocRow( @@ -236,46 +227,43 @@ public final class ACKLocalization { value: row.value ) } - + return row } ) } } - + let defaultDestination = destinations[defaultFileName] - + if defaultDestination == nil { warn("No destination for default strings file '\(defaultFileName)'") warn("This means that all keys in localization sheet need to have file specified (using `plist..` prefix) and all such files need to have its path defined in `destinations` dictionary") } - + try rowsPerFile.forEach { fileRows in guard let path = destinations[fileRows.fileName] ?? defaultDestination else { warn("No destination path found for '\(fileRows.fileName)' strings file") return } - + let dirPath = ((path as NSString).expandingTildeInPath as NSString) .appendingPathComponent(fileRows.language + ".lproj") - + try? fileSystem.createDirectory(atPath: dirPath, withIntermediateDirectories: true) - - // Collection of plural rules for a given translation key. - // Translation key is the base without the suffix ##{plural-rule} + let plurals = try buildPlurals(from: fileRows.rows) let nonPlurals = fileRows.rows.filter { !$0.isPlural } - + if !nonPlurals.isEmpty { let stringsPath = (dirPath as NSString) .appendingPathComponent(fileRows.fileName + ".strings") try writeRows(nonPlurals, to: stringsPath) } - + if !plurals.isEmpty { let stringsDictPath = (dirPath as NSString) .appendingPathComponent(fileRows.fileName + ".stringsdict") - // Create stringDict from data and save it let encoder = PropertyListEncoder() encoder.outputFormat = .xml let data = try encoder.encode(plurals) @@ -283,99 +271,39 @@ public final class ACKLocalization { } } } - - /// Saves given `mappedValues` to correct directory file - public func saveMappedValuesPublisher( - _ mappedValues: MappedValues, - defaultFileName: String, - destinations: [String: String] - ) -> AnyPublisher { - Future { [weak self] promise in - guard let self = self else { - promise(.failure(LocalizationError(message: "Unable to save mapped values"))) - return - } - - do { - try self.saveMappedValues( - mappedValues, - defaultFileName: defaultFileName, - destinations: destinations - ) - promise(.success(())) - } catch { - switch error { - case let localizationError as LocalizationError: promise(.failure(localizationError)) - default: promise(.failure(LocalizationError(message: error.localizedDescription))) - } - } - }.eraseToAnyPublisher() - } - - /// Saves given `mappedValues` to correct directory file - public func saveMappedValuesPublisher( - _ mappedValues: MappedValues, - config: Configuration - ) -> AnyPublisher { - saveMappedValuesPublisher( - mappedValues, - defaultFileName: config.defaultFileName, - destinations: config.destinations - ) - } - - /// Fetches sheet values from given `config` - public func fetchSheetValues(_ config: Configuration) -> AnyPublisher { - if let serviceAccountPath = config.serviceAccount { - return fetchSheetValues( - config.spreadsheetTabName, - spreadsheetId: config.spreadsheetID, - serviceAccountPath: serviceAccountPath - ) - } else if let apiKey = config.apiKey { - return fetchSheetValues( - config.spreadsheetTabName, - spreadsheetId: config.spreadsheetID, - apiKey: apiKey - ) - } else if let serviceAccountPath = ProcessInfo.processInfo.environment[Constants.serviceAccountPath] { - return fetchSheetValues( - config.spreadsheetTabName, - spreadsheetId: config.spreadsheetID, - serviceAccountPath: serviceAccountPath - ) - } else if let apiKey = ProcessInfo.processInfo.environment[Constants.apiKey] { - let apiKey = APIKey(value: apiKey) - return fetchSheetValues( - config.spreadsheetTabName, - spreadsheetId: config.spreadsheetID, - apiKey: apiKey + + // MARK: - Private helpers + + /// Fetches Google access token using async/await + private func fetchGoogleAccessToken(serviceAccountPath: String?) async throws -> Token { + let scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"] + let tokenProvider: TokenProvider + + if let serviceAccountPath { + tokenProvider = try await ServiceAccountTokenProvider( + serviceAccountPath: serviceAccountPath, + scopes: scopes ) + } else if let tp = await DefaultCredentialsTokenProvider(scopes: scopes) { + tokenProvider = tp } else { - return fetchSheetValues( - config.spreadsheetTabName, - spreadsheetId: config.spreadsheetID, - serviceAccountPath: nil - ) + throw RequestError(message: "Unable to instantiate token provider") } + + return try await tokenProvider.token() } - - // MARK: - Private helpers - + /// Loads configuration from `localization.json` file private func loadConfiguration() throws -> Configuration { guard let configData = FileManager.default.contents(atPath: "localization.json") else { throw LocalizationError(message: "Unable to find `localization.json` config file. Does it exist in current directory?") } - + let decoder = JSONDecoder() - + do { return try decoder.decode(Configuration.self, from: configData) } catch { - // For backwards compatibility we will try to decode old version of Configuration - // if that fails we will throw error with original message as we wanna encourage - // usage of latest Configuration version if let v1Config = try? decoder.decode(ConfigurationV1.self, from: configData) { return Configuration(v1Config: v1Config) } else { @@ -383,7 +311,7 @@ public final class ACKLocalization { } } } - + /// Actually writes given `rows` to given `file` private func writeRows(_ rows: [LocRow], to file: String) throws { guard rows.count > 0 else { return } @@ -415,55 +343,11 @@ public final class ACKLocalization { let message = "❌ " + localizationError.message FileHandle.standardError.write(message.data(using: .utf8)!) } - + /// Displays success to stdout private func displaySuccess() { print("✅ Successfully generated localizations!") } - - /// Calls throwing code and deals with its errors - private func callThrowingCode(code: (() throws -> Void)) { - do { - try code() - } catch { - switch error { - case let localizationError as LocalizationError: - displayError(localizationError) - default: - print(error) - } - exit(1) - } - } - - private func fetchGoogleAccessToken(serviceAccountPath: String?) -> AnyPublisher { - Future { promise in - Task { - do { - let tokenProvider: TokenProvider - let scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"] - - if let serviceAccountPath { - tokenProvider = try await ServiceAccountTokenProvider( - serviceAccountPath: serviceAccountPath, - scopes: scopes - ) - } else if let tp = await DefaultCredentialsTokenProvider(scopes: scopes) { - tokenProvider = tp - } else { - throw RequestError(message: "Unable to instantiate token provider") - } - - let token = try await tokenProvider.token() - promise(.success(token)) - } catch let error as TokenProviderError { - promise(.failure(RequestError(underlyingError: error))) - } catch let error as RequestError { - promise(.failure(error)) - } - } - }.eraseToAnyPublisher() - } } extension String { diff --git a/Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift b/Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift new file mode 100644 index 0000000..54e48b3 --- /dev/null +++ b/Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift @@ -0,0 +1,186 @@ +import Combine +import Foundation + +// MARK: - SheetsAPIServicing Combine extensions + +public extension SheetsAPIServicing { + /// Fetch information about given spreadsheet + func fetchSpreadsheet(_ identifier: String) -> AnyPublisher { + Future { [weak self] promise in + Task { + do { + guard let result = try await self?.fetchSpreadsheet(identifier) else { + promise(.failure(RequestError(message: "Unable to fetch spreadsheet"))) + return + } + promise(.success(result)) + } catch let error as RequestError { + promise(.failure(error)) + } catch { + promise(.failure(RequestError(underlyingError: error))) + } + } + }.eraseToAnyPublisher() + } + + /// Fetch content of given sheet from given spreadsheet + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) -> AnyPublisher { + Future { [weak self] promise in + Task { + do { + guard let result = try await self?.fetchSheet(sheetName, from: spreadsheet) else { + promise(.failure(RequestError(message: "Unable to fetch sheet"))) + return + } + promise(.success(result)) + } catch let error as RequestError { + promise(.failure(error)) + } catch { + promise(.failure(RequestError(underlyingError: error))) + } + } + }.eraseToAnyPublisher() + } +} + +// MARK: - ACKLocalization Combine extensions + +public extension ACKLocalization { + /// Fetches content of given sheet from spreadsheet using given `serviceAccount` + /// + /// If not `spreadsheetTabName` is provided, the first in the spreadsheet is used + func fetchSheetValues( + _ spreadsheetTabName: String?, + spreadsheetId: String, + serviceAccountPath: String? + ) -> AnyPublisher { + Future { [weak self] promise in + Task { + do { + guard let result = try await self?.fetchSheetValues( + spreadsheetTabName, + spreadsheetId: spreadsheetId, + serviceAccountPath: serviceAccountPath + ) else { + promise(.failure(LocalizationError(message: "Unable to fetch sheet values"))) + return + } + promise(.success(result)) + } catch let error as LocalizationError { + promise(.failure(error)) + } catch { + promise(.failure(LocalizationError(message: error.localizedDescription))) + } + } + }.eraseToAnyPublisher() + } + + func fetchSheetValues( + _ spreadsheetTabName: String?, + spreadsheetId: String, + apiKey: APIKey + ) -> AnyPublisher { + Future { [weak self] promise in + Task { + do { + guard let result = try await self?.fetchSheetValues( + spreadsheetTabName, + spreadsheetId: spreadsheetId, + apiKey: apiKey + ) else { + promise(.failure(LocalizationError(message: "Unable to fetch sheet values"))) + return + } + promise(.success(result)) + } catch let error as LocalizationError { + promise(.failure(error)) + } catch { + promise(.failure(LocalizationError(message: error.localizedDescription))) + } + } + }.eraseToAnyPublisher() + } + + /// Fetches sheet values from given `config` + func fetchSheetValues(_ config: Configuration) -> AnyPublisher { + Future { [weak self] promise in + Task { + do { + guard let result = try await self?.fetchSheetValues(config) else { + promise(.failure(LocalizationError(message: "Unable to fetch sheet values"))) + return + } + promise(.success(result)) + } catch let error as LocalizationError { + promise(.failure(error)) + } catch { + promise(.failure(LocalizationError(message: error.localizedDescription))) + } + } + }.eraseToAnyPublisher() + } + + /// Transforms given value range using given language mapping + func transformValuesPublisher( + _ valueRange: ValueRange, + with mapping: LanguageMapping, + keyColumnName: String + ) -> AnyPublisher { + Future { [weak self] promise in + guard let self else { + promise(.failure(LocalizationError(message: "Unable to transform values"))) + return + } + do { + let result = try self.transformValues(valueRange, with: mapping, keyColumnName: keyColumnName) + promise(.success(result)) + } catch let error as LocalizationError { + promise(.failure(error)) + } catch { + promise(.failure(LocalizationError(message: error.localizedDescription))) + } + }.eraseToAnyPublisher() + } + + /// Transforms given value range using given config + func transformValuesPublisher( + _ valueRange: ValueRange, + with config: Configuration + ) -> AnyPublisher { + transformValuesPublisher(valueRange, with: config.languageMapping, keyColumnName: config.keyColumnName) + } + + /// Saves given `mappedValues` to correct directory file + func saveMappedValuesPublisher( + _ mappedValues: MappedValues, + defaultFileName: String, + destinations: [String: String] + ) -> AnyPublisher { + Future { [weak self] promise in + guard let self else { + promise(.failure(LocalizationError(message: "Unable to save mapped values"))) + return + } + do { + try self.saveMappedValues(mappedValues, defaultFileName: defaultFileName, destinations: destinations) + promise(.success(())) + } catch let error as LocalizationError { + promise(.failure(error)) + } catch { + promise(.failure(LocalizationError(message: error.localizedDescription))) + } + }.eraseToAnyPublisher() + } + + /// Saves given `mappedValues` to correct directory file + func saveMappedValuesPublisher( + _ mappedValues: MappedValues, + config: Configuration + ) -> AnyPublisher { + saveMappedValuesPublisher( + mappedValues, + defaultFileName: config.defaultFileName, + destinations: config.destinations + ) + } +} diff --git a/Sources/ACKLocalizationCore/Extensions/URLSessionExtensions.swift b/Sources/ACKLocalizationCore/Extensions/URLSessionExtensions.swift deleted file mode 100644 index 65b0351..0000000 --- a/Sources/ACKLocalizationCore/Extensions/URLSessionExtensions.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// URLSessionExtensions.swift -// -// -// Created by Jakub Olejník on 16/12/2019. -// - -import Combine -import Foundation - -internal extension URLSession.DataTaskPublisher { - /// Checks response status code, if it is not inside 200 - 300 then returns an error - func validate() -> AnyPublisher { - tryMap { data, response -> Data in - // if we do not have any response, assume it is valid - guard let response = response as? HTTPURLResponse else { return data } - - if (200..<300).contains(response.statusCode) { - return data - } - - let googleError = (try? JSONDecoder().decode([String: GoogleError].self, from: data))?["error"] - let message = [ - "Response status code (" + String(response.statusCode) + ") was unacceptable", - googleError?.message - ] - .compactMap { $0 } - .joined(separator: " - ") - - throw RequestError(underlyingError: googleError, message: message) - } - .mapError { $0 as! RequestError } // the tryMap can throw only RequestErrors - .eraseToAnyPublisher() - } -} diff --git a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift index ce2a56b..d277d0e 100644 --- a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift +++ b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift @@ -1,76 +1,84 @@ // // SheetsAPIService.swift -// +// // // Created by Jakub Olejník on 11/12/2019. // -import Combine import Foundation /// Protocol wrapping service that fetches information about spreadsheet public protocol SheetsAPIServicing: AnyObject { /// Access token that will be used with all requests var credentials: CredentialsType? { get set } - + /// Fetch information about given spreadsheet /// /// Uses `accessToken` property for authorization - func fetchSpreadsheet(_ identifier: String) -> AnyPublisher - + func fetchSpreadsheet(_ identifier: String) async throws -> Spreadsheet + /// Fetch content of given sheet from given spreadsheet /// /// If no `sheetName` is provided we use the first sheet /// Uses `accessToken` property for authorization - func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) -> AnyPublisher + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws -> ValueRange } /// Service that fetches information about spreadsheet public final class SheetsAPIService: SheetsAPIServicing { /// Access token that will be used with all requests public var credentials: CredentialsType? - + private let session: URLSession - + // MARK: - Initializers - + public init(session: URLSession = .shared, credentials: CredentialsType? = nil) { self.session = session self.credentials = credentials } - + // MARK: - API calls - - /// Fetch information about given spreadsheet - /// - /// Uses `accessToken` property for authorization - public func fetchSpreadsheet(_ identifier: String) -> AnyPublisher { + + public func fetchSpreadsheet(_ identifier: String) async throws -> Spreadsheet { let url = URL(string: "https://sheets.googleapis.com/v4/spreadsheets/" + identifier)! var request = URLRequest(url: url) credentials?.addToRequest(&request) - - return session.dataTaskPublisher(for: request) - .validate() - .decode(type: Spreadsheet.self, decoder: JSONDecoder()) - .mapError(RequestError.init) - .eraseToAnyPublisher() + + let (data, response) = try await session.data(for: request) + let validData = try Self.validate(data: data, response: response) + return try JSONDecoder().decode(Spreadsheet.self, from: validData) } - - /// Fetch content of given sheet from given spreadsheet - /// - /// If no `sheetName` is provided we use the first sheet - /// Uses `accessToken` property for authorization - public func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) -> AnyPublisher { + + public func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws -> ValueRange { let sheetName = sheetName ?? spreadsheet.sheets.first?.properties.title ?? "" var urlComponents = URLComponents(string: "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheet.spreadsheetId + "/values/" + sheetName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)! urlComponents.queryItems = [URLQueryItem(name: "valueRenderOption", value: "UNFORMATTED_VALUE")] var request = URLRequest(url: urlComponents.url!) credentials?.addToRequest(&request) - - return session.dataTaskPublisher(for: request) - .validate() - .decode(type: ValueRange.self, decoder: JSONDecoder()) - .mapError(RequestError.init) - .eraseToAnyPublisher() + + let (data, response) = try await session.data(for: request) + let validData = try Self.validate(data: data, response: response) + return try JSONDecoder().decode(ValueRange.self, from: validData) + } + + // MARK: - Private helpers + + private static func validate(data: Data, response: URLResponse) throws -> Data { + guard let response = response as? HTTPURLResponse else { return data } + + if (200..<300).contains(response.statusCode) { + return data + } + + let googleError = (try? JSONDecoder().decode([String: GoogleError].self, from: data))?["error"] + let message = [ + "Response status code (" + String(response.statusCode) + ") was unacceptable", + googleError?.message + ] + .compactMap { $0 } + .joined(separator: " - ") + + throw RequestError(underlyingError: googleError, message: message) } } diff --git a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift index e926312..2c1692a 100644 --- a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift +++ b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift @@ -1,21 +1,20 @@ // // SheetsAPIServiceMock.swift -// +// // // Created by Lukáš Hromadník on 24/08/2020. // import ACKLocalizationCore -import Combine final class SheetsAPIServiceMock: SheetsAPIServicing { var credentials: CredentialsType? - - func fetchSpreadsheet(_ identifier: String) -> AnyPublisher { - Empty().eraseToAnyPublisher() + + func fetchSpreadsheet(_ identifier: String) async throws -> Spreadsheet { + fatalError("Not implemented") } - - func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) -> AnyPublisher { - Empty().eraseToAnyPublisher() + + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws -> ValueRange { + fatalError("Not implemented") } } From 69f0a1bdee5f5bf140a9cf3fbe1d2e5b730ca0f0 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Thu, 20 Aug 2026 19:46:02 +0200 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=94=A5=20Remove=20leftover=20Combin?= =?UTF-8?q?e=20extensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/CombineExtensions.swift | 186 ------------------ 1 file changed, 186 deletions(-) delete mode 100644 Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift diff --git a/Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift b/Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift deleted file mode 100644 index 54e48b3..0000000 --- a/Sources/ACKLocalizationCore/Extensions/CombineExtensions.swift +++ /dev/null @@ -1,186 +0,0 @@ -import Combine -import Foundation - -// MARK: - SheetsAPIServicing Combine extensions - -public extension SheetsAPIServicing { - /// Fetch information about given spreadsheet - func fetchSpreadsheet(_ identifier: String) -> AnyPublisher { - Future { [weak self] promise in - Task { - do { - guard let result = try await self?.fetchSpreadsheet(identifier) else { - promise(.failure(RequestError(message: "Unable to fetch spreadsheet"))) - return - } - promise(.success(result)) - } catch let error as RequestError { - promise(.failure(error)) - } catch { - promise(.failure(RequestError(underlyingError: error))) - } - } - }.eraseToAnyPublisher() - } - - /// Fetch content of given sheet from given spreadsheet - func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) -> AnyPublisher { - Future { [weak self] promise in - Task { - do { - guard let result = try await self?.fetchSheet(sheetName, from: spreadsheet) else { - promise(.failure(RequestError(message: "Unable to fetch sheet"))) - return - } - promise(.success(result)) - } catch let error as RequestError { - promise(.failure(error)) - } catch { - promise(.failure(RequestError(underlyingError: error))) - } - } - }.eraseToAnyPublisher() - } -} - -// MARK: - ACKLocalization Combine extensions - -public extension ACKLocalization { - /// Fetches content of given sheet from spreadsheet using given `serviceAccount` - /// - /// If not `spreadsheetTabName` is provided, the first in the spreadsheet is used - func fetchSheetValues( - _ spreadsheetTabName: String?, - spreadsheetId: String, - serviceAccountPath: String? - ) -> AnyPublisher { - Future { [weak self] promise in - Task { - do { - guard let result = try await self?.fetchSheetValues( - spreadsheetTabName, - spreadsheetId: spreadsheetId, - serviceAccountPath: serviceAccountPath - ) else { - promise(.failure(LocalizationError(message: "Unable to fetch sheet values"))) - return - } - promise(.success(result)) - } catch let error as LocalizationError { - promise(.failure(error)) - } catch { - promise(.failure(LocalizationError(message: error.localizedDescription))) - } - } - }.eraseToAnyPublisher() - } - - func fetchSheetValues( - _ spreadsheetTabName: String?, - spreadsheetId: String, - apiKey: APIKey - ) -> AnyPublisher { - Future { [weak self] promise in - Task { - do { - guard let result = try await self?.fetchSheetValues( - spreadsheetTabName, - spreadsheetId: spreadsheetId, - apiKey: apiKey - ) else { - promise(.failure(LocalizationError(message: "Unable to fetch sheet values"))) - return - } - promise(.success(result)) - } catch let error as LocalizationError { - promise(.failure(error)) - } catch { - promise(.failure(LocalizationError(message: error.localizedDescription))) - } - } - }.eraseToAnyPublisher() - } - - /// Fetches sheet values from given `config` - func fetchSheetValues(_ config: Configuration) -> AnyPublisher { - Future { [weak self] promise in - Task { - do { - guard let result = try await self?.fetchSheetValues(config) else { - promise(.failure(LocalizationError(message: "Unable to fetch sheet values"))) - return - } - promise(.success(result)) - } catch let error as LocalizationError { - promise(.failure(error)) - } catch { - promise(.failure(LocalizationError(message: error.localizedDescription))) - } - } - }.eraseToAnyPublisher() - } - - /// Transforms given value range using given language mapping - func transformValuesPublisher( - _ valueRange: ValueRange, - with mapping: LanguageMapping, - keyColumnName: String - ) -> AnyPublisher { - Future { [weak self] promise in - guard let self else { - promise(.failure(LocalizationError(message: "Unable to transform values"))) - return - } - do { - let result = try self.transformValues(valueRange, with: mapping, keyColumnName: keyColumnName) - promise(.success(result)) - } catch let error as LocalizationError { - promise(.failure(error)) - } catch { - promise(.failure(LocalizationError(message: error.localizedDescription))) - } - }.eraseToAnyPublisher() - } - - /// Transforms given value range using given config - func transformValuesPublisher( - _ valueRange: ValueRange, - with config: Configuration - ) -> AnyPublisher { - transformValuesPublisher(valueRange, with: config.languageMapping, keyColumnName: config.keyColumnName) - } - - /// Saves given `mappedValues` to correct directory file - func saveMappedValuesPublisher( - _ mappedValues: MappedValues, - defaultFileName: String, - destinations: [String: String] - ) -> AnyPublisher { - Future { [weak self] promise in - guard let self else { - promise(.failure(LocalizationError(message: "Unable to save mapped values"))) - return - } - do { - try self.saveMappedValues(mappedValues, defaultFileName: defaultFileName, destinations: destinations) - promise(.success(())) - } catch let error as LocalizationError { - promise(.failure(error)) - } catch { - promise(.failure(LocalizationError(message: error.localizedDescription))) - } - }.eraseToAnyPublisher() - } - - /// Saves given `mappedValues` to correct directory file - func saveMappedValuesPublisher( - _ mappedValues: MappedValues, - config: Configuration - ) -> AnyPublisher { - saveMappedValuesPublisher( - mappedValues, - defaultFileName: config.defaultFileName, - destinations: config.destinations - ) - } -} From 3da7252e4f531873c88b9f35f7342a0c60f78e47 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Thu, 20 Aug 2026 19:48:20 +0200 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=93=9D=20Add=20changelog=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed85847..21c3b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## main +### Changed +- Replace internal Combine usage with async/await ([#47](https://github.com/AckeeCZ/ACKLocalization/pull/47), kudos to @olejnjak) +- Migrate tests to Swift Testing and extend test coverage ([#47](https://github.com/AckeeCZ/ACKLocalization/pull/47), kudos to @olejnjak) + ## 1.7.0 ### Added From 7f4b89eee77cbae0c4e0b7bc96c7fd9b65c4b898 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 01:58:06 +0200 Subject: [PATCH 07/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Surface=20RequestErr?= =?UTF-8?q?or=20from=20SheetsAPIService=20using=20typed=20throws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/SheetsAPIService.swift | 28 ++++++++++++------- .../Mocks/SheetsAPIServiceMock.swift | 4 +-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift index d277d0e..d3aebfd 100644 --- a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift +++ b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift @@ -15,13 +15,13 @@ public protocol SheetsAPIServicing: AnyObject { /// Fetch information about given spreadsheet /// /// Uses `accessToken` property for authorization - func fetchSpreadsheet(_ identifier: String) async throws -> Spreadsheet + func fetchSpreadsheet(_ identifier: String) async throws(RequestError) -> Spreadsheet /// Fetch content of given sheet from given spreadsheet /// /// If no `sheetName` is provided we use the first sheet /// Uses `accessToken` property for authorization - func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws -> ValueRange + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> ValueRange } /// Service that fetches information about spreadsheet @@ -40,26 +40,34 @@ public final class SheetsAPIService: SheetsAPIServicing { // MARK: - API calls - public func fetchSpreadsheet(_ identifier: String) async throws -> Spreadsheet { + public func fetchSpreadsheet(_ identifier: String) async throws(RequestError) -> Spreadsheet { let url = URL(string: "https://sheets.googleapis.com/v4/spreadsheets/" + identifier)! var request = URLRequest(url: url) credentials?.addToRequest(&request) - let (data, response) = try await session.data(for: request) - let validData = try Self.validate(data: data, response: response) - return try JSONDecoder().decode(Spreadsheet.self, from: validData) + do { + let (data, response) = try await session.data(for: request) + let validData = try Self.validate(data: data, response: response) + return try JSONDecoder().decode(Spreadsheet.self, from: validData) + } catch { + throw RequestError(underlyingError: error) + } } - public func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws -> ValueRange { + public func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> ValueRange { let sheetName = sheetName ?? spreadsheet.sheets.first?.properties.title ?? "" var urlComponents = URLComponents(string: "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheet.spreadsheetId + "/values/" + sheetName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)! urlComponents.queryItems = [URLQueryItem(name: "valueRenderOption", value: "UNFORMATTED_VALUE")] var request = URLRequest(url: urlComponents.url!) credentials?.addToRequest(&request) - let (data, response) = try await session.data(for: request) - let validData = try Self.validate(data: data, response: response) - return try JSONDecoder().decode(ValueRange.self, from: validData) + do { + let (data, response) = try await session.data(for: request) + let validData = try Self.validate(data: data, response: response) + return try JSONDecoder().decode(ValueRange.self, from: validData) + } catch { + throw RequestError(underlyingError: error) + } } // MARK: - Private helpers diff --git a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift index 2c1692a..dd34a31 100644 --- a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift +++ b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift @@ -10,11 +10,11 @@ import ACKLocalizationCore final class SheetsAPIServiceMock: SheetsAPIServicing { var credentials: CredentialsType? - func fetchSpreadsheet(_ identifier: String) async throws -> Spreadsheet { + func fetchSpreadsheet(_ identifier: String) async throws(RequestError) -> Spreadsheet { fatalError("Not implemented") } - func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws -> ValueRange { + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> ValueRange { fatalError("Not implemented") } } From 9a25ad22e45fe3f8b8e88c90c899b0622e478d91 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 02:02:59 +0200 Subject: [PATCH 08/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Rename=20service=20p?= =?UTF-8?q?rotocols=20and=20implementations=20to=20new=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ACKLocalizationCore/ACKLocalization.swift | 8 ++++---- Sources/ACKLocalizationCore/Services/FileSystem.swift | 2 +- .../ACKLocalizationCore/Services/SheetsAPIService.swift | 4 ++-- .../Mocks/SheetsAPIServiceMock.swift | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index 7c4351f..f87016f 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -7,19 +7,19 @@ public typealias MappedValues = [String: [LocRow]] /// Class containing all `ACKLocalization` logic public final class ACKLocalization { /// Spreadsheet API used to fetch spreadsheet content - private let sheetsAPI: SheetsAPIServicing + private let sheetsAPI: SheetsAPIService /// Filesystem abstraction for writing output files private let fileSystem: FileSystem // MARK: - Initializers - public init(sheetsAPI: SheetsAPIServicing = SheetsAPIService()) { + public init(sheetsAPI: SheetsAPIService = SheetsAPIServiceImpl()) { self.sheetsAPI = sheetsAPI - self.fileSystem = DefaultFileSystem() + self.fileSystem = FileSystemImpl() } - init(sheetsAPI: SheetsAPIServicing, fileSystem: FileSystem) { + init(sheetsAPI: SheetsAPIService, fileSystem: FileSystem) { self.sheetsAPI = sheetsAPI self.fileSystem = fileSystem } diff --git a/Sources/ACKLocalizationCore/Services/FileSystem.swift b/Sources/ACKLocalizationCore/Services/FileSystem.swift index a2e0b93..07762d0 100644 --- a/Sources/ACKLocalizationCore/Services/FileSystem.swift +++ b/Sources/ACKLocalizationCore/Services/FileSystem.swift @@ -13,7 +13,7 @@ protocol FileSystem { } /// Default implementation that delegates to real filesystem APIs -struct DefaultFileSystem: FileSystem { +struct FileSystemImpl: FileSystem { func createDirectory(atPath path: String, withIntermediateDirectories: Bool) throws { try FileManager.default.createDirectory( diff --git a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift index d3aebfd..aa12d37 100644 --- a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift +++ b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift @@ -8,7 +8,7 @@ import Foundation /// Protocol wrapping service that fetches information about spreadsheet -public protocol SheetsAPIServicing: AnyObject { +public protocol SheetsAPIService: AnyObject { /// Access token that will be used with all requests var credentials: CredentialsType? { get set } @@ -25,7 +25,7 @@ public protocol SheetsAPIServicing: AnyObject { } /// Service that fetches information about spreadsheet -public final class SheetsAPIService: SheetsAPIServicing { +public final class SheetsAPIServiceImpl: SheetsAPIService { /// Access token that will be used with all requests public var credentials: CredentialsType? diff --git a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift index dd34a31..deaa26b 100644 --- a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift +++ b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift @@ -7,7 +7,7 @@ import ACKLocalizationCore -final class SheetsAPIServiceMock: SheetsAPIServicing { +final class SheetsAPIServiceMock: SheetsAPIService { var credentials: CredentialsType? func fetchSpreadsheet(_ identifier: String) async throws(RequestError) -> Spreadsheet { From f3749aebd2f6e246a7c986ddc180ea7983d495fc Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 02:07:33 +0200 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=94=A5=20Remove=20file=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ACKLocalizationCore/Constants.swift | 7 ------- Sources/ACKLocalizationCore/Model/APIKey.swift | 7 ------- Sources/ACKLocalizationCore/Model/Configuration.swift | 7 ------- Sources/ACKLocalizationCore/Model/CredentialsType.swift | 7 ------- Sources/ACKLocalizationCore/Model/CustomKey.swift | 7 ------- Sources/ACKLocalizationCore/Model/GoogleError.swift | 7 ------- Sources/ACKLocalizationCore/Model/LocRow.swift | 7 ------- Sources/ACKLocalizationCore/Model/LocalizationError.swift | 7 ------- Sources/ACKLocalizationCore/Model/PluralError.swift | 7 ------- Sources/ACKLocalizationCore/Model/PluralRule.swift | 7 ------- Sources/ACKLocalizationCore/Model/PluralRuleKey.swift | 7 ------- Sources/ACKLocalizationCore/Model/PluralRuleWrapper.swift | 7 ------- Sources/ACKLocalizationCore/Model/RequestError.swift | 7 ------- Sources/ACKLocalizationCore/Model/SafeSubscript.swift | 7 ------- Sources/ACKLocalizationCore/Model/Spreadsheet.swift | 7 ------- Sources/ACKLocalizationCore/Model/ValueRange.swift | 7 ------- .../ACKLocalizationCore/Services/SheetsAPIService.swift | 7 ------- .../Mocks/SheetsAPIServiceMock.swift | 7 ------- 18 files changed, 126 deletions(-) diff --git a/Sources/ACKLocalizationCore/Constants.swift b/Sources/ACKLocalizationCore/Constants.swift index bbbc01c..aed23be 100644 --- a/Sources/ACKLocalizationCore/Constants.swift +++ b/Sources/ACKLocalizationCore/Constants.swift @@ -1,10 +1,3 @@ -// -// Constants.swift -// -// -// Created by Jakub Olejník on 12/12/2019. -// - import Foundation /// Struct holding important constants used throughout the tool diff --git a/Sources/ACKLocalizationCore/Model/APIKey.swift b/Sources/ACKLocalizationCore/Model/APIKey.swift index ce4b85b..c9edb13 100644 --- a/Sources/ACKLocalizationCore/Model/APIKey.swift +++ b/Sources/ACKLocalizationCore/Model/APIKey.swift @@ -1,10 +1,3 @@ -// -// APIKey.swift -// -// -// Created by Jakub Olejník on 16/12/2019. -// - import Foundation /// Struct that is used to represent Google API key diff --git a/Sources/ACKLocalizationCore/Model/Configuration.swift b/Sources/ACKLocalizationCore/Model/Configuration.swift index 9ba479d..afcb311 100644 --- a/Sources/ACKLocalizationCore/Model/Configuration.swift +++ b/Sources/ACKLocalizationCore/Model/Configuration.swift @@ -1,10 +1,3 @@ -// -// Configuration.swift -// -// -// Created by Jakub Olejník on 11/12/2019. -// - import Foundation public typealias LanguageMapping = [String: String] diff --git a/Sources/ACKLocalizationCore/Model/CredentialsType.swift b/Sources/ACKLocalizationCore/Model/CredentialsType.swift index 23fde5c..78c5469 100644 --- a/Sources/ACKLocalizationCore/Model/CredentialsType.swift +++ b/Sources/ACKLocalizationCore/Model/CredentialsType.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Jakub Olejník on 16/12/2019. -// - import Foundation /// Protocol which wraps all possible credentials used in this tool diff --git a/Sources/ACKLocalizationCore/Model/CustomKey.swift b/Sources/ACKLocalizationCore/Model/CustomKey.swift index 5db7480..5e4b42c 100644 --- a/Sources/ACKLocalizationCore/Model/CustomKey.swift +++ b/Sources/ACKLocalizationCore/Model/CustomKey.swift @@ -1,10 +1,3 @@ -// -// CustomKey.swift -// -// -// Created by Lukáš Hromadník on 07/07/2020. -// - import Foundation struct CustomKey: CodingKey { diff --git a/Sources/ACKLocalizationCore/Model/GoogleError.swift b/Sources/ACKLocalizationCore/Model/GoogleError.swift index 7422d41..b850b8a 100644 --- a/Sources/ACKLocalizationCore/Model/GoogleError.swift +++ b/Sources/ACKLocalizationCore/Model/GoogleError.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Jakub Olejník on 16/12/2019. -// - import Foundation /// Represents error returned by Google API diff --git a/Sources/ACKLocalizationCore/Model/LocRow.swift b/Sources/ACKLocalizationCore/Model/LocRow.swift index 1418cbc..a36ea3d 100644 --- a/Sources/ACKLocalizationCore/Model/LocRow.swift +++ b/Sources/ACKLocalizationCore/Model/LocRow.swift @@ -1,10 +1,3 @@ -// -// LocRow.swift -// -// -// Created by Jakub Olejník on 12/12/2019. -// - import Foundation /// Struct representing single `Localizable.strings` row diff --git a/Sources/ACKLocalizationCore/Model/LocalizationError.swift b/Sources/ACKLocalizationCore/Model/LocalizationError.swift index 1ac77a0..850f605 100644 --- a/Sources/ACKLocalizationCore/Model/LocalizationError.swift +++ b/Sources/ACKLocalizationCore/Model/LocalizationError.swift @@ -1,10 +1,3 @@ -// -// LocalizationError.swift -// -// -// Created by Jakub Olejník on 12/12/2019. -// - import Foundation /// Error that is thrown throughout the whole tool diff --git a/Sources/ACKLocalizationCore/Model/PluralError.swift b/Sources/ACKLocalizationCore/Model/PluralError.swift index a53c54b..f27f122 100644 --- a/Sources/ACKLocalizationCore/Model/PluralError.swift +++ b/Sources/ACKLocalizationCore/Model/PluralError.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Lukáš Hromadník on 24/08/2020. -// - import Foundation public enum PluralError: Error, Equatable { diff --git a/Sources/ACKLocalizationCore/Model/PluralRule.swift b/Sources/ACKLocalizationCore/Model/PluralRule.swift index bfb9f77..5bb1291 100644 --- a/Sources/ACKLocalizationCore/Model/PluralRule.swift +++ b/Sources/ACKLocalizationCore/Model/PluralRule.swift @@ -1,10 +1,3 @@ -// -// PluralRule.swift -// -// -// Created by Lukáš Hromadník on 07/07/2020. -// - import Foundation /// Encapsulates pair of plural rule key and the given translation for the given rule key diff --git a/Sources/ACKLocalizationCore/Model/PluralRuleKey.swift b/Sources/ACKLocalizationCore/Model/PluralRuleKey.swift index f651598..1e79a28 100644 --- a/Sources/ACKLocalizationCore/Model/PluralRuleKey.swift +++ b/Sources/ACKLocalizationCore/Model/PluralRuleKey.swift @@ -1,10 +1,3 @@ -// -// PluralRuleKey.swift -// -// -// Created by Lukáš Hromadník on 07/07/2020. -// - import Foundation /// Enumeration of all possible plural rule keys diff --git a/Sources/ACKLocalizationCore/Model/PluralRuleWrapper.swift b/Sources/ACKLocalizationCore/Model/PluralRuleWrapper.swift index 5a46a1d..73755ec 100644 --- a/Sources/ACKLocalizationCore/Model/PluralRuleWrapper.swift +++ b/Sources/ACKLocalizationCore/Model/PluralRuleWrapper.swift @@ -1,10 +1,3 @@ -// -// PluralRuleWrapper.swift -// -// -// Created by Lukáš Hromadník on 07/07/2020. -// - import Foundation /// Custom wrapper around plural rule to have nice way how to create the stringsDict diff --git a/Sources/ACKLocalizationCore/Model/RequestError.swift b/Sources/ACKLocalizationCore/Model/RequestError.swift index dd1cef5..350f8e8 100644 --- a/Sources/ACKLocalizationCore/Model/RequestError.swift +++ b/Sources/ACKLocalizationCore/Model/RequestError.swift @@ -1,10 +1,3 @@ -// -// RequestError.swift -// -// -// Created by Jakub Olejník on 11/12/2019. -// - import Foundation /// Struct used to represent errors during network requests diff --git a/Sources/ACKLocalizationCore/Model/SafeSubscript.swift b/Sources/ACKLocalizationCore/Model/SafeSubscript.swift index a98c775..3bb32a5 100644 --- a/Sources/ACKLocalizationCore/Model/SafeSubscript.swift +++ b/Sources/ACKLocalizationCore/Model/SafeSubscript.swift @@ -1,10 +1,3 @@ -// -// SafeSubscript.swift -// -// -// Created by Jakub Olejník on 12/12/2019. -// - import Foundation internal extension RandomAccessCollection { diff --git a/Sources/ACKLocalizationCore/Model/Spreadsheet.swift b/Sources/ACKLocalizationCore/Model/Spreadsheet.swift index 88af7ff..b8ea917 100644 --- a/Sources/ACKLocalizationCore/Model/Spreadsheet.swift +++ b/Sources/ACKLocalizationCore/Model/Spreadsheet.swift @@ -1,10 +1,3 @@ -// -// Spreadsheet.swift -// -// -// Created by Jakub Olejník on 12/12/2019. -// - import Foundation /// Struct holding information about fetch spreadsheet diff --git a/Sources/ACKLocalizationCore/Model/ValueRange.swift b/Sources/ACKLocalizationCore/Model/ValueRange.swift index 5885400..dd431c9 100644 --- a/Sources/ACKLocalizationCore/Model/ValueRange.swift +++ b/Sources/ACKLocalizationCore/Model/ValueRange.swift @@ -1,10 +1,3 @@ -// -// ValueRange.swift -// -// -// Created by Jakub Olejník on 12/12/2019. -// - import Foundation /// Struct holding content of a single sheet diff --git a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift index aa12d37..63df544 100644 --- a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift +++ b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift @@ -1,10 +1,3 @@ -// -// SheetsAPIService.swift -// -// -// Created by Jakub Olejník on 11/12/2019. -// - import Foundation /// Protocol wrapping service that fetches information about spreadsheet diff --git a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift index deaa26b..bbd44a4 100644 --- a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift +++ b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift @@ -1,10 +1,3 @@ -// -// SheetsAPIServiceMock.swift -// -// -// Created by Lukáš Hromadník on 24/08/2020. -// - import ACKLocalizationCore final class SheetsAPIServiceMock: SheetsAPIService { From 07f4231ad87e69da76949082b4ab014726581690 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 02:08:05 +0200 Subject: [PATCH 10/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Hide=20service=20imp?= =?UTF-8?q?lementations=20behind=20public=20factories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ACKLocalizationCore/ACKLocalization.swift | 2 +- .../Services/FileSystem.swift | 19 ----- .../Services/FileSystemImpl.swift | 20 ++++++ .../Services/SheetsAPIService.swift | 71 ++----------------- .../Services/SheetsAPIServiceImpl.swift | 68 ++++++++++++++++++ 5 files changed, 95 insertions(+), 85 deletions(-) create mode 100644 Sources/ACKLocalizationCore/Services/FileSystemImpl.swift create mode 100644 Sources/ACKLocalizationCore/Services/SheetsAPIServiceImpl.swift diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index f87016f..d1488ba 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -14,7 +14,7 @@ public final class ACKLocalization { // MARK: - Initializers - public init(sheetsAPI: SheetsAPIService = SheetsAPIServiceImpl()) { + public init(sheetsAPI: SheetsAPIService = createSheetsAPIService()) { self.sheetsAPI = sheetsAPI self.fileSystem = FileSystemImpl() } diff --git a/Sources/ACKLocalizationCore/Services/FileSystem.swift b/Sources/ACKLocalizationCore/Services/FileSystem.swift index 07762d0..30caf5f 100644 --- a/Sources/ACKLocalizationCore/Services/FileSystem.swift +++ b/Sources/ACKLocalizationCore/Services/FileSystem.swift @@ -11,22 +11,3 @@ protocol FileSystem { /// Writes data to a file func writeData(_ data: Data, to url: URL) throws } - -/// Default implementation that delegates to real filesystem APIs -struct FileSystemImpl: FileSystem { - - func createDirectory(atPath path: String, withIntermediateDirectories: Bool) throws { - try FileManager.default.createDirectory( - atPath: path, - withIntermediateDirectories: withIntermediateDirectories - ) - } - - func writeString(_ string: String, toFile path: String, atomically: Bool, encoding: String.Encoding) throws { - try string.write(toFile: path, atomically: atomically, encoding: encoding) - } - - func writeData(_ data: Data, to url: URL) throws { - try data.write(to: url) - } -} diff --git a/Sources/ACKLocalizationCore/Services/FileSystemImpl.swift b/Sources/ACKLocalizationCore/Services/FileSystemImpl.swift new file mode 100644 index 0000000..39eafa9 --- /dev/null +++ b/Sources/ACKLocalizationCore/Services/FileSystemImpl.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Default implementation that delegates to real filesystem APIs +struct FileSystemImpl: FileSystem { + + func createDirectory(atPath path: String, withIntermediateDirectories: Bool) throws { + try FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: withIntermediateDirectories + ) + } + + func writeString(_ string: String, toFile path: String, atomically: Bool, encoding: String.Encoding) throws { + try string.write(toFile: path, atomically: atomically, encoding: encoding) + } + + func writeData(_ data: Data, to url: URL) throws { + try data.write(to: url) + } +} diff --git a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift index 63df544..d014e4a 100644 --- a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift +++ b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift @@ -17,69 +17,10 @@ public protocol SheetsAPIService: AnyObject { func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> ValueRange } -/// Service that fetches information about spreadsheet -public final class SheetsAPIServiceImpl: SheetsAPIService { - /// Access token that will be used with all requests - public var credentials: CredentialsType? - - private let session: URLSession - - // MARK: - Initializers - - public init(session: URLSession = .shared, credentials: CredentialsType? = nil) { - self.session = session - self.credentials = credentials - } - - // MARK: - API calls - - public func fetchSpreadsheet(_ identifier: String) async throws(RequestError) -> Spreadsheet { - let url = URL(string: "https://sheets.googleapis.com/v4/spreadsheets/" + identifier)! - var request = URLRequest(url: url) - credentials?.addToRequest(&request) - - do { - let (data, response) = try await session.data(for: request) - let validData = try Self.validate(data: data, response: response) - return try JSONDecoder().decode(Spreadsheet.self, from: validData) - } catch { - throw RequestError(underlyingError: error) - } - } - - public func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> ValueRange { - let sheetName = sheetName ?? spreadsheet.sheets.first?.properties.title ?? "" - var urlComponents = URLComponents(string: "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheet.spreadsheetId + "/values/" + sheetName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)! - urlComponents.queryItems = [URLQueryItem(name: "valueRenderOption", value: "UNFORMATTED_VALUE")] - var request = URLRequest(url: urlComponents.url!) - credentials?.addToRequest(&request) - - do { - let (data, response) = try await session.data(for: request) - let validData = try Self.validate(data: data, response: response) - return try JSONDecoder().decode(ValueRange.self, from: validData) - } catch { - throw RequestError(underlyingError: error) - } - } - - // MARK: - Private helpers - - private static func validate(data: Data, response: URLResponse) throws -> Data { - guard let response = response as? HTTPURLResponse else { return data } - - if (200..<300).contains(response.statusCode) { - return data - } - - let googleError = (try? JSONDecoder().decode([String: GoogleError].self, from: data))?["error"] - let message = [ - "Response status code (" + String(response.statusCode) + ") was unacceptable", - googleError?.message - ] - .compactMap { $0 } - .joined(separator: " - ") - - throw RequestError(underlyingError: googleError, message: message) - } +/// Creates a service that fetches information about spreadsheet +public func createSheetsAPIService( + session: URLSession = .shared, + credentials: CredentialsType? = nil +) -> SheetsAPIService { + SheetsAPIServiceImpl(session: session, credentials: credentials) } diff --git a/Sources/ACKLocalizationCore/Services/SheetsAPIServiceImpl.swift b/Sources/ACKLocalizationCore/Services/SheetsAPIServiceImpl.swift new file mode 100644 index 0000000..7e56f45 --- /dev/null +++ b/Sources/ACKLocalizationCore/Services/SheetsAPIServiceImpl.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Service that fetches information about spreadsheet +final class SheetsAPIServiceImpl: SheetsAPIService { + /// Access token that will be used with all requests + var credentials: CredentialsType? + + private let session: URLSession + + // MARK: - Initializers + + init(session: URLSession = .shared, credentials: CredentialsType? = nil) { + self.session = session + self.credentials = credentials + } + + // MARK: - API calls + + func fetchSpreadsheet(_ identifier: String) async throws(RequestError) -> Spreadsheet { + let url = URL(string: "https://sheets.googleapis.com/v4/spreadsheets/" + identifier)! + var request = URLRequest(url: url) + credentials?.addToRequest(&request) + + do { + let (data, response) = try await session.data(for: request) + let validData = try Self.validate(data: data, response: response) + return try JSONDecoder().decode(Spreadsheet.self, from: validData) + } catch { + throw RequestError(underlyingError: error) + } + } + + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> ValueRange { + let sheetName = sheetName ?? spreadsheet.sheets.first?.properties.title ?? "" + var urlComponents = URLComponents(string: "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheet.spreadsheetId + "/values/" + sheetName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)! + urlComponents.queryItems = [URLQueryItem(name: "valueRenderOption", value: "UNFORMATTED_VALUE")] + var request = URLRequest(url: urlComponents.url!) + credentials?.addToRequest(&request) + + do { + let (data, response) = try await session.data(for: request) + let validData = try Self.validate(data: data, response: response) + return try JSONDecoder().decode(ValueRange.self, from: validData) + } catch { + throw RequestError(underlyingError: error) + } + } + + // MARK: - Private helpers + + private static func validate(data: Data, response: URLResponse) throws -> Data { + guard let response = response as? HTTPURLResponse else { return data } + + if (200..<300).contains(response.statusCode) { + return data + } + + let googleError = (try? JSONDecoder().decode([String: GoogleError].self, from: data))?["error"] + let message = [ + "Response status code (" + String(response.statusCode) + ") was unacceptable", + googleError?.message + ] + .compactMap { $0 } + .joined(separator: " - ") + + throw RequestError(underlyingError: googleError, message: message) + } +} From 767dd340e5a90b1ef48e2e7e6ea241d018f07235 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 02:12:30 +0200 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=93=9D=20Add=20Claude=20rules=20for?= =?UTF-8?q?=20code=20style=20and=20service=20conventions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/services.md | 6 ++++++ .claude/rules/style.md | 3 +++ 2 files changed, 9 insertions(+) create mode 100644 .claude/rules/services.md create mode 100644 .claude/rules/style.md diff --git a/.claude/rules/services.md b/.claude/rules/services.md new file mode 100644 index 0000000..f8ea82a --- /dev/null +++ b/.claude/rules/services.md @@ -0,0 +1,6 @@ +# Service naming and structure + +- Protocols use the plain name (e.g. `SheetsAPIService`), implementations use the `Impl` suffix (e.g. `SheetsAPIServiceImpl`). +- Keep protocols and implementations in separate files. +- Implementations are `internal`; public services are exposed through a public factory function (e.g. `createSheetsAPIService(...)`) that mirrors the implementation's initializer. +- Prefer typed throws in service interfaces when a single error type applies (e.g. `async throws(RequestError)`). diff --git a/.claude/rules/style.md b/.claude/rules/style.md new file mode 100644 index 0000000..18842a8 --- /dev/null +++ b/.claude/rules/style.md @@ -0,0 +1,3 @@ +# Code style + +- No file header comments (no `// FileName.swift / Created by ...` blocks) — files start directly with imports. From 7db834882d4281a98f89d273fa473e3d64182451 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 02:17:11 +0200 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=93=9D=20Add=20CLAUDE.md=20with=20p?= =?UTF-8?q?roject=20guidance=20for=20Claude=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..be072d8 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,36 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +ACKLocalization is a macOS command-line tool (Swift Package, swift-tools 6.0, Swift 5 language mode, macOS 13+) that downloads translations from a Google Spreadsheet and generates `.strings` / `.stringsdict` / `InfoPlist.strings` files for Apple apps. It authenticates via Google service account, API key, or application default credentials (using the `google-auth-swift` dependency). + +## Commands + +- Build: `swift build` +- Run all tests: `swift test` +- Run a single test: `swift test --filter ` (e.g. `swift test --filter ConfigurationTests`) +- Release build (what CI archives on tag push): `swift build --configuration release` + +Tests use **Swift Testing** (`@Test`/`#expect`), not XCTest. + +## Architecture + +Two targets with a thin executable wrapper: + +- `Sources/ACKLocalization/main.swift` — executable; just instantiates `ACKLocalization` and calls `run()`. +- `Sources/ACKLocalizationCore` — all logic, exposed as a library so it is testable. + +The pipeline lives in `ACKLocalizationCore/ACKLocalization.swift` and runs: load `localization.json` (`Model/Configuration.swift`, with fallback decoding of the legacy `ConfigurationV1` format) → resolve credentials (config values take priority over the `ACKLOCALIZATION_SERVICE_ACCOUNT_PATH` / `ACKLOCALIZATION_API_KEY` env vars, service account over API key, ADC as last resort) → fetch spreadsheet via `SheetsAPIService` → `transformValues` maps sheet columns to languages via `languageMapping` → `saveMappedValues` groups rows per output file (keys prefixed `plist..` go to `.strings`, plural keys `key##{rule}` go to `.stringsdict`) and writes via `FileSystem`. + +Dependency seams for testing: `SheetsAPIService` and `FileSystem` protocols (`Services/`), mocked in `Tests/ACKLocalizationCoreTests/Mocks/`. Concurrency is async/await throughout (no Combine). + +Conventions for services and code style are in `.claude/rules/services.md` and `.claude/rules/style.md` — follow them (protocol + `Impl` in separate files, public factory functions, typed throws, no file header comments). + +## CI and releases + +- Every PR must touch `CHANGELOG.md` (enforced by the Checks workflow). +- Tests run on macOS via `swift test` for every push/PR. +- Releases: pushing a tag builds a release binary and attaches a zip to the GitHub release. Version bumps are commits like "🔖 Bump version to X.Y.Z". Distribution to users is via Mint. +- Commit messages follow the Ackee style guide: https://github.com/AckeeCZ/styleguide/blob/master/git/guides/commit-message.md, using the Ackee conventional gitmoji set (♻️, 🔥, ✅, 📝, 🔖, …): https://github.com/AckeeCZ/conventional-gitmoji From 2f40a1e79bbebb0bc246600d6e54bd535b0368b0 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 02:25:10 +0200 Subject: [PATCH 13/14] =?UTF-8?q?=F0=9F=94=A7=20Add=20project=20Claude=20C?= =?UTF-8?q?ode=20settings=20and=20Xcode=20MCP=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 103 ++++++++++++++++++++++++++++++++++++++++++ .mcp.json | 12 +++++ 2 files changed, 115 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .mcp.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..baa6220 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Read", + "Bash(swift build*)", + "Bash(swift test*)", + "Bash(swift run*)", + "Bash(swift package*)", + "Bash(git add *)", + "Bash(git checkout *)", + "Bash(git commit *)", + "Bash(git rev-parse *)", + "Bash(git stash *)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git show*)", + "Bash(git branch*)", + "Bash(git blame*)", + "Bash(git push*)", + "mcp__xcode__BuildProject", + "mcp__xcode__GetBuildLog", + "mcp__xcode__GetTestList", + "mcp__xcode__RunAllTests", + "mcp__xcode__RunSomeTests", + "mcp__xcode__DocumentationSearch", + "mcp__xcode__XcodeRead", + "mcp__xcode__XcodeGrep", + "mcp__xcode__XcodeGlob", + "mcp__xcode__XcodeLS", + "mcp__xcode__XcodeListNavigatorIssues", + "mcp__xcode__XcodeRefreshCodeIssuesInFile", + "WebFetch(domain:anthropic.com)" + ], + "deny": [ + "Bash(git push --force*)", + "Bash(git push -f*)", + "Bash(git push * --force*)", + "Bash(git push * -f)", + "Bash(git push * -f *)", + "Bash(git push --force-with-lease*)", + "Bash(git push * --force-with-lease*)", + "Bash(git push origin --delete*)", + "Bash(git push * --delete*)", + "Bash(git branch -d*)", + "Bash(git branch -D*)", + "Bash(git branch * -d*)", + "Bash(git branch * -D*)" + ], + "ask": [ + "Edit(.claude/settings.json)" + ] + }, + "enabledPlugins": { + "swift-concurrency@swift-concurrency-agent-skill": true, + "swift-lsp@claude-plugins-official": true + }, + "extraKnownMarketplaces": { + "swift-concurrency-agent-skill": { + "source": { + "source": "github", + "repo": "AvdLee/Swift-Concurrency-Agent-Skill" + } + } + }, + "sandbox": { + "enabled": true, + "autoAllowBashIfSandboxed": true, + "network": { + "allowedDomains": [ + "github.com", + "*.github.com", + "*.githubusercontent.com", + "*.swift.org", + "*.apple.com" + ] + }, + "excludedCommands": [ + "swift *", + "xcodebuild *", + "xcrun *" + ], + "filesystem": { + "allowWrite": [ + "~/Library/Caches/org.swift.swiftpm", + "~/Library/org.swift.swiftpm", + "~/Library/Developer/Xcode/DerivedData" + ], + "denyRead": [ + "~/.bash_history", + "~/.bash_profile", + "~/.bashrc", + "~/.profile", + "~/.ssh", + "~/.zprofile", + "~/.zsh_history", + "~/.zshrc" + ] + } + }, + "plansDirectory": "./.claude/plans" +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6d3806f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "xcode": { + "type": "stdio", + "command": "xcrun", + "args": [ + "mcpbridge" + ], + "env": {} + } + } +} \ No newline at end of file From 016837c3b29766f41a4b9058a79673f4ec57864a Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Fri, 21 Aug 2026 02:35:35 +0200 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=94=A7=20Exclude=20git=20from=20Cla?= =?UTF-8?q?ude=20Code=20sandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/settings.json b/.claude/settings.json index baa6220..a4bc927 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -77,6 +77,7 @@ ] }, "excludedCommands": [ + "git *", "swift *", "xcodebuild *", "xcrun *"