Русский и английский языки интерфейса
Язык выбирается в шапке панели, а до первого выбора берётся из настроек браузера. Переведены интерфейс, подписи и описания всех параметров конфигов, ошибки API и служебные сообщения менеджера в консоли. Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на выходе из API по словарю, где ключом служит русская фраза из кода, — вывод самого игрового сервера через него проходит без изменений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Переводы легко разъезжаются: строку добавили в один язык и забыли про другой.
|
||||
// Тесты ниже сверяют словари между собой и с разметкой.
|
||||
|
||||
func TestUIStringsCoverBothLanguages(t *testing.T) {
|
||||
tables := parseStrings(t)
|
||||
ru, en := tables["ru"], tables["en"]
|
||||
if len(ru) == 0 || len(en) == 0 {
|
||||
t.Fatalf("словари не разобраны: ru=%d en=%d", len(ru), len(en))
|
||||
}
|
||||
for key := range ru {
|
||||
if _, ok := en[key]; !ok {
|
||||
t.Errorf("нет английского перевода: %s", key)
|
||||
}
|
||||
}
|
||||
for key := range en {
|
||||
if _, ok := ru[key]; !ok {
|
||||
t.Errorf("нет русского перевода: %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Подстановки вида {count} должны совпадать: иначе в одном языке значение
|
||||
// просто не появится.
|
||||
func TestUIStringsPlaceholders(t *testing.T) {
|
||||
tables := parseStrings(t)
|
||||
placeholder := regexp.MustCompile(`\{(\w+)\}`)
|
||||
|
||||
for key, ruText := range tables["ru"] {
|
||||
enText, ok := tables["en"][key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if got, want := names(placeholder, enText), names(placeholder, ruText); !equal(got, want) {
|
||||
t.Errorf("%s: подстановки ru=%v, en=%v", key, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Каждый ключ из разметки должен существовать в словаре, иначе на странице
|
||||
// вместо подписи окажется сам ключ.
|
||||
func TestMarkupKeysExist(t *testing.T) {
|
||||
markup, err := os.ReadFile("static/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables := parseStrings(t)
|
||||
attr := regexp.MustCompile(`data-i18n(?:-html|-placeholder|-title)?="([^"]+)"`)
|
||||
|
||||
for _, match := range attr.FindAllStringSubmatch(string(markup), -1) {
|
||||
if _, ok := tables["ru"][match[1]]; !ok {
|
||||
t.Errorf("ключ из index.html отсутствует в словаре: %s", match[1])
|
||||
}
|
||||
}
|
||||
|
||||
script, err := os.ReadFile("static/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Ключи, собранные из кусков (`state.${state}`), сюда не попадают — у них
|
||||
// в коде есть запасной вариант на случай отсутствия перевода.
|
||||
call := regexp.MustCompile(`\bt\("([^"]+)"`)
|
||||
found := 0
|
||||
for _, match := range call.FindAllStringSubmatch(string(script), -1) {
|
||||
found++
|
||||
if _, ok := tables["ru"][match[1]]; !ok {
|
||||
t.Errorf("ключ из app.js отсутствует в словаре: %s", match[1])
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Error("в app.js не найдено ни одного обращения к словарю")
|
||||
}
|
||||
}
|
||||
|
||||
// Подписи параметров конфигов лежат отдельными файлами: в них должны быть одни
|
||||
// и те же ключи, и у каждого — подпись и описание.
|
||||
func TestParamHintsMatch(t *testing.T) {
|
||||
ru := readHints(t, "static/hints-ru.json")
|
||||
en := readHints(t, "static/hints-en.json")
|
||||
|
||||
for _, section := range []string{"config", "sandbox"} {
|
||||
if len(ru[section]) == 0 {
|
||||
t.Fatalf("раздел %s пуст", section)
|
||||
}
|
||||
if len(ru[section]) != len(en[section]) {
|
||||
t.Errorf("%s: ru=%d, en=%d параметров", section, len(ru[section]), len(en[section]))
|
||||
}
|
||||
for key, value := range ru[section] {
|
||||
translated, ok := en[section][key]
|
||||
if !ok {
|
||||
t.Errorf("%s: нет английской подписи для %s", section, key)
|
||||
continue
|
||||
}
|
||||
for _, pair := range [][]string{value, translated} {
|
||||
if len(pair) != 2 || strings.TrimSpace(pair[0]) == "" || strings.TrimSpace(pair[1]) == "" {
|
||||
t.Errorf("%s.%s: ожидались подпись и описание, получено %v", section, key, pair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readHints(t *testing.T, path string) map[string]map[string][]string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hints map[string]map[string][]string
|
||||
if err := json.Unmarshal(data, &hints); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
// parseStrings вытаскивает словари из i18n.js. Разбирать JavaScript целиком
|
||||
// незачем: строки записаны построчно, ключ в кавычках и двоеточие.
|
||||
func parseStrings(t *testing.T) map[string]map[string]string {
|
||||
t.Helper()
|
||||
source, err := os.ReadFile("static/i18n.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tables := map[string]map[string]string{}
|
||||
entry := regexp.MustCompile(`^\s{4}"([^"]+)": "(.*)",$`)
|
||||
current := ""
|
||||
for _, line := range strings.Split(string(source), "\n") {
|
||||
switch strings.TrimSpace(strings.TrimSuffix(line, "\r")) {
|
||||
case "ru: {":
|
||||
current = "ru"
|
||||
tables[current] = map[string]string{}
|
||||
continue
|
||||
case "en: {":
|
||||
current = "en"
|
||||
tables[current] = map[string]string{}
|
||||
continue
|
||||
}
|
||||
if current == "" {
|
||||
continue
|
||||
}
|
||||
if match := entry.FindStringSubmatch(strings.TrimSuffix(line, "\r")); match != nil {
|
||||
tables[current][match[1]] = match[2]
|
||||
}
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
func names(re *regexp.Regexp, text string) []string {
|
||||
found := []string{}
|
||||
for _, match := range re.FindAllStringSubmatch(text, -1) {
|
||||
found = append(found, match[1])
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found
|
||||
}
|
||||
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user