i18n_test.go (14450B)
1 // Copyright 2017 The Hugo Authors. All rights reserved.
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 // http://www.apache.org/licenses/LICENSE-2.0
7 //
8 // Unless required by applicable law or agreed to in writing, software
9 // distributed under the License is distributed on an "AS IS" BASIS,
10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 // See the License for the specific language governing permissions and
12 // limitations under the License.
13
14 package i18n
15
16 import (
17 "fmt"
18 "path/filepath"
19 "testing"
20
21 "github.com/gohugoio/hugo/common/types"
22
23 "github.com/gohugoio/hugo/modules"
24
25 "github.com/gohugoio/hugo/tpl/tplimpl"
26
27 "github.com/gohugoio/hugo/common/loggers"
28 "github.com/gohugoio/hugo/langs"
29 "github.com/gohugoio/hugo/resources/page"
30 "github.com/spf13/afero"
31
32 "github.com/gohugoio/hugo/deps"
33
34 qt "github.com/frankban/quicktest"
35 "github.com/gohugoio/hugo/config"
36 "github.com/gohugoio/hugo/hugofs"
37 )
38
39 var logger = loggers.NewErrorLogger()
40
41 type i18nTest struct {
42 name string
43 data map[string][]byte
44 args any
45 lang, id, expected, expectedFlag string
46 }
47
48 var i18nTests = []i18nTest{
49 // All translations present
50 {
51 name: "all-present",
52 data: map[string][]byte{
53 "en.toml": []byte("[hello]\nother = \"Hello, World!\""),
54 "es.toml": []byte("[hello]\nother = \"¡Hola, Mundo!\""),
55 },
56 args: nil,
57 lang: "es",
58 id: "hello",
59 expected: "¡Hola, Mundo!",
60 expectedFlag: "¡Hola, Mundo!",
61 },
62 // Translation missing in current language but present in default
63 {
64 name: "present-in-default",
65 data: map[string][]byte{
66 "en.toml": []byte("[hello]\nother = \"Hello, World!\""),
67 "es.toml": []byte("[goodbye]\nother = \"¡Adiós, Mundo!\""),
68 },
69 args: nil,
70 lang: "es",
71 id: "hello",
72 expected: "Hello, World!",
73 expectedFlag: "[i18n] hello",
74 },
75 // Translation missing in default language but present in current
76 {
77 name: "present-in-current",
78 data: map[string][]byte{
79 "en.toml": []byte("[goodbye]\nother = \"Goodbye, World!\""),
80 "es.toml": []byte("[hello]\nother = \"¡Hola, Mundo!\""),
81 },
82 args: nil,
83 lang: "es",
84 id: "hello",
85 expected: "¡Hola, Mundo!",
86 expectedFlag: "¡Hola, Mundo!",
87 },
88 // Translation missing in both default and current language
89 {
90 name: "missing",
91 data: map[string][]byte{
92 "en.toml": []byte("[goodbye]\nother = \"Goodbye, World!\""),
93 "es.toml": []byte("[goodbye]\nother = \"¡Adiós, Mundo!\""),
94 },
95 args: nil,
96 lang: "es",
97 id: "hello",
98 expected: "",
99 expectedFlag: "[i18n] hello",
100 },
101 // Default translation file missing or empty
102 {
103 name: "file-missing",
104 data: map[string][]byte{
105 "en.toml": []byte(""),
106 },
107 args: nil,
108 lang: "es",
109 id: "hello",
110 expected: "",
111 expectedFlag: "[i18n] hello",
112 },
113 // Context provided
114 {
115 name: "context-provided",
116 data: map[string][]byte{
117 "en.toml": []byte("[wordCount]\nother = \"Hello, {{.WordCount}} people!\""),
118 "es.toml": []byte("[wordCount]\nother = \"¡Hola, {{.WordCount}} gente!\""),
119 },
120 args: struct {
121 WordCount int
122 }{
123 50,
124 },
125 lang: "es",
126 id: "wordCount",
127 expected: "¡Hola, 50 gente!",
128 expectedFlag: "¡Hola, 50 gente!",
129 },
130 // https://github.com/gohugoio/hugo/issues/7787
131 {
132 name: "readingTime-one",
133 data: map[string][]byte{
134 "en.toml": []byte(`[readingTime]
135 one = "One minute to read"
136 other = "{{ .Count }} minutes to read"
137 `),
138 },
139 args: 1,
140 lang: "en",
141 id: "readingTime",
142 expected: "One minute to read",
143 expectedFlag: "One minute to read",
144 },
145 {
146 name: "readingTime-many-dot",
147 data: map[string][]byte{
148 "en.toml": []byte(`[readingTime]
149 one = "One minute to read"
150 other = "{{ . }} minutes to read"
151 `),
152 },
153 args: 21,
154 lang: "en",
155 id: "readingTime",
156 expected: "21 minutes to read",
157 expectedFlag: "21 minutes to read",
158 },
159 {
160 name: "readingTime-many",
161 data: map[string][]byte{
162 "en.toml": []byte(`[readingTime]
163 one = "One minute to read"
164 other = "{{ .Count }} minutes to read"
165 `),
166 },
167 args: 21,
168 lang: "en",
169 id: "readingTime",
170 expected: "21 minutes to read",
171 expectedFlag: "21 minutes to read",
172 },
173 // Issue #8454
174 {
175 name: "readingTime-map-one",
176 data: map[string][]byte{
177 "en.toml": []byte(`[readingTime]
178 one = "One minute to read"
179 other = "{{ .Count }} minutes to read"
180 `),
181 },
182 args: map[string]any{"Count": 1},
183 lang: "en",
184 id: "readingTime",
185 expected: "One minute to read",
186 expectedFlag: "One minute to read",
187 },
188 {
189 name: "readingTime-string-one",
190 data: map[string][]byte{
191 "en.toml": []byte(`[readingTime]
192 one = "One minute to read"
193 other = "{{ . }} minutes to read"
194 `),
195 },
196 args: "1",
197 lang: "en",
198 id: "readingTime",
199 expected: "One minute to read",
200 expectedFlag: "One minute to read",
201 },
202 {
203 name: "readingTime-map-many",
204 data: map[string][]byte{
205 "en.toml": []byte(`[readingTime]
206 one = "One minute to read"
207 other = "{{ .Count }} minutes to read"
208 `),
209 },
210 args: map[string]any{"Count": 21},
211 lang: "en",
212 id: "readingTime",
213 expected: "21 minutes to read",
214 expectedFlag: "21 minutes to read",
215 },
216 {
217 name: "argument-float",
218 data: map[string][]byte{
219 "en.toml": []byte(`[float]
220 other = "Number is {{ . }}"
221 `),
222 },
223 args: 22.5,
224 lang: "en",
225 id: "float",
226 expected: "Number is 22.5",
227 expectedFlag: "Number is 22.5",
228 },
229 // Same id and translation in current language
230 // https://github.com/gohugoio/hugo/issues/2607
231 {
232 name: "same-id-and-translation",
233 data: map[string][]byte{
234 "es.toml": []byte("[hello]\nother = \"hello\""),
235 "en.toml": []byte("[hello]\nother = \"hi\""),
236 },
237 args: nil,
238 lang: "es",
239 id: "hello",
240 expected: "hello",
241 expectedFlag: "hello",
242 },
243 // Translation missing in current language, but same id and translation in default
244 {
245 name: "same-id-and-translation-default",
246 data: map[string][]byte{
247 "es.toml": []byte("[bye]\nother = \"bye\""),
248 "en.toml": []byte("[hello]\nother = \"hello\""),
249 },
250 args: nil,
251 lang: "es",
252 id: "hello",
253 expected: "hello",
254 expectedFlag: "[i18n] hello",
255 },
256 // Unknown language code should get its plural spec from en
257 {
258 name: "unknown-language-code",
259 data: map[string][]byte{
260 "en.toml": []byte(`[readingTime]
261 one ="one minute read"
262 other = "{{.Count}} minutes read"`),
263 "klingon.toml": []byte(`[readingTime]
264 one = "eitt minutt med lesing"
265 other = "{{ .Count }} minuttar lesing"`),
266 },
267 args: 3,
268 lang: "klingon",
269 id: "readingTime",
270 expected: "3 minuttar lesing",
271 expectedFlag: "3 minuttar lesing",
272 },
273 // Issue #7838
274 {
275 name: "unknown-language-codes",
276 data: map[string][]byte{
277 "en.toml": []byte(`[readingTime]
278 one ="en one"
279 other = "en count {{.Count}}"`),
280 "a1.toml": []byte(`[readingTime]
281 one = "a1 one"
282 other = "a1 count {{ .Count }}"`),
283 "a2.toml": []byte(`[readingTime]
284 one = "a2 one"
285 other = "a2 count {{ .Count }}"`),
286 },
287 args: 3,
288 lang: "a2",
289 id: "readingTime",
290 expected: "a2 count 3",
291 expectedFlag: "a2 count 3",
292 },
293 // https://github.com/gohugoio/hugo/issues/7798
294 {
295 name: "known-language-missing-plural",
296 data: map[string][]byte{
297 "oc.toml": []byte(`[oc]
298 one = "abc"`),
299 },
300 args: 1,
301 lang: "oc",
302 id: "oc",
303 expected: "abc",
304 expectedFlag: "abc",
305 },
306 // https://github.com/gohugoio/hugo/issues/7794
307 {
308 name: "dotted-bare-key",
309 data: map[string][]byte{
310 "en.toml": []byte(`"shop_nextPage.one" = "Show Me The Money"
311 `),
312 },
313 args: nil,
314 lang: "en",
315 id: "shop_nextPage.one",
316 expected: "Show Me The Money",
317 expectedFlag: "Show Me The Money",
318 },
319 // https: //github.com/gohugoio/hugo/issues/7804
320 {
321 name: "lang-with-hyphen",
322 data: map[string][]byte{
323 "pt-br.toml": []byte(`foo.one = "abc"`),
324 },
325 args: 1,
326 lang: "pt-br",
327 id: "foo",
328 expected: "abc",
329 expectedFlag: "abc",
330 },
331 }
332
333 func TestPlural(t *testing.T) {
334 c := qt.New(t)
335
336 for _, test := range []struct {
337 name string
338 lang string
339 id string
340 templ string
341 variants []types.KeyValue
342 }{
343 {
344 name: "English",
345 lang: "en",
346 id: "hour",
347 templ: `
348 [hour]
349 one = "{{ . }} hour"
350 other = "{{ . }} hours"`,
351 variants: []types.KeyValue{
352 {Key: 1, Value: "1 hour"},
353 {Key: "1", Value: "1 hour"},
354 {Key: 1.5, Value: "1.5 hours"},
355 {Key: "1.5", Value: "1.5 hours"},
356 {Key: 2, Value: "2 hours"},
357 {Key: "2", Value: "2 hours"},
358 },
359 },
360 {
361 name: "Other only",
362 lang: "en",
363 id: "hour",
364 templ: `
365 [hour]
366 other = "{{ with . }}{{ . }}{{ end }} hours"`,
367 variants: []types.KeyValue{
368 {Key: 1, Value: "1 hours"},
369 {Key: "1", Value: "1 hours"},
370 {Key: 2, Value: "2 hours"},
371 {Key: nil, Value: " hours"},
372 },
373 },
374 {
375 name: "Polish",
376 lang: "pl",
377 id: "day",
378 templ: `
379 [day]
380 one = "{{ . }} miesiąc"
381 few = "{{ . }} miesiące"
382 many = "{{ . }} miesięcy"
383 other = "{{ . }} miesiąca"
384 `,
385 variants: []types.KeyValue{
386 {Key: 1, Value: "1 miesiąc"},
387 {Key: 2, Value: "2 miesiące"},
388 {Key: 100, Value: "100 miesięcy"},
389 {Key: "100.0", Value: "100.0 miesiąca"},
390 {Key: 100.0, Value: "100 miesiąca"},
391 },
392 },
393 } {
394
395 c.Run(test.name, func(c *qt.C) {
396 cfg := getConfig()
397 cfg.Set("enableMissingTranslationPlaceholders", true)
398 fs := hugofs.NewMem(cfg)
399
400 err := afero.WriteFile(fs.Source, filepath.Join("i18n", test.lang+".toml"), []byte(test.templ), 0755)
401 c.Assert(err, qt.IsNil)
402
403 tp := NewTranslationProvider()
404 depsCfg := newDepsConfig(tp, cfg, fs)
405 depsCfg.Logger = loggers.NewWarningLogger()
406 d, err := deps.New(depsCfg)
407 c.Assert(err, qt.IsNil)
408 c.Assert(d.LoadResources(), qt.IsNil)
409
410 f := tp.t.Func(test.lang)
411
412 for _, variant := range test.variants {
413 c.Assert(f(test.id, variant.Key), qt.Equals, variant.Value, qt.Commentf("input: %v", variant.Key))
414 c.Assert(int(depsCfg.Logger.LogCounters().WarnCounter.Count()), qt.Equals, 0)
415 }
416
417 })
418
419 }
420 }
421
422 func doTestI18nTranslate(t testing.TB, test i18nTest, cfg config.Provider) string {
423 tp := prepareTranslationProvider(t, test, cfg)
424 f := tp.t.Func(test.lang)
425 return f(test.id, test.args)
426 }
427
428 type countField struct {
429 Count any
430 }
431
432 type noCountField struct {
433 Counts int
434 }
435
436 type countMethod struct {
437 }
438
439 func (c countMethod) Count() any {
440 return 32.5
441 }
442
443 func TestGetPluralCount(t *testing.T) {
444 c := qt.New(t)
445
446 c.Assert(getPluralCount(map[string]any{"Count": 32}), qt.Equals, 32)
447 c.Assert(getPluralCount(map[string]any{"Count": 1}), qt.Equals, 1)
448 c.Assert(getPluralCount(map[string]any{"Count": 1.5}), qt.Equals, "1.5")
449 c.Assert(getPluralCount(map[string]any{"Count": "32"}), qt.Equals, "32")
450 c.Assert(getPluralCount(map[string]any{"Count": "32.5"}), qt.Equals, "32.5")
451 c.Assert(getPluralCount(map[string]any{"count": 32}), qt.Equals, 32)
452 c.Assert(getPluralCount(map[string]any{"Count": "32"}), qt.Equals, "32")
453 c.Assert(getPluralCount(map[string]any{"Counts": 32}), qt.Equals, nil)
454 c.Assert(getPluralCount("foo"), qt.Equals, nil)
455 c.Assert(getPluralCount(countField{Count: 22}), qt.Equals, 22)
456 c.Assert(getPluralCount(countField{Count: 1.5}), qt.Equals, "1.5")
457 c.Assert(getPluralCount(&countField{Count: 22}), qt.Equals, 22)
458 c.Assert(getPluralCount(noCountField{Counts: 23}), qt.Equals, nil)
459 c.Assert(getPluralCount(countMethod{}), qt.Equals, "32.5")
460 c.Assert(getPluralCount(&countMethod{}), qt.Equals, "32.5")
461
462 c.Assert(getPluralCount(1234), qt.Equals, 1234)
463 c.Assert(getPluralCount(1234.4), qt.Equals, "1234.4")
464 c.Assert(getPluralCount(1234.0), qt.Equals, "1234.0")
465 c.Assert(getPluralCount("1234"), qt.Equals, "1234")
466 c.Assert(getPluralCount("0.5"), qt.Equals, "0.5")
467 c.Assert(getPluralCount(nil), qt.Equals, nil)
468 }
469
470 func prepareTranslationProvider(t testing.TB, test i18nTest, cfg config.Provider) *TranslationProvider {
471 c := qt.New(t)
472 fs := hugofs.NewMem(cfg)
473
474 for file, content := range test.data {
475 err := afero.WriteFile(fs.Source, filepath.Join("i18n", file), []byte(content), 0755)
476 c.Assert(err, qt.IsNil)
477 }
478
479 tp := NewTranslationProvider()
480 depsCfg := newDepsConfig(tp, cfg, fs)
481 d, err := deps.New(depsCfg)
482 c.Assert(err, qt.IsNil)
483 c.Assert(d.LoadResources(), qt.IsNil)
484
485 return tp
486 }
487
488 func newDepsConfig(tp *TranslationProvider, cfg config.Provider, fs *hugofs.Fs) deps.DepsCfg {
489 l := langs.NewLanguage("en", cfg)
490 l.Set("i18nDir", "i18n")
491 return deps.DepsCfg{
492 Language: l,
493 Site: page.NewDummyHugoSite(cfg),
494 Cfg: cfg,
495 Fs: fs,
496 Logger: logger,
497 TemplateProvider: tplimpl.DefaultTemplateProvider,
498 TranslationProvider: tp,
499 }
500 }
501
502 func getConfig() config.Provider {
503 v := config.NewWithTestDefaults()
504 langs.LoadLanguageSettings(v, nil)
505 mod, err := modules.CreateProjectModule(v)
506 if err != nil {
507 panic(err)
508 }
509 v.Set("allModules", modules.Modules{mod})
510
511 return v
512 }
513
514 func TestI18nTranslate(t *testing.T) {
515 c := qt.New(t)
516 var actual, expected string
517 v := getConfig()
518
519 // Test without and with placeholders
520 for _, enablePlaceholders := range []bool{false, true} {
521 v.Set("enableMissingTranslationPlaceholders", enablePlaceholders)
522
523 for _, test := range i18nTests {
524 c.Run(fmt.Sprintf("%s-%t", test.name, enablePlaceholders), func(c *qt.C) {
525 if enablePlaceholders {
526 expected = test.expectedFlag
527 } else {
528 expected = test.expected
529 }
530 actual = doTestI18nTranslate(c, test, v)
531 c.Assert(actual, qt.Equals, expected)
532 })
533 }
534 }
535 }
536
537 func BenchmarkI18nTranslate(b *testing.B) {
538 v := getConfig()
539 for _, test := range i18nTests {
540 b.Run(test.name, func(b *testing.B) {
541 tp := prepareTranslationProvider(b, test, v)
542 b.ResetTimer()
543 for i := 0; i < b.N; i++ {
544 f := tp.t.Func(test.lang)
545 actual := f(test.id, test.args)
546 if actual != test.expected {
547 b.Fatalf("expected %v got %v", test.expected, actual)
548 }
549 }
550 })
551 }
552 }