mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 13:53:01 +02:00
Speak the operator's language
The interface is German or English, chosen per operator profile on the GUI tab, in force at once in every open window, and remembered across restarts. 376 texts in both languages. The texts are ordinary .properties files that nobody has to compile: a contributor copies the English one, translates the values, and opens a pull request. A third module -- build tooling that never ships -- turns them into Kotlin before anything compiles, which is what makes a mistyped key and a wrong argument count compile errors rather than things an operator discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it exists to prevent. Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to English, so a partial contributed language can be submitted at all; an orphan key, a placeholder the translation drops, a duplicate key or an unreadable file fail the build with the generator's own message naming the file. The argument count always comes from the English base. German is held complete by a test because it ships; a contributed language is not. What is deliberately not translated is the harder half. Protocol frames and chat commands would break the connection or go unread; the beacon texts, auto answers, snippets and shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273 println calls in core are diagnostics, and a German stack trace is worse for whoever reads the bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three tests guard the boundary by measuring behaviour rather than text: the protocol frames built under three locales, every preference default compared by reflection, and no translation value being a protocol command. The reflective one is the only one that covers what nobody thought of. One insight turned up three times. Anything built outside a composition freezes the language it was built in: the window title, the settings tab strip and every column header are all constructed once at startup -- the columns from Java -- so each now holds a function read when it is drawn. That also fixed something older and unrelated to language: the main window's title was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried the live connection line. It carries it again. A column's id is never translated. It is what the remembered widths and order are keyed on.
This commit is contained in:
85 files changed
+3552
-508
No files matched your search
@@ -399,6 +399,13 @@ public class ChatPreferences {
|
||||
/** The same for the evening design. Two designs, two independent sets. */
|
||||
private String guiOptions_paletteOverridesEvening = "";
|
||||
|
||||
/**
|
||||
* The operator's interface language: empty for the system language, otherwise a language
|
||||
* code such as "de". Per profile, like every other GUI preference -- at a multi operator
|
||||
* station two operators may want two languages.
|
||||
*/
|
||||
private String guiOptions_language = "";
|
||||
|
||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||
private boolean GUIstationMapPathAnalysisVisible = true;
|
||||
@@ -828,6 +835,25 @@ public class ChatPreferences {
|
||||
guiOptions_paletteOverridesEvening == null ? "" : guiOptions_paletteOverridesEvening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the operator's interface language.
|
||||
*
|
||||
* @return a language code, or empty for the system language
|
||||
*/
|
||||
public String getGuiOptions_language() {
|
||||
return guiOptions_language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the operator's interface language.
|
||||
*
|
||||
* @param guiOptions_language a language code, or empty for the system language; null
|
||||
* becomes empty
|
||||
*/
|
||||
public void setGuiOptions_language(String guiOptions_language) {
|
||||
this.guiOptions_language = guiOptions_language == null ? "" : guiOptions_language;
|
||||
}
|
||||
|
||||
public boolean isGuiOptions_defaultFilterPmToOther() {
|
||||
return guiOptions_defaultFilterPmToOther;
|
||||
}
|
||||
@@ -2149,6 +2175,10 @@ public class ChatPreferences {
|
||||
guiOptions_paletteOverridesEvening.setTextContent(this.getGuiOptions_paletteOverridesEvening());
|
||||
guiSaveableOptions.appendChild(guiOptions_paletteOverridesEvening);
|
||||
|
||||
Element guiOptions_language = doc.createElement("guiOptions_language");
|
||||
guiOptions_language.setTextContent(this.getGuiOptions_language());
|
||||
guiSaveableOptions.appendChild(guiOptions_language);
|
||||
|
||||
Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive");
|
||||
guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + "");
|
||||
guiSaveableOptions.appendChild(guiOptions_darkModeActive);
|
||||
@@ -3226,6 +3256,7 @@ public class ChatPreferences {
|
||||
// Added in release 1.50: the operator's own colours, one string per design.
|
||||
this.setGuiOptions_paletteOverridesDay(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesDay(), "guiOptions_paletteOverridesDay"));
|
||||
this.setGuiOptions_paletteOverridesEvening(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesEvening(), "guiOptions_paletteOverridesEvening"));
|
||||
this.setGuiOptions_language(getText(guiSaveableOptionsEl, this.getGuiOptions_language(), "guiOptions_language"));
|
||||
|
||||
// Added in later versions: dark mode flags
|
||||
this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive");
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* The frames on the wire do not change with the interface language.
|
||||
*
|
||||
* A translated frame breaks the connection, and nothing about the user interface would show
|
||||
* it. This checks the output rather than the translation files, because the output is what the
|
||||
* server reads -- and because a boundary that is only documented is a boundary that holds
|
||||
* until the next person pulls a string into a bundle in good faith.
|
||||
*
|
||||
* Turkish is in the list deliberately: it is the locale under which "I".toLowerCase() is not
|
||||
* "i", the classic trap for any protocol code that case-folds without Locale.ROOT.
|
||||
*/
|
||||
class On4KstProtocolLocaleTest {
|
||||
|
||||
private static final Locale[] LOCALES = {
|
||||
Locale.GERMANY,
|
||||
Locale.UK,
|
||||
Locale.forLanguageTag("tr"),
|
||||
};
|
||||
|
||||
private <T> T underLocale(final Locale locale, final Supplier<T> body) {
|
||||
Locale previous = Locale.getDefault();
|
||||
try {
|
||||
Locale.setDefault(locale);
|
||||
return body.get();
|
||||
} finally {
|
||||
Locale.setDefault(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertLocaleIndependent(final String what, final Supplier<String> frame) {
|
||||
String reference = underLocale(Locale.UK, frame);
|
||||
|
||||
for (Locale locale : LOCALES) {
|
||||
assertEquals(reference, underLocale(locale, frame), what + " under " + locale);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theLoginFrameIsLocaleIndependent() {
|
||||
assertLocaleIndependent(
|
||||
"login",
|
||||
() -> On4KstProtocol.login("DN9APW", "secret", 1, "KST4Contest", 0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSettingsDoneFrameIsLocaleIndependent() {
|
||||
assertLocaleIndependent("settingsDone", () -> On4KstProtocol.settingsDone(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theAddChatFrameIsLocaleIndependent() {
|
||||
assertLocaleIndependent("addChat", () -> On4KstProtocol.addChat(1, 0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theLivenessFramesAreLocaleIndependent() {
|
||||
assertLocaleIndependent("clientLivenessProbe", On4KstProtocol::clientLivenessProbe);
|
||||
assertLocaleIndependent(
|
||||
"serverLivenessProbeResponse",
|
||||
On4KstProtocol::serverLivenessProbeResponse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFramesReallyAreTheOpcodesThisTestClaimsToGuard() {
|
||||
/*
|
||||
* Without this, the four tests above would still pass if the frames became empty
|
||||
* strings: identical under every locale, and identically useless.
|
||||
*/
|
||||
assertEquals(
|
||||
"LOGINC|",
|
||||
On4KstProtocol.login("DN9APW", "secret", 1, "KST4Contest", 0L).substring(0, 7));
|
||||
assertEquals("SDONE|", On4KstProtocol.settingsDone(1).substring(0, 6));
|
||||
assertEquals("ACHAT|", On4KstProtocol.addChat(1, 0L).substring(0, 6));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** The interface language is stored per profile, like every other GUI preference. */
|
||||
class ChatPreferencesLanguageTest {
|
||||
|
||||
@Test
|
||||
void theDefaultIsTheSystemLanguage() {
|
||||
// Empty means "follow the system", which is what the spec asks for on a first start.
|
||||
assertEquals("", new ChatPreferences().getGuiOptions_language());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aChosenLanguageIsKept() {
|
||||
ChatPreferences prefs = new ChatPreferences();
|
||||
|
||||
prefs.setGuiOptions_language("de");
|
||||
|
||||
assertEquals("de", prefs.getGuiOptions_language());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNullSetterValueBecomesEmpty() {
|
||||
ChatPreferences prefs = new ChatPreferences();
|
||||
|
||||
prefs.setGuiOptions_language(null);
|
||||
|
||||
assertEquals("", prefs.getGuiOptions_language());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* No default that goes to another radio amateur changes with the interface language.
|
||||
*
|
||||
* Over every String field by reflection rather than over a maintained list. The beacon texts
|
||||
* and the auto answers are the ones anybody would think of, and they are also in every
|
||||
* operator's stored XML, so translating one would alter existing profiles. The fields nobody
|
||||
* thinks of are the reason this is reflective -- including the ones added after this test was
|
||||
* written.
|
||||
*/
|
||||
class ChatPreferencesLocaleTest {
|
||||
|
||||
private Map<String, String> stringFieldsUnder(final Locale locale) throws Exception {
|
||||
Locale previous = Locale.getDefault();
|
||||
try {
|
||||
Locale.setDefault(locale);
|
||||
ChatPreferences prefs = new ChatPreferences();
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
|
||||
for (Field field : ChatPreferences.class.getDeclaredFields()) {
|
||||
if (field.getType() != String.class || Modifier.isStatic(field.getModifiers())) {
|
||||
continue;
|
||||
}
|
||||
field.setAccessible(true);
|
||||
values.put(field.getName(), (String) field.get(prefs));
|
||||
}
|
||||
|
||||
return values;
|
||||
} finally {
|
||||
Locale.setDefault(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyStringDefaultIsTheSameUnderGermanAndEnglish() throws Exception {
|
||||
assertEquals(stringFieldsUnder(Locale.UK), stringFieldsUnder(Locale.GERMANY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyStringDefaultIsTheSameUnderTurkishToo() throws Exception {
|
||||
// The locale where case folding differs; a default built with toUpperCase() would show.
|
||||
assertEquals(
|
||||
stringFieldsUnder(Locale.UK),
|
||||
stringFieldsUnder(Locale.forLanguageTag("tr")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void thereAreEnoughStringFieldsForThisTestToMeanSomething() {
|
||||
/*
|
||||
* Without this, the two tests above would pass just as well if reflection found no
|
||||
* fields at all -- two empty maps are equal.
|
||||
*/
|
||||
long count = java.util.Arrays.stream(ChatPreferences.class.getDeclaredFields())
|
||||
.filter(field -> field.getType() == String.class)
|
||||
.filter(field -> !Modifier.isStatic(field.getModifiers()))
|
||||
.count();
|
||||
|
||||
assertTrue(count > 20, "only " + count + " String fields were found by reflection");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTextsThatGoToOtherRadioAmateursAreStillEnglish() throws Exception {
|
||||
/*
|
||||
* Named as well as covered reflectively: these are in the stored XML of every
|
||||
* operator, so a change to them is a change to existing profiles. If a later stage
|
||||
* moves them, this test is the place that says it was deliberate.
|
||||
*/
|
||||
Map<String, String> values = stringFieldsUnder(Locale.GERMANY);
|
||||
|
||||
assertEquals("Hi, pse call us", values.get("bcn_beaconTextMainCat"));
|
||||
assertEquals("Hi, pse call us", values.get("bcn_beaconTextSecondCat"));
|
||||
assertTrue(
|
||||
values.get("messageHandling_autoAnswerTextMainCat").startsWith("Hi, sry I am not qrv"),
|
||||
values.get("messageHandling_autoAnswerTextMainCat"));
|
||||
assertTrue(
|
||||
values.get("messageHandling_autoAnswerTextSecondCat").startsWith("Hi, sry I am not qrv"),
|
||||
values.get("messageHandling_autoAnswerTextSecondCat"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user