mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): GameText, the game's text in the viewer's language
One helper for game text on the dashboard: zone names, locale phrases, localized table columns, %[key] expansion and the game's currency words, looked up in the viewer's language (a cookie pick, else Accept-Language), falling back to en_US and then the key or id. Unit tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"PlayerActions.cpp"
|
||||
"AccountRoutes.cpp"
|
||||
"ClientAssets.cpp"
|
||||
"GameText.cpp"
|
||||
"MaintenanceRoutes.cpp"
|
||||
"ReportRoutes.cpp"
|
||||
"EconomyPlaces.cpp"
|
||||
|
||||
195
dDashboardServer/routes/GameText.cpp
Normal file
195
dDashboardServer/routes/GameText.cpp
Normal file
@@ -0,0 +1,195 @@
|
||||
#include "GameText.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <map>
|
||||
|
||||
#include "HTTPContext.h"
|
||||
#include "Locale.h"
|
||||
#include "LocaleText.h"
|
||||
|
||||
namespace {
|
||||
thread_local std::string t_Language = GameText::DEFAULT_LANGUAGE;
|
||||
|
||||
// Zone names per language, built by Init (read-only afterwards)
|
||||
std::map<std::string, nlohmann::json> g_ZoneNames;
|
||||
const nlohmann::json g_NoZones = nlohmann::json{ { "0", "Character Select" } };
|
||||
|
||||
// The part of a tag before the region: "de_DE" -> "de"
|
||||
std::string_view Primary(std::string_view tag) {
|
||||
return tag.substr(0, tag.find('_'));
|
||||
}
|
||||
|
||||
bool EqualNoCase(std::string_view a, std::string_view b) {
|
||||
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
|
||||
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
|
||||
});
|
||||
}
|
||||
|
||||
std::string_view Trim(std::string_view text) {
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) text.remove_prefix(1);
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.back()))) text.remove_suffix(1);
|
||||
return text;
|
||||
}
|
||||
|
||||
nlohmann::json BuildZoneNames(const std::string& language) {
|
||||
nlohmann::json names = g_NoZones;
|
||||
constexpr std::string_view PREFIX = "ZoneTable_", SUFFIX = "_DisplayDescription";
|
||||
for (const auto& key : Locale::GetPhraseIdsWithPrefix(std::string(PREFIX))) {
|
||||
if (key.size() <= PREFIX.size() + SUFFIX.size() || !key.ends_with(SUFFIX)) continue;
|
||||
const auto& name = Locale::GetPhrase(key, language);
|
||||
if (!name.empty()) names[key.substr(PREFIX.size(), key.size() - PREFIX.size() - SUFFIX.size())] = name;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
namespace GameText {
|
||||
std::string NormalizeTag(std::string_view tag) {
|
||||
std::string out(Trim(tag));
|
||||
std::replace(out.begin(), out.end(), '-', '_');
|
||||
const auto split = out.find('_');
|
||||
for (size_t i = 0; i < out.size(); i++) {
|
||||
const auto c = static_cast<unsigned char>(out[i]);
|
||||
out[i] = static_cast<char>(split != std::string::npos && i > split ? std::toupper(c) : std::tolower(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> MatchAcceptLanguage(std::string_view header, const std::vector<std::string>& available) {
|
||||
struct Wanted { std::string tag; double weight; size_t order; };
|
||||
std::vector<Wanted> wanted;
|
||||
size_t order = 0;
|
||||
while (!header.empty()) {
|
||||
const auto comma = header.find(',');
|
||||
auto part = header.substr(0, comma);
|
||||
header = comma == std::string_view::npos ? std::string_view{} : header.substr(comma + 1);
|
||||
double weight = 1.0;
|
||||
if (const auto semi = part.find(';'); semi != std::string_view::npos) {
|
||||
const auto param = Trim(part.substr(semi + 1));
|
||||
if (param.starts_with("q=")) {
|
||||
try { weight = std::stod(std::string(param.substr(2))); } catch (...) { weight = 0.0; }
|
||||
}
|
||||
part = part.substr(0, semi);
|
||||
}
|
||||
const auto tag = NormalizeTag(part);
|
||||
if (tag.empty() || tag == "*" || weight <= 0.0) continue;
|
||||
wanted.push_back({ tag, weight, order++ });
|
||||
}
|
||||
std::stable_sort(wanted.begin(), wanted.end(), [](const Wanted& a, const Wanted& b) { return a.weight > b.weight; });
|
||||
for (const auto& want : wanted) {
|
||||
for (const auto& language : available) if (EqualNoCase(NormalizeTag(language), want.tag)) return language;
|
||||
for (const auto& language : available) if (EqualNoCase(Primary(NormalizeTag(language)), Primary(want.tag))) return language;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string CookieValue(std::string_view cookieHeader, std::string_view name) {
|
||||
while (!cookieHeader.empty()) {
|
||||
const auto semi = cookieHeader.find(';');
|
||||
const auto part = Trim(cookieHeader.substr(0, semi));
|
||||
cookieHeader = semi == std::string_view::npos ? std::string_view{} : cookieHeader.substr(semi + 1);
|
||||
const auto equals = part.find('=');
|
||||
if (equals != std::string_view::npos && Trim(part.substr(0, equals)) == name) return std::string(Trim(part.substr(equals + 1)));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string ChooseLanguage(std::string_view cookie, std::string_view acceptLanguage, const std::vector<std::string>& available) {
|
||||
if (!cookie.empty()) {
|
||||
for (const auto& language : available) if (language == cookie) return language;
|
||||
}
|
||||
if (const auto match = MatchAcceptLanguage(acceptLanguage, available)) return *match;
|
||||
return available.empty() ? DEFAULT_LANGUAGE : available.front();
|
||||
}
|
||||
|
||||
std::string Key(std::string_view table, int64_t id, std::string_view column) {
|
||||
std::string key(table);
|
||||
key += '_';
|
||||
key += std::to_string(id);
|
||||
key += '_';
|
||||
key += column;
|
||||
return key;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& Languages() {
|
||||
static const std::vector<std::string> fallback{ DEFAULT_LANGUAGE };
|
||||
const auto& loaded = Locale::GetLocales();
|
||||
return loaded.empty() ? fallback : loaded;
|
||||
}
|
||||
|
||||
const std::string& Language() {
|
||||
return t_Language;
|
||||
}
|
||||
|
||||
LanguageScope::LanguageScope(std::string language) : m_Previous(std::move(t_Language)) {
|
||||
t_Language = std::move(language);
|
||||
}
|
||||
|
||||
LanguageScope::LanguageScope(const HTTPContext& context)
|
||||
: LanguageScope(ChooseLanguage(CookieValue(context.GetHeader("cookie"), COOKIE), context.GetHeader("accept-language"), Languages())) {}
|
||||
|
||||
LanguageScope::~LanguageScope() {
|
||||
t_Language = std::move(m_Previous);
|
||||
}
|
||||
|
||||
const std::string& Phrase(const std::string& key) {
|
||||
return Locale::GetPhrase(key, t_Language);
|
||||
}
|
||||
|
||||
std::string Text(const std::string& key, const std::string& fallback) {
|
||||
const auto& phrase = Phrase(key);
|
||||
return phrase.empty() ? fallback : phrase;
|
||||
}
|
||||
|
||||
std::string TextOrKey(const std::string& key) {
|
||||
return Text(key, key);
|
||||
}
|
||||
|
||||
std::string Name(std::string_view table, int64_t id, std::string_view column) {
|
||||
return Text(Key(table, id, column), std::string(table) + " " + std::to_string(id));
|
||||
}
|
||||
|
||||
std::string ZoneName(uint32_t zoneId) {
|
||||
const auto& names = ZoneNames();
|
||||
const auto key = std::to_string(zoneId);
|
||||
if (const auto it = names.find(key); it != names.end() && it->is_string()) return it->get<std::string>();
|
||||
return Text("ZoneTable_" + key + "_DisplayDescription", "Zone " + key);
|
||||
}
|
||||
|
||||
const nlohmann::json& ZoneNames() {
|
||||
if (const auto it = g_ZoneNames.find(t_Language); it != g_ZoneNames.end()) return it->second;
|
||||
if (const auto it = g_ZoneNames.find(Languages().front()); it != g_ZoneNames.end()) return it->second;
|
||||
return g_NoZones;
|
||||
}
|
||||
|
||||
std::string Expand(const std::string& text) {
|
||||
return LocaleText::Expand(text, [](const std::string& key) -> const std::string& { return Phrase(key); });
|
||||
}
|
||||
|
||||
const std::vector<std::pair<std::string, std::string>>& TermKeys() {
|
||||
static const std::vector<std::pair<std::string, std::string>> keys{
|
||||
{ "coins", "UI_COINS" },
|
||||
{ "uscore", "UI_UNIVERSE_SCORE" },
|
||||
{ "imagination", "UI_IMAGINATION" },
|
||||
{ "reputation", "UI_REPUTATION" },
|
||||
{ "life", "UI_HEALTH" },
|
||||
};
|
||||
return keys;
|
||||
}
|
||||
|
||||
nlohmann::json Terms() {
|
||||
nlohmann::json terms = nlohmann::json::object();
|
||||
for (const auto& [name, key] : TermKeys()) terms[name] = TextOrKey(key);
|
||||
return terms;
|
||||
}
|
||||
|
||||
nlohmann::json PageJson() {
|
||||
return { { "language", t_Language }, { "languages", Languages() }, { "terms", Terms() }, { "zones", ZoneNames() } };
|
||||
}
|
||||
|
||||
void Init() {
|
||||
g_ZoneNames.clear();
|
||||
for (const auto& language : Languages()) g_ZoneNames[language] = BuildZoneNames(language);
|
||||
}
|
||||
}
|
||||
100
dDashboardServer/routes/GameText.h
Normal file
100
dDashboardServer/routes/GameText.h
Normal file
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
struct HTTPContext;
|
||||
|
||||
/**
|
||||
* The game's own text (zone, object, mission, activity names, currencies, ...) as the client's locale has it, in the
|
||||
* viewer's language. The dashboard never keeps its own copies of game text: C++ routes call these, templates get them
|
||||
* through the `game` data and the phrase()/zone_name() callbacks, and scripts read them with GameText.* in common.js.
|
||||
*
|
||||
* The phrases are loaded once at startup (Locale::LoadFromFile with every locale, then Init) and only read afterwards,
|
||||
* so any thread may look them up. The viewer's language is per thread: RouteUtils sets it for each request
|
||||
* (LanguageScope) and Workers::Reply carries it to the worker thread.
|
||||
*/
|
||||
namespace GameText {
|
||||
constexpr const char* DEFAULT_LANGUAGE = "en_US";
|
||||
constexpr const char* COOKIE = "game_lang"; // the viewer's pick (common.js keeps it in step with the account's preference)
|
||||
|
||||
// ---- Pure helpers (unit tested) ----
|
||||
|
||||
// "de-DE" / "de_de" -> "de_DE"; "de" -> "de"
|
||||
std::string NormalizeTag(std::string_view tag);
|
||||
|
||||
/**
|
||||
* The best of `available` (locale names like "de_DE") for an Accept-Language header ("de-CH,de;q=0.9,en;q=0.5"):
|
||||
* by the header's weights, an exact match first, then the same language in another region (de-CH -> de_DE, en ->
|
||||
* en_US when listed first). nullopt when nothing matches.
|
||||
*/
|
||||
std::optional<std::string> MatchAcceptLanguage(std::string_view header, const std::vector<std::string>& available);
|
||||
|
||||
// A cookie's value from a Cookie header ("a=1; game_lang=de_DE"); empty when absent
|
||||
std::string CookieValue(std::string_view cookieHeader, std::string_view name);
|
||||
|
||||
/**
|
||||
* The language to show: the cookie's pick when it's one of `available`, else the best Accept-Language match,
|
||||
* else `available`'s first (the default), else DEFAULT_LANGUAGE
|
||||
*/
|
||||
std::string ChooseLanguage(std::string_view cookie, std::string_view acceptLanguage, const std::vector<std::string>& available);
|
||||
|
||||
// "<table>_<id>_<column>": Key("Objects", 1727) is "Objects_1727_name"
|
||||
std::string Key(std::string_view table, int64_t id, std::string_view column = "name");
|
||||
|
||||
// ---- The viewer's language ----
|
||||
|
||||
// The languages the client's locale has, the default first
|
||||
const std::vector<std::string>& Languages();
|
||||
// This thread's viewer language (DEFAULT_LANGUAGE outside a request)
|
||||
const std::string& Language();
|
||||
|
||||
// Sets this thread's language for its lifetime
|
||||
class LanguageScope {
|
||||
public:
|
||||
explicit LanguageScope(std::string language);
|
||||
// The request's language (cookie, then Accept-Language)
|
||||
explicit LanguageScope(const HTTPContext& context);
|
||||
~LanguageScope();
|
||||
LanguageScope(const LanguageScope&) = delete;
|
||||
LanguageScope& operator=(const LanguageScope&) = delete;
|
||||
private:
|
||||
std::string m_Previous;
|
||||
};
|
||||
|
||||
// ---- Lookups, in the viewer's language, then the default; the fallbacks are the raw key or id ----
|
||||
|
||||
// The phrase for `key`; empty when the locale lacks it
|
||||
const std::string& Phrase(const std::string& key);
|
||||
// The phrase for `key`, else `fallback`
|
||||
std::string Text(const std::string& key, const std::string& fallback);
|
||||
// The phrase for `key`, else the key itself
|
||||
std::string TextOrKey(const std::string& key);
|
||||
// A row's localized column ("Missions", 5, "name"), else "<table> <id>"
|
||||
std::string Name(std::string_view table, int64_t id, std::string_view column = "name");
|
||||
// A zone's display name (ZoneTable_<id>_DisplayDescription), else "Zone <id>"; 0 is character select
|
||||
std::string ZoneName(uint32_t zoneId);
|
||||
// {"<zone id>": name} for every zone the locale names (and "0"), in the viewer's language
|
||||
const nlohmann::json& ZoneNames();
|
||||
// Every %[KEY] in `text` replaced by its phrase (mail and bug reports store text this way)
|
||||
std::string Expand(const std::string& text);
|
||||
|
||||
/**
|
||||
* The game's words the pages show as labels ({coins: "Coins", uscore: "Universe Score", imagination, reputation,
|
||||
* life}), in the viewer's language; the key when the locale lacks one
|
||||
*/
|
||||
nlohmann::json Terms();
|
||||
// The key -> locale phrase id table behind Terms()
|
||||
const std::vector<std::pair<std::string, std::string>>& TermKeys();
|
||||
|
||||
// What every page gets: {language, languages, terms, zones}
|
||||
nlohmann::json PageJson();
|
||||
|
||||
// Build the per-language caches (zone names). Call on the main thread after the locale loads, before workers start.
|
||||
void Init();
|
||||
}
|
||||
@@ -36,6 +36,8 @@ set(DWEBTESTS_SOURCES
|
||||
"ModeratorHelperTests.cpp"
|
||||
"ChatHistoryTests.cpp"
|
||||
"ClientSysInfoViewTests.cpp"
|
||||
"GameTextTests.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/GameText.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/ClientSysInfoView.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/ChatHistory.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ClaudeClient.cpp"
|
||||
|
||||
147
tests/dWebTests/GameTextTests.cpp
Normal file
147
tests/dWebTests/GameTextTests.cpp
Normal file
@@ -0,0 +1,147 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include "GameText.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "Locale.h"
|
||||
|
||||
using namespace GameText;
|
||||
|
||||
namespace {
|
||||
const std::vector<std::string> LANGUAGES{ "en_US", "de_DE", "en_GB" };
|
||||
|
||||
// A small locale.xml like the client's: three locales, a few phrases (some without every translation)
|
||||
class GameTextLocaleTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
m_Path = std::filesystem::temp_directory_path() / "dlu_gametext_test_locale.xml";
|
||||
std::ofstream out(m_Path);
|
||||
out << R"(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<localization version="1.2">
|
||||
<locales count="3"><locale>en_US</locale><locale>de_DE</locale><locale>en_GB</locale></locales>
|
||||
<phrases count="5">
|
||||
<phrase id="ZoneTable_1200_DisplayDescription"><translation locale="en_US">Test Station</translation><translation locale="de_DE">Test-Station</translation><translation locale="en_GB">Test Station</translation></phrase>
|
||||
<phrase id="ZoneTable_1100_DisplayDescription"><translation locale="en_US">Test Gardens</translation></phrase>
|
||||
<phrase id="Objects_1727_name"><translation locale="en_US">Test Hat</translation><translation locale="de_DE">Testhut</translation></phrase>
|
||||
<phrase id="UI_COINS"><translation locale="en_US">Coins</translation><translation locale="de_DE">Muenzen</translation></phrase>
|
||||
<phrase id="MissionEmail_1_subjectText"><translation locale="en_US">Hello</translation><translation locale="de_DE">Hallo</translation></phrase>
|
||||
</phrases>
|
||||
</localization>)";
|
||||
out.close();
|
||||
Locale::LoadFromFile(m_Path.string(), "en_US", true);
|
||||
Init();
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
std::filesystem::remove(m_Path);
|
||||
}
|
||||
|
||||
std::filesystem::path m_Path;
|
||||
};
|
||||
}
|
||||
|
||||
TEST(GameTextTest, NormalizesTags) {
|
||||
EXPECT_EQ(NormalizeTag("de-DE"), "de_DE");
|
||||
EXPECT_EQ(NormalizeTag(" EN-gb "), "en_GB");
|
||||
EXPECT_EQ(NormalizeTag("de"), "de");
|
||||
}
|
||||
|
||||
TEST(GameTextTest, MatchesAcceptLanguage) {
|
||||
EXPECT_EQ(MatchAcceptLanguage("de-DE,de;q=0.9,en;q=0.8", LANGUAGES), "de_DE");
|
||||
EXPECT_EQ(MatchAcceptLanguage("en-GB", LANGUAGES), "en_GB");
|
||||
// Same language, other region: the first listed of that language
|
||||
EXPECT_EQ(MatchAcceptLanguage("de-CH", LANGUAGES), "de_DE");
|
||||
EXPECT_EQ(MatchAcceptLanguage("en-AU,en;q=0.5", LANGUAGES), "en_US");
|
||||
// Weights win over order
|
||||
EXPECT_EQ(MatchAcceptLanguage("fr;q=0.9,de;q=0.95", LANGUAGES), "de_DE");
|
||||
EXPECT_EQ(MatchAcceptLanguage("en;q=0.2, de-AT", LANGUAGES), "de_DE");
|
||||
// Nothing fitting, refused (q=0) or junk
|
||||
EXPECT_EQ(MatchAcceptLanguage("fr-FR,fr;q=0.9", LANGUAGES), std::nullopt);
|
||||
EXPECT_EQ(MatchAcceptLanguage("de;q=0", LANGUAGES), std::nullopt);
|
||||
EXPECT_EQ(MatchAcceptLanguage("*", LANGUAGES), std::nullopt);
|
||||
EXPECT_EQ(MatchAcceptLanguage("", LANGUAGES), std::nullopt);
|
||||
EXPECT_EQ(MatchAcceptLanguage("de;q=abc", LANGUAGES), std::nullopt);
|
||||
}
|
||||
|
||||
TEST(GameTextTest, ReadsCookies) {
|
||||
EXPECT_EQ(CookieValue("token=abc; game_lang=de_DE; x=1", "game_lang"), "de_DE");
|
||||
EXPECT_EQ(CookieValue("game_lang=en_GB", "game_lang"), "en_GB");
|
||||
EXPECT_EQ(CookieValue("my_game_lang=de_DE", "game_lang"), "");
|
||||
EXPECT_EQ(CookieValue("", "game_lang"), "");
|
||||
}
|
||||
|
||||
TEST(GameTextTest, ChoosesLanguage) {
|
||||
EXPECT_EQ(ChooseLanguage("de_DE", "en-US", LANGUAGES), "de_DE"); // the viewer's pick wins
|
||||
EXPECT_EQ(ChooseLanguage("xx_XX", "en-GB", LANGUAGES), "en_GB"); // an unknown pick is ignored
|
||||
EXPECT_EQ(ChooseLanguage("", "fr-FR", LANGUAGES), "en_US"); // nothing fits: the default
|
||||
EXPECT_EQ(ChooseLanguage("", "", {}), DEFAULT_LANGUAGE);
|
||||
}
|
||||
|
||||
TEST(GameTextTest, BuildsKeys) {
|
||||
EXPECT_EQ(Key("Objects", 1727), "Objects_1727_name");
|
||||
EXPECT_EQ(Key("Missions", 5, "description"), "Missions_5_description");
|
||||
}
|
||||
|
||||
TEST_F(GameTextLocaleTest, LoadsEveryLocale) {
|
||||
EXPECT_EQ(Languages(), LANGUAGES);
|
||||
EXPECT_EQ(Locale::GetPhrase("Objects_1727_name"), "Test Hat");
|
||||
EXPECT_EQ(Locale::GetPhrase("Objects_1727_name", "de_DE"), "Testhut");
|
||||
// Falls back to the default locale
|
||||
EXPECT_EQ(Locale::GetPhrase("Objects_1727_name", "en_GB"), "Test Hat");
|
||||
EXPECT_EQ(Locale::GetPhrase("Objects_1727_name", "xx_XX"), "Test Hat");
|
||||
EXPECT_EQ(Locale::GetPhrase("Nope", "de_DE"), "");
|
||||
}
|
||||
|
||||
TEST_F(GameTextLocaleTest, LooksUpInTheViewersLanguage) {
|
||||
EXPECT_EQ(Language(), DEFAULT_LANGUAGE);
|
||||
EXPECT_EQ(ZoneName(1200), "Test Station");
|
||||
EXPECT_EQ(Name("Objects", 1727), "Test Hat");
|
||||
{
|
||||
LanguageScope german("de_DE");
|
||||
EXPECT_EQ(Language(), "de_DE");
|
||||
EXPECT_EQ(ZoneName(1200), "Test-Station");
|
||||
EXPECT_EQ(ZoneName(1100), "Test Gardens"); // no German name: the default's
|
||||
EXPECT_EQ(ZoneNames()["1200"], "Test-Station");
|
||||
EXPECT_EQ(Name("Objects", 1727), "Testhut");
|
||||
EXPECT_EQ(Terms()["coins"], "Muenzen");
|
||||
EXPECT_EQ(Expand("%[MissionEmail_1_subjectText]!"), "Hallo!");
|
||||
EXPECT_EQ(PageJson()["language"], "de_DE");
|
||||
}
|
||||
EXPECT_EQ(Language(), DEFAULT_LANGUAGE);
|
||||
EXPECT_EQ(Terms()["coins"], "Coins");
|
||||
}
|
||||
|
||||
TEST_F(GameTextLocaleTest, FallsBackToTheKeyOrId) {
|
||||
EXPECT_EQ(ZoneName(9999), "Zone 9999");
|
||||
EXPECT_EQ(ZoneName(0), "Character Select");
|
||||
EXPECT_EQ(Name("Objects", 42), "Objects 42");
|
||||
EXPECT_EQ(Phrase("UI_NOT_THERE"), "");
|
||||
EXPECT_EQ(TextOrKey("UI_NOT_THERE"), "UI_NOT_THERE");
|
||||
EXPECT_EQ(Text("UI_NOT_THERE", "fallback"), "fallback");
|
||||
EXPECT_EQ(Terms()["reputation"], "UI_REPUTATION");
|
||||
EXPECT_EQ(Expand("%[Unknown] ok"), "%[Unknown] ok");
|
||||
}
|
||||
|
||||
TEST_F(GameTextLocaleTest, PicksTheRequestsLanguage) {
|
||||
HTTPContext context;
|
||||
context.headers["accept-language"] = "de-AT,de;q=0.9";
|
||||
{
|
||||
LanguageScope scope(context);
|
||||
EXPECT_EQ(Language(), "de_DE");
|
||||
}
|
||||
context.headers["cookie"] = "game_lang=en_GB";
|
||||
{
|
||||
LanguageScope scope(context);
|
||||
EXPECT_EQ(Language(), "en_GB");
|
||||
}
|
||||
EXPECT_EQ(Language(), DEFAULT_LANGUAGE);
|
||||
}
|
||||
|
||||
TEST_F(GameTextLocaleTest, EveryTermHasAKey) {
|
||||
for (const auto& [name, key] : TermKeys()) {
|
||||
EXPECT_FALSE(name.empty());
|
||||
EXPECT_TRUE(key.starts_with("UI_")) << key;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user