Updated Iris language files -

All 17 translations now use editable TOML files
Language menus and debug messages include translations
Headers explain all 243 variables, prefixes, colors and formatting
Generated guides refresh without overwriting customized messages
Missing or invalid messages fall back to English
Language reloads preserve server and player selections
Jars include localized guides and required TOML support
This commit is contained in:
Robert Stoneking committed 2026-09-10 01:17:18 -06:00
1 parent 62e61970f8
commit 45407a7e3f
45 files changed
+37032 -27536

No files matched your search

@@ -46,7 +46,7 @@ public class SettingsHotloadWatchTest {
previousSettings = IrisSettings.settings;
dataFolder = temporaryFolder.newFolder("iris-hotload");
settingsFile = new File(dataFolder, "iris.json");
overrideFolder = new File(dataFolder, "languages/overrides");
overrideFolder = new File(dataFolder, "languages");
Files.createDirectories(overrideFolder.toPath());
String settings = settings("en_US");
Files.writeString(settingsFile.toPath(), settings, StandardCharsets.UTF_8);
@@ -68,15 +68,15 @@ public class SettingsHotloadWatchTest {
}
@Test
public void activeLocaleSnapshotAppliesAndInvalidSnapshotRetainsLastGood() {
public void activeLocaleSnapshotAppliesAndInvalidSnapshotUsesEnglish() {
File override = override("en_US");
String valid = locale("en_US", "Active {permission}");
assertTrue(watch.applySnapshot(present(override, valid)));
assertEquals("Active " + PERMISSION, permissionMessage());
assertFalse(watch.applySnapshot(present(override, "{ invalid")));
assertEquals("Active " + PERMISSION, permissionMessage());
assertTrue(watch.applySnapshot(present(override, "{ invalid")));
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
@Test
@@ -120,13 +120,13 @@ public class SettingsHotloadWatchTest {
}
@Test
public void invalidLocaleBlocksItsSettingsSwitchAndRetainsThePreviousRuntime() throws Exception {
public void invalidLocaleAllowsItsSettingsSwitchWithEnglishFallback() throws Exception {
Files.writeString(override("de_DE").toPath(), "{ invalid", StandardCharsets.UTF_8);
assertFalse(watch.applySnapshot(present(settingsFile, settings("de_DE"))));
assertTrue(watch.applySnapshot(present(settingsFile, settings("de_DE"))));
assertEquals("en_US", IrisSettings.get().getGeneral().getLanguage());
assertEquals("en_US", IrisLanguage.activeLocale());
assertEquals("de_DE", IrisSettings.get().getGeneral().getLanguage());
assertEquals("de_DE", IrisLanguage.activeLocale());
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
@@ -226,7 +226,7 @@ public class SettingsHotloadWatchTest {
}
@Test(timeout = 8_000L)
public void oversizedActiveOverrideReportsOnceAndRetainsLastGoodCatalog() throws Exception {
public void oversizedActiveLanguageReportsOnceAndUsesEnglish() throws Exception {
File override = override("en_US");
assertTrue(watch.applySnapshot(present(override, locale("en_US", "Active {permission}"))));
byte[] oversized = new byte[2 * 1024 * 1024 + 1];
@@ -238,11 +238,11 @@ public class SettingsHotloadWatchTest {
diagnostic,
"Failed to read watched Iris file " + override.getAbsolutePath()
));
assertEquals("Active " + PERMISSION, permissionMessage());
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
@Test(timeout = 8_000L)
public void malformedUtf8FailureIsDeduplicatedAndRetainsLastGoodCatalog() throws Exception {
public void malformedUtf8FailureIsDeduplicatedAndUsesEnglish() throws Exception {
File override = override("en_US");
assertTrue(watch.applySnapshot(present(override, locale("en_US", "Active {permission}"))));
Files.write(override.toPath(), new byte[]{(byte) 0xC3, 0x28});
@@ -253,7 +253,7 @@ public class SettingsHotloadWatchTest {
diagnostic,
"Failed to read watched Iris file " + override.getAbsolutePath()
));
assertEquals("Active " + PERMISSION, permissionMessage());
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
private void awaitPermissionMessage(String expected) {
@@ -326,15 +326,15 @@ public class SettingsHotloadWatchTest {
}
private ConfigHotloadEngine.StableContentSnapshot present(File file, String content) {
return new ConfigHotloadEngine.StableContentSnapshot(file, "present", content.trim());
return new ConfigHotloadEngine.StableContentSnapshot(file, "present", content.trim(), 0L);
}
private ConfigHotloadEngine.StableContentSnapshot missing(File file) {
return new ConfigHotloadEngine.StableContentSnapshot(file, "missing", null);
return new ConfigHotloadEngine.StableContentSnapshot(file, "missing", null, 0L);
}
private File override(String locale) {
return new File(overrideFolder, locale + ".json").getAbsoluteFile();
return new File(overrideFolder, locale + ".toml").getAbsoluteFile();
}
private String permissionMessage() {
@@ -349,7 +349,6 @@ public class SettingsHotloadWatchTest {
}
private String locale(String locale, String permissionMessage) {
return "{\"locale\":\"" + locale + "\",\"messages\":{"
+ "\"iris.command.permission_denied\":\"" + permissionMessage + "\"}}";
return "[iris.command]\npermission_denied = \"" + permissionMessage + "\"\n";
}
}
@@ -4,8 +4,7 @@ import art.arcane.iris.testsupport.ProjectPaths;
import art.arcane.volmlib.util.localization.LocaleOverlay;
import art.arcane.volmlib.util.localization.LocalizationValidator;
import art.arcane.volmlib.util.localization.RemoteLanguageCatalog;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import art.arcane.volmlib.util.localization.TextValue;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.Rule;
@@ -30,34 +29,26 @@ import static org.junit.Assert.assertTrue;
public class IrisDownloadValidationTest {
private static final String LOCALE = "de_DE";
private static final String MISSING_KEY = "iris.director.commanddeveloper.director.stage_world_generation_update";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void incompleteCacheDownloadsCompleteCatalogAndReusesIt() throws Exception {
public void missingLanguageDownloadsOnceAndPreservesLaterEdits() throws Exception {
String complete = completeCatalog();
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(complete, requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path cache = catalog.cacheFile(LOCALE);
Files.createDirectories(cache.getParent());
Files.writeString(cache, incompleteCatalog(complete));
RemoteLanguageCatalog.CacheResult initial = catalog.read(LOCALE, IrisLanguage::validateDownload);
assertEquals(RemoteLanguageCatalog.CacheState.INVALID, initial.state());
assertTrue(initial.failure().getMessage().contains(MISSING_KEY));
assertEquals(0, requests.get());
assertEquals(complete, catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
assertEquals(complete, Files.readString(cache));
assertEquals(RemoteLanguageCatalog.CacheState.VALID,
catalog.read(LOCALE, IrisLanguage::validateDownload).state());
Path language = temporaryFolder.newFolder().toPath().resolve("languages/de_DE.toml");
assertEquals(complete, catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(complete, Files.readString(language));
assertEquals(1, requests.get());
assertEquals(complete, catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
String edited = "\"iris.command.unknown\" = \"Eigener Befehl\"\n";
Files.writeString(language, edited);
assertEquals(edited, catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(edited, Files.readString(language));
assertEquals(1, requests.get());
} finally {
server.stop(0);
@@ -65,36 +56,34 @@ public class IrisDownloadValidationTest {
}
@Test
public void incompleteUpstreamDoesNotReplaceAnInvalidCache() throws Exception {
String incomplete = incompleteCatalog(completeCatalog());
public void existingUnreadableLanguageIsNeverReplacedByADownload() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(incomplete, requests);
HttpServer server = server(completeCatalog(), requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path cache = catalog.cacheFile(LOCALE);
Files.createDirectories(cache.getParent());
String cached = "{\"locale\":\"de_DE\",\"messages\":{}}";
Files.writeString(cache, cached);
Path language = temporaryFolder.newFile("de_DE.toml").toPath();
String unreadable = "[unterminated";
Files.writeString(language, unreadable);
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
assertTrue(failure.getMessage().contains(MISSING_KEY));
assertEquals(cached, Files.readString(cache));
assertEquals(1, requests.get());
} finally {
server.stop(0);
}
}
@Test
public void incompleteUpstreamDoesNotCreateACacheFile() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(incompleteCatalog(completeCatalog()), requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
assertThrows(IllegalArgumentException.class,
() -> catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
assertFalse(Files.exists(catalog.cacheFile(LOCALE)));
() -> catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(unreadable, Files.readString(language));
assertEquals(0, requests.get());
} finally {
server.stop(0);
}
}
@Test
public void malformedDownloadDoesNotCreateALanguageFile() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server("[unterminated", requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path language = temporaryFolder.newFolder().toPath().resolve("de_DE.toml");
assertThrows(IllegalArgumentException.class,
() -> catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertFalse(Files.exists(language));
assertEquals(1, requests.get());
} finally {
server.stop(0);
@@ -102,9 +91,33 @@ public class IrisDownloadValidationTest {
}
@Test
public void partialOperatorOverridesRemainValid() {
LocaleOverlay overlay = IrisLanguage.parseOverlay("override", LOCALE, """
{"locale":"de_DE","messages":{"iris.command.unknown":"Unbekannter Iris-Befehl"}}
public void partialDownloadsInstallTheirValidMessages() throws Exception {
String partial = """
"iris.command.unknown" = "Unbekannter Iris-Befehl"
"iris.command.permission_denied" = "Missing placeholder"
"future.message" = "Future value"
""";
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(partial, requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path language = temporaryFolder.newFolder().toPath().resolve("de_DE.toml");
assertEquals(partial, catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(partial, Files.readString(language));
LocaleOverlay overlay = IrisLanguage.parseOverlay(language.toString(), LOCALE, partial);
assertEquals(1, overlay.values().size());
assertEquals("Unbekannter Iris-Befehl", ((TextValue)
overlay.value(IrisMessages.COMMAND_UNKNOWN.id())).template());
assertEquals(1, requests.get());
} finally {
server.stop(0);
}
}
@Test
public void partialLanguageFilesRemainValid() {
LocaleOverlay overlay = IrisLanguage.parseOverlay("language", LOCALE, """
"iris.command.unknown" = "Unbekannter Iris-Befehl"
""");
assertEquals(1, overlay.values().size());
@@ -112,19 +125,13 @@ public class IrisDownloadValidationTest {
}
private String completeCatalog() throws IOException {
return Files.readString(ProjectPaths.moduleFile("src/main/resources/languages").resolve(LOCALE + ".json"));
}
private String incompleteCatalog(String complete) {
JsonObject document = JsonParser.parseString(complete).getAsJsonObject();
assertTrue(document.getAsJsonObject("messages").remove(MISSING_KEY) != null);
return document.toString();
return Files.readString(ProjectPaths.moduleFile("src/main/resources/languages").resolve(LOCALE + ".toml"));
}
private HttpServer server(String content, AtomicInteger requests) throws IOException {
byte[] response = content.getBytes(StandardCharsets.UTF_8);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/master/languages/de_DE.json", exchange -> respond(exchange, response, requests));
server.createContext("/master/languages/de_DE.toml", exchange -> respond(exchange, response, requests));
server.start();
return server;
}
@@ -150,9 +157,8 @@ public class IrisDownloadValidationTest {
"Iris",
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"),
"languages",
".json",
".toml",
"source.properties",
temporaryFolder.newFolder().toPath(),
resources
));
}
@@ -1,6 +1,7 @@
package art.arcane.iris.core.localization;
import art.arcane.iris.testsupport.ProjectPaths;
import art.arcane.volmlib.util.config.TomlCodec;
import art.arcane.volmlib.util.localization.LocalizationCandidate;
import art.arcane.volmlib.util.localization.LocalizationSnapshot;
import art.arcane.volmlib.util.localization.PluginLanguageEditor;
@@ -8,7 +9,9 @@ import art.arcane.volmlib.util.localization.PluginLanguageService;
import art.arcane.volmlib.util.localization.PluralSelector;
import art.arcane.volmlib.util.localization.RemoteLanguageCatalog;
import art.arcane.volmlib.util.localization.TextValue;
import art.arcane.volmlib.util.localization.TomlLanguageEditor;
import art.arcane.volmlib.util.localization.VolmitLocales;
import com.google.gson.JsonElement;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.After;
@@ -32,7 +35,6 @@ import java.util.logging.Logger;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -48,9 +50,9 @@ public class IrisLanguageEditorTest {
public void prepareEditor() throws Exception {
directory = temporaryFolder.newFolder();
assertTrue(IrisLanguage.reload(directory, "en_US"));
Path cache = IrisLanguage.remote(directory).cacheFile("fr_FR");
Files.createDirectories(cache.getParent());
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.json"), cache);
Path language = directory.toPath().resolve("languages/fr_FR.toml");
Files.createDirectories(language.getParent());
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.toml"), language);
PluginLanguageEditor.Options options = IrisLanguage.editorOptions();
LocalizationSnapshot english = LocalizationSnapshot.create(
LocalizationCandidate.english(IrisLanguage.catalog(), PluralSelector.oneOther()));
@@ -73,20 +75,22 @@ public class IrisLanguageEditorTest {
@Test
public void savesOneLocaleAndRefreshesItsPersonalSnapshotWithoutSelectingIt() throws Exception {
UUID player = UUID.randomUUID();
Path english = directory.toPath().resolve("languages/en_US.toml");
byte[] englishBefore = Files.readAllBytes(english);
languages.selectPlayer(player, "fr_FR").get(5, TimeUnit.SECONDS);
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
TextValue value = new TextValue("Langue \"{locale}\" rechargee.\nSuite.");
editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS);
Path file = directory.toPath().resolve("languages/overrides/fr_FR.json");
Path file = directory.toPath().resolve("languages/fr_FR.toml");
assertTrue(Files.isRegularFile(file));
assertEquals(value, IrisLanguage.editorOptions().loader().load("fr_FR").value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals(value, languages.snapshot(player).value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals("fr_FR", languages.playerLocale(player).orElseThrow());
assertEquals("en_US", languages.defaultLocale());
assertEquals("en_US", IrisLanguage.activeLocale());
assertFalse(Files.exists(directory.toPath().resolve("languages/overrides/en_US.json")));
assertArrayEquals(englishBefore, Files.readAllBytes(english));
}
@Test
@@ -95,7 +99,7 @@ public class IrisLanguageEditorTest {
editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), new TextValue("Langue {locale}")))
.get(5, TimeUnit.SECONDS);
Path file = directory.toPath().resolve("languages/overrides/fr_FR.json");
Path file = directory.toPath().resolve("languages/fr_FR.toml");
byte[] before = Files.readAllBytes(file);
PluginLanguageEditor.Document saved = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
@@ -106,8 +110,8 @@ public class IrisLanguageEditorTest {
}
@Test
public void incompleteDownloadedLocaleCanBeEditedWithoutBeingSelected() throws Exception {
byte[] complete = Files.readAllBytes(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.json"));
public void missingLocaleDownloadsAndCanBeEditedWithoutBeingSelected() throws Exception {
byte[] complete = Files.readAllBytes(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.toml"));
AtomicInteger requests = new AtomicInteger();
Field remoteField = IrisLanguage.class.getDeclaredField("remote");
remoteField.setAccessible(true);
@@ -117,19 +121,19 @@ public class IrisLanguageEditorTest {
"Iris",
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"),
"languages",
".json",
".toml",
"iris-language-source.properties",
directory.toPath().resolve("languages/downloaded"),
IrisLanguage.class.getClassLoader()
))) {
server.createContext(catalog.sourceUri("fr_FR").getPath(),
exchange -> respond(exchange, complete, requests));
server.start();
remoteField.set(null, catalog);
Path cache = catalog.cacheFile("fr_FR");
Files.writeString(cache, "{\"locale\":\"fr_FR\",\"messages\":{}}");
Path language = directory.toPath().resolve("languages/fr_FR.toml");
Files.delete(language);
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
assertArrayEquals(complete, Files.readAllBytes(language));
TextValue value = new TextValue("Langue {locale}");
PluginLanguageEditor.Document saved = editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS);
@@ -137,7 +141,7 @@ public class IrisLanguageEditorTest {
assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals("en_US", languages.defaultLocale());
assertEquals("en_US", IrisLanguage.activeLocale());
assertArrayEquals(complete, Files.readAllBytes(cache));
assertEquals(value, IrisLanguage.editorOptions().loader().load("fr_FR").value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals(1, requests.get());
} finally {
remoteField.set(null, previous);
@@ -145,6 +149,55 @@ public class IrisLanguageEditorTest {
}
}
@Test
public void savingOneMessagePreservesUnknownValuesAndLeadingComments() throws Exception {
Path file = directory.toPath().resolve("languages/fr_FR.toml");
String header = "# Operator customization\n";
String extension = "\n[future]\nvalue = \"Preserve this\"\nenabled = true\norder = [1, 2]\n";
String source = header + Files.readString(file) + extension;
Files.writeString(file, source);
JsonElement unknown = TomlCodec.toJsonElement(source).getAsJsonObject().get("future");
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
TextValue value = new TextValue("Langue {locale}");
editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS);
String saved = Files.readString(file);
assertTrue(saved.startsWith(header));
assertEquals(unknown, TomlCodec.toJsonElement(saved).getAsJsonObject().get("future"));
assertEquals(value, IrisLanguage.editorOptions().loader().load("fr_FR").value(IrisMessages.COMMAND_RELOAD_SUCCESS));
}
@Test
public void staleEditorSaveDoesNotReplaceANewerLocalEdit() throws Exception {
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
Path file = directory.toPath().resolve("languages/fr_FR.toml");
String external = TomlLanguageEditor.upsert(Files.readString(file), IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
new TextValue("Modification externe {locale}")).content();
Files.writeString(file, external);
assertThrows(ExecutionException.class, () -> editor.save(new PluginLanguageEditor.Edit("fr_FR",
IrisMessages.COMMAND_RELOAD_SUCCESS.id(), original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS),
new TextValue("Modification ancienne {locale}"))).get(5, TimeUnit.SECONDS));
assertEquals(external, Files.readString(file));
}
@Test
public void editingDefaultEnglishRefreshesServerMessagesWithoutSelectingAnotherLocale() throws Exception {
PluginLanguageEditor.Document original = editor.load("en_US").get(5, TimeUnit.SECONDS);
TextValue value = new TextValue("Custom Iris command");
editor.save(new PluginLanguageEditor.Edit("en_US", IrisMessages.COMMAND_UNKNOWN.id(),
original.snapshot().value(IrisMessages.COMMAND_UNKNOWN), value)).get(5, TimeUnit.SECONDS);
assertEquals("Custom Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals("en_US", IrisLanguage.activeLocale());
assertEquals("en_US", languages.defaultLocale());
assertEquals(value, IrisLanguage.editorOptions().loader().load("en_US").value(IrisMessages.COMMAND_UNKNOWN));
}
private static void respond(HttpExchange exchange, byte[] response, AtomicInteger requests) throws IOException {
requests.incrementAndGet();
try {
@@ -0,0 +1,123 @@
package art.arcane.iris.core.localization;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisLanguageGuideTest {
private static final String ENGLISH_GUIDE = """
# Iris — en_US
#
# === File editing ===
# plugins/Iris/languages/en_US.toml
# Edit this file directly. Local changes are preserved; missing or invalid entries use built-in English.
# /iris language server edit en_US
#
# === Prefix ===
# Prefixes belong to individual messages or the platform display.
#
# === Formatting ===
# Colors and styles: &0-&f, &k-&r.
# RGB colors: &#RRGGBB, &xRRGGBB, &x&R&R&G&G&B&B, [RRGGBB].
# Put a backslash before & or [ to display it literally.
# Keep the placeholders already present in each message.
#
# === Variables ===
# Keep variable names unchanged and use only the variables belonging to each message.
# {permission}
# {world}
""";
private static final String GERMAN_GUIDE = """
# Iris - de_DE
#
# === Datei bearbeiten ===
# languages/de_DE.toml
# Diese Datei kann direkt bearbeitet werden. Lokale Änderungen bleiben erhalten; fehlende oder ungültige Einträge verwenden das eingebaute Englisch.
# /iris language server edit de_DE
#
# === Präfix ===
# Präfixe stehen in einzelnen Nachrichten oder werden von der jeweiligen Server- oder Client-Oberfläche ergänzt.
#
# === Formatierung ===
# Farben: &0-&f, &k-&r, &#RRGGBB, &xRRGGBB, &x&R&R&G&G&B&B und [RRGGBB].
# Ein vorangestellter Rückwärtsschrägstrich zeigt & oder [ wörtlich an.
#
# === Variablen ===
# Variablennamen unverändert lassen und nur die Variablen der jeweiligen Nachricht verwenden.
# {permission} {world}
""";
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@Test
public void refreshesKnownEnglishProseAcrossLineEndingsAndCatalogSizes() throws Exception {
String note = "# Local operator note\r\n";
String body = "\r\n# Keep this explanation\r\n[iris.command]\r\nunknown = 'Custom answer'\r\n"
+ "[extra]\r\nenabled = true\r\n";
File file = temporary.newFile("en_US.toml");
Files.writeString(file.toPath(), note + ENGLISH_GUIDE.replace("\n", "\r\n") + body);
IrisLanguageGuide.refresh(file, "en_US");
assertArrayEquals((note + IrisLanguageGuide.englishHeader("en_US") + body).getBytes(StandardCharsets.UTF_8),
Files.readAllBytes(file.toPath()));
}
@Test
public void refreshesKnownGermanProsePreservingMessageBytes() throws Exception {
String body = "\n# Eigene Erklärung\n[iris.command]\nunknown = 'Eigene Nachricht'\n";
File file = temporary.newFile("de_DE.toml");
Files.writeString(file.toPath(), GERMAN_GUIDE + body);
IrisLanguageGuide.refresh(file, "de_DE");
assertArrayEquals((IrisLanguageGuide.header("de_DE") + body).getBytes(StandardCharsets.UTF_8),
Files.readAllBytes(file.toPath()));
}
@Test
public void leavesCustomCommentsInsideAnUnmarkedGuideUntouched() throws Exception {
String custom = ENGLISH_GUIDE.replace("# === Formatting ===", "# Operator's custom explanation\n# === Formatting ===")
+ "\n[iris.command]\nunknown = 'Custom answer'\n";
File file = temporary.newFile("en_US.toml");
Files.writeString(file.toPath(), custom);
FileTime originalTime = FileTime.fromMillis(123_456L);
Files.setLastModifiedTime(file.toPath(), originalTime);
IrisLanguageGuide.refresh(file, "en_US");
assertArrayEquals(custom.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(file.toPath()));
assertEquals(originalTime, Files.getLastModifiedTime(file.toPath()));
}
@Test
public void leavesChangedGuideProseAndUnknownUnmarkedGuidesUntouched() {
String changed = GERMAN_GUIDE.replace("# languages/de_DE.toml", "# My chosen file location")
+ "\n[iris.command]\nunknown = 'Custom answer'\n";
String unknown = "# Iris - custom\n# Custom instructions\n# {world}\n[iris.command]\nunknown = 'Custom answer'\n";
assertEquals(changed, IrisLanguageGuide.replaceHeader(changed, IrisLanguageGuide.englishHeader("de_DE"), "de_DE"));
assertEquals(unknown, IrisLanguageGuide.replaceHeader(unknown, IrisLanguageGuide.englishHeader("custom"), "custom"));
}
@Test
public void englishLocationIsRelativeToThePlatformDataFolder() {
String header = IrisLanguageGuide.englishHeader("en_US");
assertTrue(header.contains("# languages/en_US.toml\n"));
assertFalse(header.contains("# plugins/Iris/languages/en_US.toml"));
}
}
@@ -5,6 +5,7 @@ import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
@@ -13,16 +14,17 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
public class IrisLanguageHotloadSnapshotTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void capturesMissingOverrideAsStableTombstone() throws Exception {
File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json");
public void capturesMissingLanguageAsStableTombstone() throws Exception {
File language = new File(temporaryFolder.getRoot(), "languages/en_US.toml");
LocaleHotloadSnapshot snapshot = IrisLanguage.captureHotloadSnapshot(override, "en_US");
LocaleHotloadSnapshot snapshot = IrisLanguage.captureHotloadSnapshot(language, "en_US");
assertTrue(snapshot.missing());
assertEquals("missing", snapshot.sha256());
@@ -30,18 +32,18 @@ public class IrisLanguageHotloadSnapshotTest {
@Test
public void detectsSameMetadataContentReplacementBySha256() throws Exception {
File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json");
Files.createDirectories(override.toPath().getParent());
String firstContent = "{\"messages\":{\"a\":\"1\"}}";
String secondContent = "{\"messages\":{\"a\":\"2\"}}";
Files.writeString(override.toPath(), firstContent, StandardCharsets.UTF_8);
File language = new File(temporaryFolder.getRoot(), "languages/en_US.toml");
Files.createDirectories(language.toPath().getParent());
String firstContent = "a = \"1\"\n";
String secondContent = "a = \"2\"\n";
Files.writeString(language.toPath(), firstContent, StandardCharsets.UTF_8);
FileTime fixedTime = FileTime.fromMillis(10_000L);
Files.setLastModifiedTime(override.toPath(), fixedTime);
LocaleHotloadSnapshot first = IrisLanguage.captureHotloadSnapshot(override, "en_US");
Files.setLastModifiedTime(language.toPath(), fixedTime);
LocaleHotloadSnapshot first = IrisLanguage.captureHotloadSnapshot(language, "en_US");
Files.writeString(override.toPath(), secondContent, StandardCharsets.UTF_8);
Files.setLastModifiedTime(override.toPath(), fixedTime);
LocaleHotloadSnapshot second = IrisLanguage.captureHotloadSnapshot(override, "en_US");
Files.writeString(language.toPath(), secondContent, StandardCharsets.UTF_8);
Files.setLastModifiedTime(language.toPath(), fixedTime);
LocaleHotloadSnapshot second = IrisLanguage.captureHotloadSnapshot(language, "en_US");
assertFalse(first.missing());
assertFalse(second.missing());
@@ -52,4 +54,27 @@ public class IrisLanguageHotloadSnapshotTest {
assertNotEquals(first.sha256(), second.sha256());
assertNotEquals(first, second);
}
@Test
public void snapshotsRejectFilesLargerThanTwoMebibytes() throws Exception {
File language = temporaryFolder.newFile("en_US.toml");
Files.write(language.toPath(), new byte[2 * 1024 * 1024 + 1]);
assertThrows(IllegalArgumentException.class, () -> IrisLanguage.captureHotloadSnapshot(language, "en_US"));
}
@Test
public void snapshotsRejectInvalidUtf8() throws Exception {
File language = temporaryFolder.newFile("en_US.toml");
Files.write(language.toPath(), new byte[]{(byte) 0xC3, (byte) 0x28});
assertThrows(IOException.class, () -> IrisLanguage.captureHotloadSnapshot(language, "en_US"));
}
@Test
public void snapshotsRejectDirectoriesAtTheLanguagePath() throws Exception {
File language = temporaryFolder.newFolder("en_US.toml");
assertThrows(IllegalArgumentException.class, () -> IrisLanguage.captureHotloadSnapshot(language, "en_US"));
}
}
@@ -12,7 +12,6 @@ import art.arcane.volmlib.util.localization.MessageValue;
import art.arcane.volmlib.util.localization.PluralValue;
import art.arcane.volmlib.util.localization.TextValue;
import art.arcane.volmlib.util.localization.VolmitLocales;
import com.google.gson.JsonArray;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
@@ -25,6 +24,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -116,50 +116,52 @@ public class IrisLanguageTest {
dataFolder = temporaryFolder.newFolder();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
for (String locale : VolmitLocales.nonEnglish()) {
Path target = IrisLanguage.remote(dataFolder).cacheFile(locale);
Path target = dataFolder.toPath().resolve("languages/" + locale + ".toml");
Files.createDirectories(target.getParent());
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".json"), target, StandardCopyOption.REPLACE_EXISTING);
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".toml"), target, StandardCopyOption.REPLACE_EXISTING);
}
}
@After
public void tearDown() {
IrisLanguage.shutdown();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
}
private LocaleOverlay loadSourceOverlay(String locale) throws Exception {
Path source = ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".json");
Path source = ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".toml");
return IrisLanguage.parseDownloadedOverlay(source.toString(), locale, Files.readString(source));
}
@Test
public void downloadedCatalogSelectsBukkitKeysWithoutAcceptingUnknownOverrides() {
public void languageCatalogSelectsKnownKeysWithoutDiscardingValidTranslations() {
String raw = """
{"locale":"de_DE","messages":{
"iris.command.unknown":"Unbekannter Iris-Befehl",
"iris.modded.help.entry.command.version":"Version anzeigen"
}}
"iris.command.unknown" = "Unbekannter Iris-Befehl"
"iris.modded.help.entry.command.version" = "Version anzeigen"
""";
LocaleOverlay downloaded = IrisLanguage.parseDownloadedOverlay("download", "de_DE", raw);
assertEquals(Set.of(IrisMessages.COMMAND_UNKNOWN.id()), downloaded.values().keySet());
assertTrue(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(downloaded)).errors().isEmpty());
LocaleOverlay override = IrisLanguage.parseOverlay("override", "de_DE", raw);
assertFalse(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(override)).errors().isEmpty());
LocaleOverlay local = IrisLanguage.parseOverlay("language", "de_DE", raw);
assertEquals(downloaded.values(), local.values());
assertTrue(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(local)).errors().isEmpty());
assertEquals("A pregeneration task is already running. Stop it first with /iris pregen stop.",
IrisLanguage.plain(IrisMessages.PREGEN_ALREADY_RUNNING));
}
@Test
public void downloadedCatalogStillRejectsInvalidActivePlaceholders() {
public void invalidPlaceholdersAreExcludedWithoutDiscardingValidMessages() {
LocaleOverlay downloaded = IrisLanguage.parseDownloadedOverlay("download", "de_DE", """
{"locale":"de_DE","messages":{"iris.command.permission_denied":"Keine Erlaubnis"}}
"iris.command.permission_denied" = "Keine Erlaubnis"
"iris.command.unknown" = "Unbekannter Iris-Befehl"
""");
assertFalse(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(downloaded)).errors().isEmpty());
assertEquals(Set.of(IrisMessages.COMMAND_UNKNOWN.id()), downloaded.values().keySet());
assertTrue(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(downloaded)).errors().isEmpty());
}
@Test
public void missingDownloadUsesCodeOwnedEnglish() throws Exception {
Files.delete(IrisLanguage.remote(dataFolder).cacheFile("de_DE"));
public void missingLanguageUsesBuiltInEnglish() throws Exception {
Files.delete(dataFolder.toPath().resolve("languages/de_DE.toml"));
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@@ -203,7 +205,44 @@ public class IrisLanguageTest {
}
@Test
public void usesCodeOwnedEnglishWithoutLocaleFile() {
public void hotloadRefreshesDefaultAndPersonalLanguagesWithoutChangingSelections() throws Exception {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
UUID frenchPlayer = UUID.randomUUID();
IrisLanguage.start();
try {
IrisLanguage.selections().selectDefault("de_DE").get(5, TimeUnit.SECONDS);
IrisLanguage.selections().selectPlayer(frenchPlayer, "fr_FR").get(5, TimeUnit.SECONDS);
String german = "\"iris.command.unknown\" = \"Deutsch aktualisiert\"\n";
File germanFile = writeLanguage("de_DE", german);
assertTrue(IrisLanguage.reloadOverride(germanFile, german));
assertEquals("Deutsch aktualisiert", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
String french = "\"iris.command.unknown\" = \"Francais actualise\"\n";
File frenchFile = writeLanguage("fr_FR", french);
assertTrue(IrisLanguage.reloadOverride(frenchFile, french));
assertEquals("Francais actualise", LanguageAudience.call(frenchPlayer,
() -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));
assertEquals("Deutsch aktualisiert", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals("fr_FR", IrisLanguage.selections().playerLocale(frenchPlayer).orElseThrow());
assertEquals("de_DE", IrisLanguage.selections().defaultLocale());
assertEquals("de_DE", IrisSettings.settings.getGeneral().getLanguage());
} finally {
IrisLanguage.shutdown();
IrisSettings.settings = previous;
}
}
@Test
public void generatedEnglishLanguageRoundTripsTheEntireCatalog() throws Exception {
Path english = dataFolder.toPath().resolve("languages/en_US.toml");
assertTrue(Files.isRegularFile(english));
LocaleOverlay overlay = IrisLanguage.parseDownloadedOverlay(english.toString(), "en_US", Files.readString(english));
assertEquals(IrisLanguage.catalog().ids(), overlay.values().keySet());
for (MessageKey key : IrisLanguage.catalog().keys()) {
assertEquals(key.id(), key.englishValue(), overlay.value(key.id()));
}
String rendered = IrisLanguage.plain(
IrisMessages.COMMAND_PERMISSION_DENIED,
MessageArgument.untrusted("permission", "iris.all")
@@ -214,14 +253,128 @@ public class IrisLanguageTest {
}
@Test
public void appliesExternalOverlayAndFallsBackPerKey() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "Fehlende Berechtigung: {permission}"
}
}
public void guideDescribesEveryRequiredAndOptionalCatalogPlaceholder() {
Map<String, String> descriptions = IrisLanguageGuide.variableDescriptions();
for (MessageKey key : IrisLanguage.catalog().keys()) {
for (String placeholder : key.placeholders()) {
assertPlaceholderDescription(key.id(), placeholder, descriptions.get(placeholder));
}
for (String placeholder : key.optionalPlaceholders()) {
assertPlaceholderDescription(key.id(), placeholder, descriptions.get(placeholder));
}
}
for (Map.Entry<String, String> entry : descriptions.entrySet()) {
assertPlaceholderDescription("language guide", entry.getKey(), entry.getValue());
}
}
@Test
public void generatedEnglishGuideIncludesMeaningsAndPrefixExamples() throws Exception {
String header = Files.readString(dataFolder.toPath().resolve("languages/en_US.toml"));
assertTrue(header.startsWith("# === Iris language guide ===\n"));
for (Map.Entry<String, String> entry : IrisLanguageGuide.variableDescriptions().entrySet()) {
assertTrue(entry.getKey(), header.contains("# {" + entry.getKey() + "} " + entry.getValue()));
}
assertTrue(header.contains("set_debug = \"&6[Worlds]&r &aSet debug to: {to}\""));
assertTrue(header.contains("&6 gold"));
assertTrue(header.contains("&l bold"));
assertTrue(header.contains("&r reset"));
assertTrue(header.contains("{PC}, {SC} and {TC} are not Iris theme variables"));
}
@Test
public void everyTranslatedGuideDescribesItsVariablesAndMatchesThePackagedGuide() throws Exception {
Pattern definition = Pattern.compile("(?m)^# \\{([A-Za-z][A-Za-z0-9_]*)} (\\S[^\\r\\n]*)$");
for (String locale : VolmitLocales.nonEnglish()) {
String source = Files.readString(ProjectPaths.moduleFile("src/main/resources/languages/" + locale + ".toml"));
int bodyStart = source.indexOf("\n[") + 1;
assertTrue(locale, bodyStart > 0);
String header = source.substring(0, bodyStart);
String body = source.substring(bodyStart);
Map<String, String> descriptions = new LinkedHashMap<>();
Matcher defined = definition.matcher(header);
while (defined.find()) {
assertFalse(locale + ": duplicate " + defined.group(1), descriptions.containsKey(defined.group(1)));
descriptions.put(defined.group(1), defined.group(2));
}
Matcher used = PLACEHOLDER.matcher(body);
while (used.find()) {
String placeholder = used.group().substring(1, used.group().length() - 1);
assertPlaceholderDescription(locale, placeholder, descriptions.get(placeholder));
}
assertEquals(locale, IrisLanguageGuide.variableDescriptions().keySet(), descriptions.keySet());
assertEquals(locale, header.stripTrailing(), IrisLanguageGuide.header(locale).stripTrailing());
assertTrue(locale, header.contains("# === End Iris language guide ==="));
}
}
@Test
public void refreshingAnExistingGuidePreservesCustomMessagesAndOtherComments() throws Exception {
String note = "# Operator's own note\r\n";
String oldGuide = "# === Iris language guide ===\r\n# Iris — en_US\r\n"
+ "# === Variables ===\r\n# {permission} \r\n# {world} \r\n"
+ "# === End Iris language guide ===\r\n";
String body = "\r\n# Keep this explanation\r\n\"iris.command.unknown\" = 'My edited message'\r\n"
+ "[future]\r\nenabled = true\r\nitems = [1, 2]\r\n";
File english = writeLanguage("en_US", note + oldGuide + body);
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
String refreshed = Files.readString(english.toPath());
assertEquals(note + IrisLanguageGuide.englishHeader("en_US") + body, refreshed);
assertEquals("My edited message", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
FileTime unchangedTime = FileTime.fromMillis(123_456L);
Files.setLastModifiedTime(english.toPath(), unchangedTime);
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
assertEquals(refreshed, Files.readString(english.toPath()));
assertEquals(unchangedTime, Files.getLastModifiedTime(english.toPath()));
}
@Test
public void refreshingATranslatedGuidePreservesItsCustomMessages() throws Exception {
String oldGuide = "# === Iris language guide ===\n# Iris - de_DE\n"
+ "# === Variablen ===\n# {permission} {world}\n# === End Iris language guide ===\n";
String body = "\n[iris.command]\nunknown = 'Eigener Text'\n";
File german = writeLanguage("de_DE", oldGuide + body);
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals(IrisLanguageGuide.header("de_DE") + body, Files.readString(german.toPath()));
assertEquals("Eigener Text", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@Test
public void documentedPrefixExampleUsesItsExistingDebugVariable() throws Exception {
writeLanguage("en_US", "[iris.bukkit.commandiris]\nset_debug = \"&6[Worlds]&r &aSet debug to: {to}\"\n");
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
MessageKey debug = IrisLanguage.catalog().require("iris.bukkit.commandiris.set_debug");
assertEquals("\u00a76[Worlds]\u00a7r \u00a7aSet debug to: true",
IrisLanguage.text(debug, MessageArgument.trusted("to", true)));
assertEquals("[Worlds] Set debug to: false",
IrisLanguage.plain(debug, MessageArgument.trusted("to", false)));
}
@Test
public void unreadableEnglishGuideDoesNotBlockSelectingAnotherLanguage() throws Exception {
Files.write(dataFolder.toPath().resolve("languages/en_US.toml"), new byte[]{(byte) 0xc3, 0x28});
UUID player = UUID.randomUUID();
IrisLanguage.start();
try {
IrisLanguage.selections().selectPlayer(player, "de_DE").get(5, TimeUnit.SECONDS);
assertEquals("de_DE", IrisLanguage.selections().playerLocale(player).orElseThrow());
assertFalse("Unknown Iris command".equals(LanguageAudience.call(player,
() -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN))));
} finally {
IrisLanguage.shutdown();
}
}
@Test
public void appliesLanguageFileAndFallsBackPerKey() throws Exception {
writeLanguage("de_DE", """
"iris.command.permission_denied" = "Fehlende Berechtigung: {permission}"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
@@ -232,7 +385,7 @@ public class IrisLanguageTest {
MessageArgument.untrusted("permission", "iris.all")
)
);
assertFalse("Unknown Iris command".equals(IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@Test
@@ -265,11 +418,11 @@ public class IrisLanguageTest {
@Test
public void sourceLocalesExactlyMatchNonEnglishManifest() throws Exception {
Set<String> expected = VolmitLocales.nonEnglish().stream()
.map(locale -> locale + ".json")
.map(locale -> locale + ".toml")
.collect(Collectors.toUnmodifiableSet());
assertEquals(expected, resourceFiles("languages"));
assertFalse(expected.contains(VolmitLocales.ENGLISH + ".json"));
assertFalse(expected.contains(VolmitLocales.ENGLISH + ".toml"));
}
@Test
@@ -289,42 +442,61 @@ public class IrisLanguageTest {
}
@Test
public void downloadedLocaleLoadsWithoutUserOverride() {
public void installedLocaleLoadsDirectly() {
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("de_DE", IrisLanguage.activeLocale());
assertFalse("Unknown Iris command".equals(IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));
}
@Test
public void rejectsUnknownKeysAndPlaceholderShapeWhileRetainingLastGood() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "Erlaubnis {permission} fehlt"
}
}
public void invalidEntriesUseEnglishWhileValidAndUnknownFileContentIsPreserved() throws Exception {
writeLanguage("de_DE", """
"iris.command.permission_denied" = "Erlaubnis {permission} fehlt"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "Erlaubnis fehlt",
"iris.command.not_real": "Unbekannt"
}
}
""");
String raw = """
"iris.command.permission_denied" = "Erlaubnis fehlt"
"iris.command.unknown" = "Unbekannter Iris-Befehl"
"iris.command.not_real" = "Unbekannt"
""";
File language = writeLanguage("de_DE", raw);
assertFalse(IrisLanguage.reload(dataFolder, "de_DE"));
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals(
"Erlaubnis iris.all fehlt",
"You lack the permission 'iris.all'",
IrisLanguage.plain(
IrisMessages.COMMAND_PERMISSION_DENIED,
MessageArgument.untrusted("permission", "iris.all")
)
);
assertEquals("Unbekannter Iris-Befehl", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals(raw, Files.readString(language.toPath()));
}
@Test
public void unreadableTomlUsesEnglishWithoutReplacingTheFile() throws Exception {
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
String raw = "[unterminated";
File language = writeLanguage("de_DE", raw);
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals(raw, Files.readString(language.toPath()));
}
@Test
public void englishEditsSurviveReloadAndMissingEnglishIsCreatedForAnotherLocale() throws Exception {
String edited = "\"iris.command.unknown\" = \"My Iris command\"\n";
File english = writeLanguage("en_US", edited);
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
assertEquals("My Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals(edited, Files.readString(english.toPath()));
Files.delete(english.toPath());
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertTrue(Files.isRegularFile(english.toPath()));
assertEquals("de_DE", IrisLanguage.activeLocale());
}
@Test
@@ -349,13 +521,8 @@ public class IrisLanguageTest {
@Test
public void untrustedArgumentsCannotInjectLegacyOrMiniMessageFormatting() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "&aWert: {permission}"
}
}
writeLanguage("de_DE", """
"iris.command.permission_denied" = "&aWert: {permission}"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
@@ -385,28 +552,20 @@ public class IrisLanguageTest {
}
@Test
public void lookupsUseImmutableSnapshotWithoutReadingTheOverrideAgain() throws Exception {
File override = writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.unknown": "Unbekannter Iris-Befehl"
}
}
public void lookupsUseImmutableSnapshotWithoutReadingTheLanguageFileAgain() throws Exception {
File language = writeLanguage("de_DE", """
"iris.command.unknown" = "Unbekannter Iris-Befehl"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
Files.delete(override.toPath());
Files.delete(language.toPath());
assertEquals("Unbekannter Iris-Befehl", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@Test
public void appliesLineAndPluralOverlayShapes() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [
writeLanguage("de_DE", """
"iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning" = [
"Sicherung erstellen.",
"Vorhandene Chunks bleiben erhalten.",
"Neue Chunks verwenden das neue Paket.",
@@ -414,13 +573,10 @@ public class IrisLanguageTest {
"Den vollständigen Verlauf sichern.",
"Neustart bestätigen.",
"Welt {world}, Paket {pack}"
],
"iris.bukkit.runtime.commandpack.more_warning_s": {
"one": "Noch {count} Warnung.",
"other": "Noch {count} Warnungen."
}
}
}
]
["iris.bukkit.runtime.commandpack.more_warning_s"]
one = "Noch {count} Warnung."
other = "Noch {count} Warnungen."
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
@@ -454,13 +610,21 @@ public class IrisLanguageTest {
);
}
private File writeOverride(String locale, String json) throws Exception {
File file = new File(dataFolder, "languages/overrides/" + locale + ".json");
private File writeLanguage(String locale, String toml) throws Exception {
File file = new File(dataFolder, "languages/" + locale + ".toml");
Files.createDirectories(file.toPath().getParent());
Files.writeString(file.toPath(), json, StandardCharsets.UTF_8);
Files.writeString(file.toPath(), toml, StandardCharsets.UTF_8);
return file;
}
private void assertPlaceholderDescription(String key, String placeholder, String description) {
String context = key + " placeholder {" + placeholder + "}";
assertNotNull(context + " needs a definition", description);
assertFalse(context + " definition must not be blank", description.isBlank());
assertFalse(context + " definition must explain the variable", placeholder.equalsIgnoreCase(description.strip()));
assertFalse(context + " definition must explain the variable", ("{" + placeholder + "}").equals(description.strip()));
}
private void assertValueIntegrity(String locale, String key, MessageValue english, MessageValue translated) {
if (english instanceof TextValue englishText && translated instanceof TextValue translatedText) {
assertTemplateIntegrity(locale, key, englishText.template(), translatedText.template());