Skip to content

Commit 66312d8

Browse files
authored
Make localization errors non-fatal (#175)
* Limit localization tests to localization logic only * Try to recover when requested locale is not available * Handle all the remaining errors gracefully * Refactor a bit * Test the recovery of an unknown table * Handle cascading errors * Clear the environment locale after it cannot be found
1 parent df7eaed commit 66312d8

File tree

5 files changed

+363
-298
lines changed

5 files changed

+363
-298
lines changed

Sources/HTMLKit/Framework/Localization/Localization.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,32 @@ public class Localization {
252252

253253
throw Errors.missingKey(string.key.value, currentLocale.tag)
254254
}
255+
256+
/// Recovers from an error.
257+
///
258+
/// - Parameters:
259+
/// - priorError: The prior error to compare to
260+
/// - string: The string to localize
261+
///
262+
/// - Returns: The translation or the string literal
263+
internal func recover(from priorError: Errors, with string: LocalizedString) throws -> String {
264+
265+
do {
266+
267+
return try localize(string: string)
268+
269+
} catch let error as Errors {
270+
271+
switch error {
272+
case .missingKey where error != priorError:
273+
return try recover(from: error, with: string)
274+
275+
case .missingTable where error != priorError:
276+
return try recover(from: error, with: string)
277+
278+
default:
279+
return string.key.literal
280+
}
281+
}
282+
}
255283
}

Sources/HTMLKit/Framework/Rendering/Renderer.swift

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -288,20 +288,38 @@ public struct Renderer {
288288
}
289289

290290
do {
291+
291292
return try localization.localize(string: string, for: environment.locale)
292293

293-
} catch Localization.Errors.missingKey(let key, let locale) {
294+
} catch let error as Localization.Errors {
294295

295-
logger.warning("Unable to find translation key '\(key)' for the locale '\(locale)'.")
296+
logger.warning("\(error.description)")
296297

297-
// Check if the fallback was already in charge
298-
if environment.locale != nil {
298+
switch error {
299+
case .missingKey:
300+
301+
if environment.locale != nil {
302+
303+
logger.debug("Trying to recover from missing key")
304+
305+
return try localization.recover(from: error, with: string)
306+
}
307+
308+
fallthrough
309+
310+
case .missingTable:
299311

300-
// Seems not, let's try to recover by using the fallback
301-
return try localization.localize(string: string)
312+
logger.debug("Trying to recover from missing table")
313+
314+
// Clear the locale on the environment, since it cannot be used for the remainder of the rendering,
315+
// otherwise it will throw an error each time
316+
environment.upsert(Optional<Locale>.none, for: \EnvironmentKeys.locale)
317+
318+
return try localization.recover(from: error, with: string)
319+
320+
default:
321+
return string.key.literal
302322
}
303-
304-
return string.key.literal
305323
}
306324
}
307325

Tests/HTMLKitTests/LocalizationTests.swift

Lines changed: 27 additions & 215 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import XCTest
33

