Язык выбирается в шапке панели, а до первого выбора берётся из настроек браузера. Переведены интерфейс, подписи и описания всех параметров конфигов, ошибки API и служебные сообщения менеджера в консоли. Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на выходе из API по словарю, где ключом служит русская фраза из кода, — вывод самого игрового сервера через него проходит без изменений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
5.4 KiB
Go
179 lines
5.4 KiB
Go
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
|
|
}
|