From 94b22c48b63876cb377f0bf042ad041e6b6eea59 Mon Sep 17 00:00:00 2001 From: "Takuto NAKAMURA (Kyome)" Date: Sat, 3 Jan 2026 00:03:32 +0900 Subject: [PATCH 1/2] Added Tests to XMMigratorTests --- Sources/XCStringsMigrator/XMMigrator.swift | 31 ++-- .../Migrator/Localizable.stringsdict | 24 +++ .../full.lproj/Localizable.stringsdict | 24 +++ .../XMMigratorTests.swift | 151 ++++++++++++++++-- 4 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 Tests/XCStringsMigratorTests/Resources/Migrator/Localizable.stringsdict create mode 100644 Tests/XCStringsMigratorTests/Resources/Migrator/full.lproj/Localizable.stringsdict diff --git a/Sources/XCStringsMigrator/XMMigrator.swift b/Sources/XCStringsMigrator/XMMigrator.swift index ef53ac3..34cca55 100644 --- a/Sources/XCStringsMigrator/XMMigrator.swift +++ b/Sources/XCStringsMigrator/XMMigrator.swift @@ -45,7 +45,17 @@ public struct XMMigrator { } return dictionary } - + + func extractVariableName(from formatKey: String) -> String { + let pattern = "%#@([^@]+)@" + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: formatKey, range: NSRange(formatKey.startIndex..., in: formatKey)), + let range = Range(match.range(at: 1), in: formatKey) else { + return "" + } + return String(formatKey[range]) + } + func extractStringsDictValue(from url: URL) -> [String: [String: String]]? { guard let data = try? Data(contentsOf: url), let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), @@ -54,44 +64,25 @@ public struct XMMigrator { } return dictionary.compactMapValues { topLevelDict -> [String: String]? in - // Extract the format key (e.g., "%#@format@") guard let formatKey = topLevelDict["NSStringLocalizedFormatKey"] as? String else { return nil } - - // Extract variable name from formatKey (e.g., "%#@format@" -> "format") let variableName = extractVariableName(from: formatKey) guard !variableName.isEmpty else { return nil } - - // Get the variable dictionary guard let variableDict = topLevelDict[variableName] as? [String: Any], let specType = variableDict["NSStringFormatSpecTypeKey"] as? String, specType == "NSStringPluralRuleType" else { return nil } - - // Extract plural variations (zero, one, two, few, many, other) var pluralRules: [String: String] = [:] for key in ["zero", "one", "two", "few", "many", "other"] { if let value = variableDict[key] as? String { pluralRules[key] = value } } - return pluralRules.isEmpty ? nil : pluralRules } } - - private func extractVariableName(from formatKey: String) -> String { - // Extract variable from "%#@format@" -> "format" - let pattern = "%#@([^@]+)@" - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: formatKey, range: NSRange(formatKey.startIndex..., in: formatKey)), - let range = Range(match.range(at: 1), in: formatKey) else { - return "" - } - return String(formatKey[range]) - } func extractStringsData() throws -> [StringsData] { let fileManager = FileManager.default diff --git a/Tests/XCStringsMigratorTests/Resources/Migrator/Localizable.stringsdict b/Tests/XCStringsMigratorTests/Resources/Migrator/Localizable.stringsdict new file mode 100644 index 0000000..922f153 --- /dev/null +++ b/Tests/XCStringsMigratorTests/Resources/Migrator/Localizable.stringsdict @@ -0,0 +1,24 @@ + + + + + %lld item(s) + + NSStringLocalizedFormatKey + %#@format@ + format + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + li + one + %lld item + other + %lld items + zero + %lld item + + + + diff --git a/Tests/XCStringsMigratorTests/Resources/Migrator/full.lproj/Localizable.stringsdict b/Tests/XCStringsMigratorTests/Resources/Migrator/full.lproj/Localizable.stringsdict new file mode 100644 index 0000000..922f153 --- /dev/null +++ b/Tests/XCStringsMigratorTests/Resources/Migrator/full.lproj/Localizable.stringsdict @@ -0,0 +1,24 @@ + + + + + %lld item(s) + + NSStringLocalizedFormatKey + %#@format@ + format + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + li + one + %lld item + other + %lld items + zero + %lld item + + + + diff --git a/Tests/XCStringsMigratorTests/XMMigratorTests.swift b/Tests/XCStringsMigratorTests/XMMigratorTests.swift index 0daf23f..3992095 100644 --- a/Tests/XCStringsMigratorTests/XMMigratorTests.swift +++ b/Tests/XCStringsMigratorTests/XMMigratorTests.swift @@ -27,6 +27,36 @@ struct XMMigratorTests { #expect(actual == expect) } + @Test("Extract variable name from formatKey (e.g., \"%#@format@\" -> \"format\")") + func extractVariableName_positive() throws { + let sut = XMMigrator(sourceLanguage: "", paths: [], outputPath: "", verbose: false) + let actual = sut.extractVariableName(from: "%#@format@") + #expect(actual == "format") + } + + @Test("If URL is invalid, nil will be returned.") + func extractStringsDictValue_negative() throws { + let sut = XMMigrator(sourceLanguage: "", paths: [], outputPath: "", verbose: false) + let url = try #require(Bundle.module.resourceURL).appending(path: "not-exist.stringsdict") + let actual = sut.extractStringsDictValue(from: url) + #expect(actual == nil) + } + + @Test("If stringsdict file is valid, dictionary will be returned.") + func extractStringsDictValue_positive() throws { + let sut = XMMigrator(sourceLanguage: "", paths: [], outputPath: "", verbose: false) + let url = try #require(Bundle.module.url(forResource: "Localizable", withExtension: "stringsdict", subdirectory: "Migrator")) + let actual = sut.extractStringsDictValue(from: url) + let expect: [String: [String: String]] = [ + "%lld item(s)": [ + "zero": "%lld item", + "one": "%lld item", + "other": "%lld items" + ] + ] + #expect(actual == expect) + } + @Test("If paths is empty, error is thrown.") func extractStringsData_negative_1() throws { let sut = XMMigrator(sourceLanguage: "", paths: [], outputPath: "", verbose: false) @@ -77,7 +107,18 @@ struct XMMigratorTests { "key": .simple("value"), "path": .simple("/"), ] - ) + ), + StringsData( + tableName: "Localizable", + language: "full", + values: [ + "%lld item(s)": .plural([ + "other": "%lld items", + "one": "%lld item", + "zero": "%lld item", + ]), + ] + ), ] #expect(actual == expect) } @@ -117,6 +158,11 @@ struct XMMigratorTests { "Count = %lld": .simple("Count = %lld"), "language": .simple("English"), "path": .simple("/"), + "%lld item(s)": .plural([ + "one": "%lld item", + "zero": "%lld item", + "other": "%lld items", + ]), ] ), StringsData( @@ -127,6 +173,10 @@ struct XMMigratorTests { "Count = %lld": .simple("カウント=%lld"), "language": .simple("日本語"), "path": .simple("/"), + "%lld item(s)": .plural([ + "zero": "%lld個", + "other": "%lld個", + ]), ] ), ] @@ -135,20 +185,31 @@ struct XMMigratorTests { sourceLanguage: "test", strings: [ "\"Hello %@\"": Strings(localizations: [ - "en": Localization(stringUnit: StringUnit(value: "\"Hello %@\"")), - "ja": Localization(stringUnit: StringUnit(value: "「こんにちは%@」")), + "en": Localization(stringUnit: .init(value: "\"Hello %@\"")), + "ja": Localization(stringUnit: .init(value: "「こんにちは%@」")), ]), "Count = %lld": Strings(localizations: [ - "en": Localization(stringUnit: StringUnit(value: "Count = %lld")), - "ja": Localization(stringUnit: StringUnit(value: "カウント=%lld")), + "en": Localization(stringUnit: .init(value: "Count = %lld")), + "ja": Localization(stringUnit: .init(value: "カウント=%lld")), ]), "language": Strings(localizations: [ - "en": Localization(stringUnit: StringUnit(value: "English")), - "ja": Localization(stringUnit: StringUnit(value: "日本語")), + "en": Localization(stringUnit: .init(value: "English")), + "ja": Localization(stringUnit: .init(value: "日本語")), ]), "path": Strings(localizations: [ - "en": Localization(stringUnit: StringUnit(value: "/")), - "ja": Localization(stringUnit: StringUnit(value: "/")), + "en": Localization(stringUnit: .init(value: "/")), + "ja": Localization(stringUnit: .init(value: "/")), + ]), + "%lld item(s)": Strings(localizations: [ + "en": Localization(variations: .init(plural: [ + "one": PluralVariation(stringUnit: .init(value: "%lld item")), + "zero": PluralVariation(stringUnit: .init(value: "%lld item")), + "other": PluralVariation(stringUnit: .init(value: "%lld items")), + ])), + "ja": Localization(variations: .init(plural: [ + "zero": PluralVariation(stringUnit: .init(value: "%lld個")), + "other": PluralVariation(stringUnit: .init(value: "%lld個")), + ])), ]), ], version: "1.0" @@ -179,7 +240,7 @@ struct XMMigratorTests { sut.standardOutput = { items in standardOutputs.append(contentsOf: items.map({ "\($0)" })) } - var writeDataCount: Int = 0 + var writeDataCount = Int.zero sut.writeData = { _, url in #expect(url.path() == "output/Localizable.xcstrings") writeDataCount += 1 @@ -203,6 +264,17 @@ struct XMMigratorTests { "en": Localization(stringUnit: StringUnit(value: "/")), "ja": Localization(stringUnit: StringUnit(value: "/")), ]), + "%lld item(s)": Strings(localizations: [ + "en": Localization(variations: .init(plural: [ + "one": PluralVariation(stringUnit: .init(value: "%lld item")), + "zero": PluralVariation(stringUnit: .init(value: "%lld item")), + "other": PluralVariation(stringUnit: .init(value: "%lld items")), + ])), + "ja": Localization(variations: .init(plural: [ + "zero": PluralVariation(stringUnit: .init(value: "%lld個")), + "other": PluralVariation(stringUnit: .init(value: "%lld個")), + ])), + ]), ], version: "1.0" ) @@ -219,7 +291,7 @@ struct XMMigratorTests { sut.standardOutput = { items in standardOutputs.append(contentsOf: items.map({ "\($0)" })) } - var writeDataCount: Int = 0 + var writeDataCount = Int.zero sut.writeData = { _, url in #expect(url.path() == "output/Localizable.xcstrings") writeDataCount += 1 @@ -243,6 +315,17 @@ struct XMMigratorTests { "en": Localization(stringUnit: StringUnit(value: "/")), "ja": Localization(stringUnit: StringUnit(value: "/")), ]), + "%lld item(s)": Strings(localizations: [ + "en": Localization(variations: .init(plural: [ + "one": PluralVariation(stringUnit: .init(value: "%lld item")), + "zero": PluralVariation(stringUnit: .init(value: "%lld item")), + "other": PluralVariation(stringUnit: .init(value: "%lld items")), + ])), + "ja": Localization(variations: .init(plural: [ + "zero": PluralVariation(stringUnit: .init(value: "%lld個")), + "other": PluralVariation(stringUnit: .init(value: "%lld個")), + ])), + ]), ], version: "1.0" ) @@ -267,6 +350,52 @@ struct XMMigratorTests { } } }, + "%lld item(s)" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld item" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld items" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld item" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld個" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld個" + } + } + } + } + } + } + }, "Count = %lld" : { "localizations" : { "en" : { From 27d5d2958c96536f7b6ee451771da21017c1ac0d Mon Sep 17 00:00:00 2001 From: "Takuto NAKAMURA (Kyome)" Date: Sat, 3 Jan 2026 03:15:03 +0900 Subject: [PATCH 2/2] Added Tests to XMReverterTests --- Sources/XCStringsMigrator/XMReverter.swift | 18 +-- .../Resources/Reverter/Localizable.xcstrings | 48 +++++++- .../XMReverterTests.swift | 114 +++++++++++++++--- 3 files changed, 152 insertions(+), 28 deletions(-) diff --git a/Sources/XCStringsMigrator/XMReverter.swift b/Sources/XCStringsMigrator/XMReverter.swift index c513d5d..c5f8b65 100644 --- a/Sources/XCStringsMigrator/XMReverter.swift +++ b/Sources/XCStringsMigrator/XMReverter.swift @@ -5,7 +5,7 @@ public struct XMReverter { private var outputPath: String var standardOutput: (Any...) -> Void var createDirectory: (URL) throws -> Void - var writeString: (String, URL) throws -> Void + var writeData: (Data, URL) throws -> Void public init(path: String, outputPath: String) { self.path = path @@ -16,8 +16,8 @@ public struct XMReverter { self.createDirectory = { try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) } - self.writeString = { - try $0.write(to: $1, atomically: false, encoding: .utf8) + self.writeData = { + try $0.write(to: $1) } } @@ -94,14 +94,15 @@ public struct XMReverter { let outputFileURL = outputFolderURL .appending(path: stringsData.tableName) .appendingPathExtension("strings") - let text = stringsData.values + let data = stringsData.values .sorted(by: { $0.key < $1.key }) .compactMap { key, value -> String? in guard case .simple(let stringValue) = value else { return nil } return "\(key.debugDescription) = \(stringValue.debugDescription);" } .joined(separator: "\n") - try writeString(text, outputFileURL) + .data(using: .utf8)! + try writeData(data, outputFileURL) standardOutput("Succeeded to export strings file.") } catch { throw XMError.failedToExportStringsFile @@ -132,7 +133,7 @@ public struct XMReverter { "NSStringFormatSpecTypeKey": "NSStringPluralRuleType", "NSStringFormatValueTypeKey": "li" ] - + // Add plural rules for (pluralKey, pluralValue) in pluralRules { variableDict[pluralKey] = pluralValue @@ -146,13 +147,12 @@ public struct XMReverter { } // Convert to XML plist - let plistData = try PropertyListSerialization.data( + let data = try PropertyListSerialization.data( fromPropertyList: plistDict, format: .xml, options: 0 ) - - try plistData.write(to: outputFileURL) + try writeData(data, outputFileURL) standardOutput("Succeeded to export stringsdict file.") } catch { throw XMError.failedToExportStringsFile diff --git a/Tests/XCStringsMigratorTests/Resources/Reverter/Localizable.xcstrings b/Tests/XCStringsMigratorTests/Resources/Reverter/Localizable.xcstrings index 9289375..6e8cf3c 100644 --- a/Tests/XCStringsMigratorTests/Resources/Reverter/Localizable.xcstrings +++ b/Tests/XCStringsMigratorTests/Resources/Reverter/Localizable.xcstrings @@ -17,6 +17,52 @@ } } }, + "%lld item(s)" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld item" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld items" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld item" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld個" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld個" + } + } + } + } + } + } + }, "Count = %lld" : { "localizations" : { "en" : { @@ -67,4 +113,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Tests/XCStringsMigratorTests/XMReverterTests.swift b/Tests/XCStringsMigratorTests/XMReverterTests.swift index 67b2c69..73d1175 100644 --- a/Tests/XCStringsMigratorTests/XMReverterTests.swift +++ b/Tests/XCStringsMigratorTests/XMReverterTests.swift @@ -56,6 +56,17 @@ struct XMReverterTests { "en": Localization(stringUnit: .init(value: "/")), "ja": Localization(stringUnit: .init(value: "/")), ]), + "%lld item(s)": Strings(localizations: [ + "en": Localization(variations: .init(plural: [ + "zero": PluralVariation(stringUnit: .init(value: "%lld item")), + "one": PluralVariation(stringUnit: .init(value: "%lld item")), + "other": PluralVariation(stringUnit: .init(value: "%lld items")), + ])), + "ja": Localization(variations: .init(plural: [ + "zero": PluralVariation(stringUnit: .init(value: "%lld個")), + "other": PluralVariation(stringUnit: .init(value: "%lld個")), + ])) + ]) ], version: "1.0" ) @@ -77,21 +88,27 @@ struct XMReverterTests { "ja": Localization(stringUnit: .init(value: "ja_value_2")), ]), "key3": Strings(localizations: [ - "en": Localization(stringUnit: .init(value: "en_value_3")), - "ja": Localization(stringUnit: .init(value: "ja_value_3")), + "en": Localization(variations: .init(plural: [ + "zero": PluralVariation(stringUnit: .init(value: "en_value_zero")), + "one": PluralVariation(stringUnit: .init(value: "en_value_one")), + "other": PluralVariation(stringUnit: .init(value: "en_value_other")), + ])), + "ja": Localization(variations: .init(plural: [ + "zero": PluralVariation(stringUnit: .init(value: "ja_value_zero")), + "other": PluralVariation(stringUnit: .init(value: "ja_value_other")), + ])), ]), ], version: "1.0" ) let (actualStrings, actualStringsDict) = sut.convertToStringsData(from: input) - let expect = [ + let expectStrings = [ StringsData( tableName: "Localizable", language: "en", values: [ "key1": .simple("en_value_1"), "key2": .simple("en_value_2"), - "key3": .simple("en_value_3"), ] ), StringsData( @@ -100,12 +117,34 @@ struct XMReverterTests { values: [ "key1": .simple("ja_value_1"), "key2": .simple("ja_value_2"), - "key3": .simple("ja_value_3"), ] ), ] - #expect(actualStrings.sorted(by: { $0.language < $1.language }) == expect) - #expect(actualStringsDict.isEmpty) + let expectStringsDict = [ + StringsData( + tableName: "Localizable", + language: "en", + values: [ + "key3": .plural([ + "zero": "en_value_zero", + "one": "en_value_one", + "other": "en_value_other", + ]), + ] + ), + StringsData( + tableName: "Localizable", + language: "ja", + values: [ + "key3": .plural([ + "zero": "ja_value_zero", + "other": "ja_value_other", + ]), + ] + ), + ] + #expect(actualStrings.sorted(by: { $0.language < $1.language }) == expectStrings) + #expect(actualStringsDict.sorted(by: { $0.language < $1.language }) == expectStringsDict) } @Test("If exporting file fails, an error is thrown.") @@ -115,7 +154,7 @@ struct XMReverterTests { sut.standardOutput = { items in standardOutputs.append(contentsOf: items.map({ "\($0)" })) } - sut.writeString = { _, _ in + sut.writeData = { _, _ in throw CocoaError(.fileWriteUnknown) } let input = StringsData(tableName: "Localizable", language: "test", values: [:]) @@ -132,10 +171,10 @@ struct XMReverterTests { sut.standardOutput = { items in standardOutputs.append(contentsOf: items.map({ "\($0)" })) } - var writeStrings = [String]() - sut.writeString = { text, url in + var writeDataCount = Int.zero + sut.writeData = { _, url in #expect(url.path() == "output/test.lproj/Localizable.strings") - writeStrings.append(text) + writeDataCount += 1 } let input = StringsData( tableName: "Localizable", @@ -148,13 +187,52 @@ struct XMReverterTests { ] ) try sut.exportStringsFile(input) - let details = #""" - "\"Hello %@\"" = "\"Hello %@\""; - "Count = %lld" = "Count = %lld"; - "key" = "value"; - "path" = "/"; - """# #expect(standardOutputs == ["Succeeded to export strings file."]) - #expect(writeStrings == [details]) + #expect(writeDataCount == 1) + } + + @Test("If exporting file fails, an error is thrown.") + func exportStringsDictFile_negative() throws { + var sut = XMReverter(path: "", outputPath: "") + var standardOutputs = [String]() + sut.standardOutput = { items in + standardOutputs.append(contentsOf: items.map({ "\($0)" })) + } + sut.writeData = { _, _ in + throw CocoaError(.fileWriteUnknown) + } + let input = StringsData(tableName: "Localizable", language: "test", values: [:]) + #expect(throws: XMError.failedToExportStringsFile) { + try sut.exportStringsDictFile(input) + } + #expect(standardOutputs.isEmpty) + } + + @Test("If StringsData is valid, file is successfully exported.") + func exportStringsDictFile_positive() throws { + var sut = XMReverter(path: "", outputPath: "output") + var standardOutputs = [String]() + sut.standardOutput = { items in + standardOutputs.append(contentsOf: items.map({ "\($0)" })) + } + var writeDataCount = Int.zero + sut.writeData = { text, url in + #expect(url.path() == "output/test.lproj/Localizable.stringsdict") + writeDataCount += 1 + } + let input = StringsData( + tableName: "Localizable", + language: "test", + values: [ + "%lld item(s)": .plural([ + "zero": "%lld item", + "one": "%lld item", + "other": "%lld items", + ]), + ] + ) + try sut.exportStringsDictFile(input) + #expect(standardOutputs == ["Succeeded to export stringsdict file."]) + #expect(writeDataCount == 1) } }