From b62dcf8d292104cd8e4e6371fcc6c6b54bc685a5 Mon Sep 17 00:00:00 2001 From: Stuart Breckenridge Date: Sun, 8 Jan 2023 06:35:22 +0800 Subject: [PATCH] Adds localization technote And a missing translation --- Technotes/Localization.md | 70 ++++++++++++++++++ .../LocalizationTestPlan.xctestplan | 39 ++++++++++ .../LocalizationTests.swift | 71 +++++++++++++++++++ .../NetNewsWireUITests_iOS.swift | 42 +++++++++++ .../NetNewsWireUITests_iOSLaunchTests.swift | 33 +++++++++ .../Cell/MasterTimelineTableViewCell.swift | 5 +- iOS/Resources/en-GB.lproj/Localizable.strings | 12 ++-- iOS/Resources/en.lproj/Localizable.strings | 6 ++ 8 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 Technotes/Localization.md create mode 100644 Tests/NetNewsWireUITests-iOS/LocalizationTestPlan.xctestplan create mode 100644 Tests/NetNewsWireUITests-iOS/LocalizationTests.swift create mode 100644 Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOS.swift create mode 100644 Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOSLaunchTests.swift diff --git a/Technotes/Localization.md b/Technotes/Localization.md new file mode 100644 index 000000000..8efc1b86e --- /dev/null +++ b/Technotes/Localization.md @@ -0,0 +1,70 @@ +# Localization + +NetNewsWire is an Internationalized and Localized app. + +## Internationalization + +`Internationalized` means that, at code-level, we don't use locale-dependent strings. Instead, in code we use a key-based approach. Generally, this takes the form of: + +`UIKit` or `AppKit` — using `NSLocalizedString`: + +```swift +let messageFormat = NSLocalizedString("alert.title.open-articles-in-browser.%ld", comment: "Are you sure you want to open %ld articles in your browser?") +alert.messageText = String.localizedStringWithFormat(messageFormat, urlStrings.count) +``` + +or, in `SwiftUI` — using the `Text` struct: + +```swift +Text("alert.title.remove-account.\(viewModel.accountToDelete?.nameForDisplay ?? "")", comment: "Are you sure you want to remove “%@“?") +``` + +### Key Format + +All keys are lower cased and follow the `dot.separated` UI element name -> `hyphen-separated` string descriptor -> `dot.separated` variable list format, e.g.: + +``` +alert.title.open-articles-in-browser.%ld +button.title.close +``` + +### Comments + +Whether using `NSLocalizedString` or `Text`, a `comment` must be provided. This will generally be a reference to the string in English. However, where a key contains multiple variables, the ordering of the variables must be specified in the comment. + + +## Localization + +All of NetNewsWire's strings in code are localized in external resources — `.strings` or `.stringsdict`. Each target has its own `Localizable.strings` (and, where necessary, `.stringsdict`) files. All Storyboards are also localized. + +### Adding New Strings + +If you are developing a new feature that introduces new strings to the code base, follow these general guidelines: + +- Check if there is an existing string in `Localizable.strings` that meets your needs. If there is, use that. +- If there isn't: + - Add your string in code following the key and comment rules above + - Run `Export Localizations` + - Open the `en.xcloc` file and provide the new translations. + - Save the `en.xcloc` file. + - Run `Import Localizations` + - Select `en.xcloc` and Import. + +### Updating Existing Translations + +Update the Development Language translation first: + +- Run `Export Localizations` +- Open the `en.xcloc` file and provide the new translations. +- Save the `en.xcloc` file. +- Run `Import Localizations` +- Select `en.xcloc` and Import. + +Then update other lanaguages: + +- Run `Export Localizations` +- Open the `en-GB.xcloc` file and provide the new translations. +- Save the `en-GB.xcloc` file. +- Run `Import Localizations` +- Select `en-GB.xcloc` and Import. + diff --git a/Tests/NetNewsWireUITests-iOS/LocalizationTestPlan.xctestplan b/Tests/NetNewsWireUITests-iOS/LocalizationTestPlan.xctestplan new file mode 100644 index 000000000..2eb53a572 --- /dev/null +++ b/Tests/NetNewsWireUITests-iOS/LocalizationTestPlan.xctestplan @@ -0,0 +1,39 @@ +{ + "configurations" : [ + { + "id" : "3878F164-A79E-44EE-8DC3-3CFC413BCE62", + "name" : "English - US", + "options" : { + "areLocalizationScreenshotsEnabled" : true, + "language" : "en", + "region" : "US" + } + }, + { + "id" : "4D5E7C9B-F2B7-4E6B-AF7E-FF172A8EC7BA", + "name" : "English - UK", + "options" : { + "areLocalizationScreenshotsEnabled" : true, + "language" : "en-GB", + "region" : "GB" + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "skippedTests" : [ + "NetNewsWireUITests_iOS", + "NetNewsWireUITests_iOSLaunchTests" + ], + "target" : { + "containerPath" : "container:NetNewsWire.xcodeproj", + "identifier" : "DFB616C82965739D00A359AB", + "name" : "NetNewsWireUITests-iOS" + } + } + ], + "version" : 1 +} diff --git a/Tests/NetNewsWireUITests-iOS/LocalizationTests.swift b/Tests/NetNewsWireUITests-iOS/LocalizationTests.swift new file mode 100644 index 000000000..f1cd11ac4 --- /dev/null +++ b/Tests/NetNewsWireUITests-iOS/LocalizationTests.swift @@ -0,0 +1,71 @@ +// +// LocalizationTests.swift +// NetNewsWireTests +// +// Created by Stuart Breckenridge on 04/01/2023. +// Copyright © 2023 Ranchero Software. All rights reserved. +// + +import XCTest + +final class LocalizationTests_iOSTest: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testAppRunThrough() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + _ = addUIInterruptionMonitor(withDescription: "Handle Notifications Alert") { element in + if element.buttons["Allow"].exists { + element.buttons["Allow"].tap() + return true + } + return false + } + + app.toolbars["Toolbar"].buttons["Settings"].tap() + + let collectionViewsQuery = app.collectionViews + collectionViewsQuery/*@START_MENU_TOKEN@*/.buttons["Manage Accounts"]/*[[".cells.buttons[\"Manage Accounts\"]",".buttons[\"Manage Accounts\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["Manage Accounts"]/*@START_MENU_TOKEN@*/.buttons["Add"]/*[[".otherElements[\"Add\"].buttons[\"Add\"]",".buttons[\"Add\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["Add Account"]/*@START_MENU_TOKEN@*/.buttons["Cancel"]/*[[".otherElements[\"Cancel\"].buttons[\"Cancel\"]",".buttons[\"Cancel\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["Manage Accounts"].buttons["Settings"].tap() + collectionViewsQuery/*@START_MENU_TOKEN@*/.buttons["Manage Extensions"]/*[[".cells.buttons[\"Manage Extensions\"]",".buttons[\"Manage Extensions\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["Manage Extensions"]/*@START_MENU_TOKEN@*/.buttons["Add"]/*[[".otherElements[\"Add\"].buttons[\"Add\"]",".buttons[\"Add\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["Add Extensions"]/*@START_MENU_TOKEN@*/.buttons["Cancel"]/*[[".otherElements[\"Cancel\"].buttons[\"Cancel\"]",".buttons[\"Cancel\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["Manage Extensions"].buttons["Settings"].tap() + collectionViewsQuery/*@START_MENU_TOKEN@*/.buttons["Import Subscriptions"]/*[[".cells.buttons[\"Import Subscriptions\"]",".buttons[\"Import Subscriptions\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app/*@START_MENU_TOKEN@*/.scrollViews/*[[".otherElements[\"Choose an account to receive the imported feeds and folders\"].scrollViews",".scrollViews"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.otherElements.buttons["Cancel"].tap() + collectionViewsQuery/*@START_MENU_TOKEN@*/.buttons["Export Subscriptions"]/*[[".cells.buttons[\"Export Subscriptions\"]",".buttons[\"Export Subscriptions\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app/*@START_MENU_TOKEN@*/.scrollViews/*[[".otherElements[\"Choose an account with the subscriptions to export\"].scrollViews",".scrollViews"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.otherElements.buttons["Cancel"].tap() + let displayBehaviorsButton = collectionViewsQuery.buttons["button.title.display-and-behaviors"] + displayBehaviorsButton.tap() + app.navigationBars.buttons["Settings"].tap() + collectionViewsQuery/*@START_MENU_TOKEN@*/.buttons["New Article Notifications"]/*[[".cells.buttons[\"New Article Notifications\"]",".buttons[\"New Article Notifications\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["New Article Notifications"].buttons["Settings"].tap() + displayBehaviorsButton.swipeUp() + collectionViewsQuery/*@START_MENU_TOKEN@*/.buttons["About"]/*[[".cells.buttons[\"About\"]",".buttons[\"About\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + app.navigationBars["About"].buttons["Settings"].tap() + } + + private func addScreenShot(_ name: String, app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + +} diff --git a/Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOS.swift b/Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOS.swift new file mode 100644 index 000000000..7fe803901 --- /dev/null +++ b/Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOS.swift @@ -0,0 +1,42 @@ +// +// NetNewsWireUITests_iOS.swift +// NetNewsWireUITests-iOS +// +// Created by Stuart Breckenridge on 04/01/2023. +// Copyright © 2023 Ranchero Software. All rights reserved. +// + +import XCTest + +final class NetNewsWireUITests_iOS: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOSLaunchTests.swift b/Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOSLaunchTests.swift new file mode 100644 index 000000000..aeb5407af --- /dev/null +++ b/Tests/NetNewsWireUITests-iOS/NetNewsWireUITests_iOSLaunchTests.swift @@ -0,0 +1,33 @@ +// +// NetNewsWireUITests_iOSLaunchTests.swift +// NetNewsWireUITests-iOS +// +// Created by Stuart Breckenridge on 04/01/2023. +// Copyright © 2023 Ranchero Software. All rights reserved. +// + +import XCTest + +final class NetNewsWireUITests_iOSLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift b/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift index 594dbfa98..394fe193e 100644 --- a/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift +++ b/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift @@ -263,9 +263,8 @@ private extension MasterTimelineTableViewCell { } func updateAccessiblityLabel() { - #warning("This needs to be localized.") - let starredStatus = cellData.starred ? "\(NSLocalizedString("Starred", comment: "Starred article for accessibility")), " : "" - let unreadStatus = cellData.read ? "" : "\(NSLocalizedString("Unread", comment: "Unread")), " + let starredStatus = cellData.starred ? "\(NSLocalizedString("label.text.starred", comment: "Starred")), " : "" + let unreadStatus = cellData.read ? "" : "\(NSLocalizedString("label.text.unread", comment: "Unread")), " let label = starredStatus + unreadStatus + "\(cellData.feedName), \(cellData.title), \(cellData.summary), \(cellData.dateString)" accessibilityLabel = label } diff --git a/iOS/Resources/en-GB.lproj/Localizable.strings b/iOS/Resources/en-GB.lproj/Localizable.strings index 1393a3330..e9ef95064 100644 --- a/iOS/Resources/en-GB.lproj/Localizable.strings +++ b/iOS/Resources/en-GB.lproj/Localizable.strings @@ -745,6 +745,9 @@ /* Small */ "label.text.small" = "Small"; +/* Starred */ +"label.text.starred" = "Starred"; + /* Thanks */ "label.text.thanks" = "Thanks"; @@ -760,6 +763,9 @@ /* Timeline */ "label.text.timeline" = "Timeline"; +/* Unread */ +"label.text.unread" = "Unread"; + /* Relative time that the account was last refreshed. The variable is a named relative time. Example: Updated 8 minutes ago */ "label.text.updatedat.%@" = "Updated %@"; @@ -857,9 +863,6 @@ /* Smart Feeds group title */ "smartfeeds.title" = "Smart Feeds"; -/* Starred article for accessibility */ -"Starred" = "Starred"; - /* Email Address */ "textfield.placeholder.email-address" = "Email Address"; @@ -911,6 +914,3 @@ /* Unread label for accessiblity */ "unread" = "unread"; -/* Unread */ -"Unread" = "Unread"; - diff --git a/iOS/Resources/en.lproj/Localizable.strings b/iOS/Resources/en.lproj/Localizable.strings index 746f2572a..25f662c0b 100644 --- a/iOS/Resources/en.lproj/Localizable.strings +++ b/iOS/Resources/en.lproj/Localizable.strings @@ -730,6 +730,9 @@ /* Small */ "label.text.small" = "Small"; +/* Starred */ +"label.text.starred" = "Starred"; + /* Thanks */ "label.text.thanks" = "Thanks"; @@ -745,6 +748,9 @@ /* Timeline */ "label.text.timeline" = "Timeline"; +/* Unread */ +"label.text.unread" = "Unread"; + /* Relative time that the account was last refreshed. The variable is a named relative time. Example: Updated 8 minutes ago */ "label.text.updatedat.%@" = "Updated %@";