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 }