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 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. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a4bc927 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,104 @@ +{ + "$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": [ + "git *", + "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 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 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 7788ca0..d1488ba 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import GoogleAuth @@ -8,45 +7,54 @@ 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 var fetchCancellable: Cancellable? - + 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 = createSheetsAPIService()) { + self.sheetsAPI = sheetsAPI + self.fileSystem = FileSystemImpl() + } + + init(sheetsAPI: SheetsAPIService, fileSystem: FileSystem) { 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 @@ -54,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, @@ -196,30 +196,30 @@ 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 else { return defaultFileName } - + 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( @@ -227,146 +227,83 @@ 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? FileManager.default.createDirectory(atPath: dirPath, withIntermediateDirectories: true) - - // Collection of plural rules for a given translation key. - // Translation key is the base without the suffix ##{plural-rule} + + try? fileSystem.createDirectory(atPath: dirPath, withIntermediateDirectories: true) + 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) - try data.write(to: URL(fileURLWithPath: stringsDictPath)) + try fileSystem.writeData(data, to: URL(fileURLWithPath: stringsDictPath)) } } } - - /// 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 { @@ -374,16 +311,19 @@ 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 } 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 @@ -403,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/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/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/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/FileSystem.swift b/Sources/ACKLocalizationCore/Services/FileSystem.swift new file mode 100644 index 0000000..30caf5f --- /dev/null +++ b/Sources/ACKLocalizationCore/Services/FileSystem.swift @@ -0,0 +1,13 @@ +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 +} 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 ce2a56b..d014e4a 100644 --- a/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift +++ b/Sources/ACKLocalizationCore/Services/SheetsAPIService.swift @@ -1,76 +1,26 @@ -// -// 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 { +public protocol SheetsAPIService: 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(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) -> AnyPublisher + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> 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 { - 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() - } - - /// 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 { - 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() - } +/// 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) + } +} diff --git a/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift b/Tests/ACKLocalizationCoreTests/ACKLocalization+Plurals.swift index 782ba42..693824c 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,73 @@ 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) + } + + // 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) } } diff --git a/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift b/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift index e1dceaa..69ef6bb 100644 --- a/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift +++ b/Tests/ACKLocalizationCoreTests/ACKLocalizationTests.swift @@ -1,10 +1,13 @@ +import Foundation @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,56 +18,67 @@ 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 { + @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( @@ -72,9 +86,159 @@ final class ACKLocalizationTests: XCTestCase { 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 49600d8..600b579 100644 --- a/Tests/ACKLocalizationCoreTests/LocRowTests.swift +++ b/Tests/ACKLocalizationCoreTests/LocRowTests.swift @@ -1,84 +1,175 @@ -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) + } + + // 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/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/Mocks/SheetsAPIServiceMock.swift b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift index e926312..bbd44a4 100644 --- a/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift +++ b/Tests/ACKLocalizationCoreTests/Mocks/SheetsAPIServiceMock.swift @@ -1,21 +1,13 @@ -// -// SheetsAPIServiceMock.swift -// -// -// Created by Lukáš Hromadník on 24/08/2020. -// - import ACKLocalizationCore -import Combine -final class SheetsAPIServiceMock: SheetsAPIServicing { +final class SheetsAPIServiceMock: SheetsAPIService { var credentials: CredentialsType? - - func fetchSpreadsheet(_ identifier: String) -> AnyPublisher { - Empty().eraseToAnyPublisher() + + func fetchSpreadsheet(_ identifier: String) async throws(RequestError) -> Spreadsheet { + fatalError("Not implemented") } - - func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) -> AnyPublisher { - Empty().eraseToAnyPublisher() + + func fetchSheet(_ sheetName: String?, from spreadsheet: Spreadsheet) async throws(RequestError) -> ValueRange { + fatalError("Not implemented") } } diff --git a/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift b/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift new file mode 100644 index 0000000..a78c94b --- /dev/null +++ b/Tests/ACKLocalizationCoreTests/SaveMappedValuesTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing +@testable import ACKLocalizationCore + +@Suite +struct SaveMappedValuesTests { + private let localization: ACKLocalization + private let sheetsAPI: SheetsAPIServiceMock + private let fileSystem: FileSystemMock + + init() { + sheetsAPI = SheetsAPIServiceMock() + fileSystem = FileSystemMock() + localization = ACKLocalization(sheetsAPI: sheetsAPI, fileSystem: fileSystem) + } + + // MARK: - File writing + + @Test + func writesStringsFile() throws { + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "greeting", value: "Hello"), + LocRow(key: "farewell", value: "Goodbye") + ] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: ["Localizable": "/output"] + ) + + let stringsPath = "/output/en.lproj/Localizable.strings" + + #expect(fileSystem.writtenStrings[stringsPath] != nil) + + let content = fileSystem.writtenStrings[stringsPath]! + #expect(content.contains(#""greeting" = "Hello";"#)) + #expect(content.contains(#""farewell" = "Goodbye";"#)) + } + + @Test + func createsLprojDirectories() throws { + 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": "/output"] + ) + + #expect(fileSystem.createdDirectories.contains("/output/en.lproj")) + #expect(fileSystem.createdDirectories.contains("/output/cs.lproj")) + } + + @Test + func writesStringsDictForPlurals() throws { + 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": "/output"] + ) + + 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 + #expect(fileSystem.writtenStrings[stringsPath] == nil) + } + + @Test + func separatesPluralsFromRegularKeys() throws { + 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": "/output"] + ) + + let stringsPath = "/output/en.lproj/Localizable.strings" + let stringsDictPath = "/output/en.lproj/Localizable.stringsdict" + + #expect(fileSystem.writtenStrings[stringsPath] != nil) + #expect(fileSystem.writtenData[stringsDictPath] != nil) + + let stringsContent = fileSystem.writtenStrings[stringsPath]! + #expect(stringsContent.contains(#""greeting" = "Hello";"#)) + #expect(!stringsContent.contains("items")) + } + + // MARK: - Plist prefix routing + + @Test + func plistPrefixRoutesToSeparateFile() throws { + 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": "/output", + "InfoPlist": "/output" + ] + ) + + let localizablePath = "/output/en.lproj/Localizable.strings" + let infoPlistPath = "/output/en.lproj/InfoPlist.strings" + + #expect(fileSystem.writtenStrings[localizablePath] != nil) + #expect(fileSystem.writtenStrings[infoPlistPath] != nil) + + let localizableContent = fileSystem.writtenStrings[localizablePath]! + #expect(localizableContent.contains(#""greeting" = "Hello";"#)) + + let infoPlistContent = fileSystem.writtenStrings[infoPlistPath]! + #expect(infoPlistContent.contains(#""CFBundleDisplayName" = "My App";"#)) + } + + @Test + func plistPrefixStripsKeyPrefix() throws { + let mappedValues: MappedValues = [ + "en": [ + LocRow(key: "plist.InfoPlist.NSCameraUsageDescription", value: "Camera needed") + ] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable", + destinations: [ + "Localizable": "/output", + "InfoPlist": "/output" + ] + ) + + let infoPlistPath = "/output/en.lproj/InfoPlist.strings" + + 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")) + } + + // MARK: - Default file name suffix stripping + + @Test + func defaultFileNameStripsStringsSuffix() throws { + let mappedValues: MappedValues = [ + "en": [LocRow(key: "key", value: "value")] + ] + + try localization.saveMappedValues( + mappedValues, + defaultFileName: "Localizable.strings", + destinations: ["Localizable": "/output"] + ) + + let stringsPath = "/output/en.lproj/Localizable.strings" + #expect(fileSystem.writtenStrings[stringsPath] != nil) + } + + // MARK: - Duplicate keys + + @Test + func duplicateKeysThrowsOnSave() throws { + 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": "/output"] + ) + } + } + + // MARK: - Empty mapped values + + @Test + func emptyMappedValuesDoesNotThrow() throws { + #expect(throws: Never.self) { + try localization.saveMappedValues( + [:], + defaultFileName: "Localizable", + destinations: ["Localizable": "/output"] + ) + } + } +} 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"]]) + } +} 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