44
final class LocalizationTests: XCTestCase {
55

6-
var renderer: Renderer?
6+
var localization: Localization?
77

88
override func setUp() {
99
super.setUp()
@@ -16,55 +16,15 @@ final class LocalizationTests: XCTestCase {
1616
/// The test expects the key to exist in the default translation table and to be rendered correctly.
1717
func testLocalization() throws {
1818

19-
struct MainView: View {
20-
21-
var body: Content {
22-
Heading1("hello.world")
23-
}
24-
}
25-
26-
XCTAssertEqual(try renderer!.render(view: MainView()),
27-
"""
28-
<h1>Hello World</h1>
29-
"""
30-
)
19+
XCTAssertEqual(try localization!.localize(string: .init(key: "hello.world")), "Hello World")
3120
}
3221

33-
/// Tests the localization of a attribute
22+
/// Tests the localization of a translation key in a specified translation table
3423
///
35-
/// The test expects the key to exist in the default translation table and to be rendered correctly.
36-
func testLocalizationAttribute() throws {
37-
38-
struct TestView: View {
39-
40-
let placeholder = "hello.world"
41-
42-
var body: Content {
43-
Input()
44-
.placeholder("hello.world", tableName: nil)
45-
.alternate(LocalizedStringKey("hello.world"))
46-
.value(LocalizedStringKey("hello.world"), tableName: "web")
47-
.title("hello.world", tableName: "mobile")
48-
Meta()
49-
.content("hello.world")
50-
Input()
51-
.placeholder(verbatim: "hello.world")
52-
.alternate(verbatim: "hello.world")
53-
.value(verbatim: placeholder)
54-
.title(verbatim: "hello.world")
55-
TextArea {}
56-
.placeholder(placeholder)
57-
}
58-
}
24+
/// The test expects the key to exist in the specified translation table and to be rendered accurately.
25+
func testLocalizationWithTable() throws {
5926

60-
XCTAssertEqual(try renderer!.render(view: TestView()),
61-
"""
62-
<input placeholder="Hello World" alt="Hello World" value="Hello World" title="Hello World">\
63-
<meta content="Hello World">\
64-
<input placeholder="hello.world" alt="hello.world" value="hello.world" title="hello.world">\
65-
<textarea placeholder="hello.world"></textarea>
66-
"""
67-
)
27+
XCTAssertEqual(try localization!.localize(string: .init(key: "hello.world", table: "web")), "Hello World")
6828
}
6929

7030
/// Tests the localization of string interpolation
@@ -73,24 +33,10 @@ final class LocalizationTests: XCTestCase {
7333
/// and rendered accurately.
7434
func testLocalizationWithStringInterpolation() throws {
7535

76-
struct TestView: View {
77-
78-
var body: Content {
79-
Paragraph("String: \("John Doe")")
80-
Paragraph("Integer: \(31)")
81-
Paragraph("Double: \(12.5)")
82-
Paragraph("Date: \(Date(timeIntervalSince1970: 50000))")
83-
}
84-
}
85-
86-
XCTAssertEqual(try renderer!.render(view: TestView()),
87-
"""
88-
<p>String: John Doe</p>\
89-
<p>Integer: 31</p>\
90-
<p>Double: 12.5</p>\
91-
<p>Date: 01/01/1970</p>
92-
"""
93-
)
36+
XCTAssertEqual(try localization!.localize(string: .init(key: "String: \("John Doe")")), "String: John Doe")
37+
XCTAssertEqual(try localization!.localize(string: .init(key: "Integer: \(31)")), "Integer: 31")
38+
XCTAssertEqual(try localization!.localize(string: .init(key: "Double: \(12.5)")), "Double: 12.5")
39+
XCTAssertEqual(try localization!.localize(string: .init(key: "Date: \(Date(timeIntervalSince1970: 50000))")), "Date: 01/01/1970")
9440
}
9541

9642
/// Tests the localization of string interpolation with multiple arguments and various data types
@@ -99,122 +45,35 @@ final class LocalizationTests: XCTestCase {
9945
/// with the arguments in the proper order, and to be rendered accurately.
10046
func testStringInterpolationWithMultipleArguments() throws {
10147

102-
struct TestView: View {
103-
104-
var body: Content {
105-
Paragraph("Hello \("Jane") and \("John Doe")")
106-
Paragraph("Do you \(2) have time at \(Date(timeIntervalSince1970: 50000))?")
107-
Paragraph("cheers.person \("Jean")")
108-
}
109-
}
110-
111-
XCTAssertEqual(try renderer!.render(view: TestView()),
112-
"""
113-
<p>Hello Jane and John Doe</p>\
114-
<p>Do you 2 have time at 01/01/1970?</p>\
115-
<p>Cheers Jean</p>
116-
"""
117-
)
118-
}
119-
120-
/// Tests the localization of a translation key in a specified translation table
121-
///
122-
/// The test expects the key to exist in the specified translation tabl and to be rendered accurately.
123-
func testLocaliationWithTable() throws {
124-
125-
struct TestView: View {
126-
127-
var body: Content {
128-
Paragraph("hello.world", tableName: "web")
129-
}
130-
}
131-
132-
XCTAssertEqual(try renderer!.render(view: TestView()),
133-
"""
134-
<p>Hello World</p>
135-
"""
136-
)
137-
}
138-
139-
/// Tests the change of the locale by the environment modifier
140-
///
141-
/// The test expects that the localization environment modifier correctly applies the locale
142-
/// down to nested views
143-
func testEnvironmentLocalization() throws {
144-
145-
struct MainView: View {
146-
147-
var content: [Content]
148-
149-
init(@ContentBuilder<Content> content: () -> [Content]) {
150-
self.content = content()
151-
}
152-
153-
var body: Content {
154-
Division {
155-
content
156-
}
157-
.environment(key: \.locale, value: Locale(tag: .french))
158-
}
159-
}
160-
161-
struct ChildView: View {
162-
163-
var body: Content {
164-
MainView {
165-
Heading1("hello.world")
166-
.environment(key: \.locale)
167-
}
168-
}
169-
}
170-
171-
XCTAssertEqual(try renderer!.render(view: ChildView()),
172-
"""
173-
<div>\
174-
<h1>Bonjour le monde</h1>\
175-
</div>
176-
"""
177-
)
48+
XCTAssertEqual(try localization!.localize(string: .init(key: "Hello \("Jane") and \("John Doe")")), "Hello Jane and John Doe")
49+
XCTAssertEqual(try localization!.localize(string: .init(key: "Do you \(2) have time at \(Date(timeIntervalSince1970: 50000))?")), "Do you 2 have time at 01/01/1970?")
50+
XCTAssertEqual(try localization!.localize(string: .init(key: "cheers.person \("Jean")")), "Cheers Jean")
17851
}
17952

18053
/// Tests the behavior when a localization key is missing
18154
///
18255
/// A key is considered as missing if it cannot be found in the translation table. In this case,
183-
/// the renderer is expected to use the fallback literal string.
56+
/// the localization is expected to throw an error.
18457
func testMissingKey() throws {
18558

186-
struct MainView: View {
59+
XCTAssertThrowsError(try localization!.localize(string: .init(key: "unknown.key")), "unknown.key") { error in
18760

188-
var body: Content {
189-
Heading1("unknown.key")
61+
guard let localizationError = error as? Localization.Errors else {
62+
return XCTFail("Unexpected error type: \(error)")
19063
}
64+
65+
XCTAssertEqual(localizationError, .missingKey("unknown.key", "en-GB"))
66+
XCTAssertEqual(localizationError.description, "Unable to find translation key 'unknown.key' for the locale 'en-GB'.")
19167
}
192-
193-
XCTAssertEqual(try renderer!.render(view: MainView()),
194-
"""
195-
<h1>unknown.key</h1>
196-
"""
197-
)
19868
}
19969

200-
/// Tests the behavior when a translation table is missing
70+
/// Tests the behavior when a translation table is missing.
20171
///
20272
/// A table is considered as missing if there is no translation table for the given locale. In this case,
203-
/// the renderer is expected to throw an error.
73+
/// the localization is expected to throw an error.
20474
func testMissingTable() throws {
20575

206-
struct MainView: View {
207-
208-
var body: Content {
209-
Division {
210-
Heading1("greeting.world")
211-
.environment(key: \.locale)
212-
}
213-
.environment(key: \.locale, value: Locale(tag: "unknown.tag"))
214-
}
215-
}
216-
217-
XCTAssertThrowsError(try renderer!.render(view: MainView())) { error in
76+
XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world"), for: .init(tag: "unknown.tag"))) { error in
21877

21978
guard let localizationError = error as? Localization.Errors else {
22079
return XCTFail("Unexpected error type: \(error)")
@@ -225,20 +84,13 @@ final class LocalizationTests: XCTestCase {
22584
}
22685
}
22786

228-
/// Tests the behavior when a translation table is unknown
87+
/// Tests the behavior when a translation table is unknown.
22988
///
23089
/// A table is considered as unknown if it cannot be found by the given table name. In this case,
231-
/// the renderer is expected to throw an error.
90+
/// the localization is expected to throw an error.
23291
func testUnknownTable() throws {
23392

234-
struct MainView: View {
235-
236-
var body: Content {
237-
Heading1("greeting.world", tableName: "unknown.table")
238-
}
239-
}
240-
241-
XCTAssertThrowsError(try renderer!.render(view: MainView())) { error in
93+
XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world", table: "unknown.table"))) { error in
24294

24395
guard let localizationError = error as? Localization.Errors else {
24496
return XCTFail("Unexpected error type: \(error)")
@@ -248,46 +100,6 @@ final class LocalizationTests: XCTestCase {
248100
XCTAssertEqual(localizationError.description, "Unable to find translation table 'unknown.table' for the locale 'en-GB'.")
249101
}
250102
}
251-
252-
/// Tests the recovery from a missing key
253-
///
254-
/// The renderer should attempt a secondary lookup in the translation tables of the default locale.
255-
func testRecoveryFromMissingKey() throws {
256-
257-
struct MainView: View {
258-
259-
var content: [Content]
260-
261-
init(@ContentBuilder<Content> content: () -> [Content]) {
262-
self.content = content()
263-
}
264-
265-
var body: Content {
266-
Division {
267-
content
268-
}
269-
.environment(key: \.locale, value: Locale(tag: .french))
270-
}
271-
}
272-
273-
struct ChildView: View {
274-
275-
var body: Content {
276-
MainView {
277-
Heading1("Hello \("John Doe")")
278-
.environment(key: \.locale)
279-
}
280-
}
281-
}
282-
283-
XCTAssertEqual(try renderer!.render(view: ChildView()),
284-
"""
285-
<div>\
286-
<h1>Hello John Doe</h1>\
287-
</div>
288-
"""
289-
)
290-
}
291103
}
292104

293105
extension LocalizationTests {
@@ -298,6 +110,6 @@ extension LocalizationTests {
298110
return
299111
}
300112

301-
self.renderer = Renderer(localization: .init(source: sourcePath, locale: .init(tag: "en-GB")))
113+
self.localization = Localization(source: sourcePath, locale: .init(tag: "en-GB"))
302114
}
303115
}

0 commit comments

Comments
 (0)