diff --git a/ip-intelligence.translation/pom.xml b/ip-intelligence.translation/pom.xml
new file mode 100644
index 0000000..5160629
--- /dev/null
+++ b/ip-intelligence.translation/pom.xml
@@ -0,0 +1,99 @@
+
+
+
+ pipeline.ip-intelligence
+ com.51degrees
+ 4.4.21-SNAPSHOT
+
+ 4.0.0
+
+ ip-intelligence.translation
+ 51Degrees :: IP Intelligence :: Translation
+ Translation engines that turn the weighted ISO country codes
+ from 51Degrees IP Intelligence into localized, ordered country name and
+ code lists.
+ https://51degrees.com?utm_source=maven&utm_medium=package&utm_campaign=ip-intelligence-java&utm_content=ip-intelligence.translation-pom.xml&utm_term=url
+
+
+
+ ${project.groupId}
+ ip-intelligence.shared
+ ${project.version}
+
+
+ ${project.groupId}
+ pipeline.translation
+ ${pipeline.version}
+
+
+
+ ${project.groupId}
+ ip-intelligence.shared
+ ${project.version}
+ test-jar
+ test
+
+
+
+ ${project.groupId}
+ ip-intelligence.engine.on-premise
+ ${project.version}
+ test
+
+
+
+ junit
+ junit
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+ 3.3.1
+
+
+ copy-translation-data
+ generate-resources
+
+ copy-resources
+
+
+ ${project.build.outputDirectory}/fiftyone/ipintelligence/translation
+
+
+ ${project.basedir}/../ip-intelligence.engine.on-premise/src/main/cxx/ip-intelligence-cxx/ip-intelligence-data/Translations
+
+ countrycodes.en_GB.yml
+
+
+
+ ${project.basedir}/../ip-intelligence.engine.on-premise/src/main/cxx/ip-intelligence-cxx/ip-intelligence-data/Translations/OSM
+
+ countries.*.yml
+
+
+
+
+
+
+
+
+
+
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/Constants.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/Constants.java
new file mode 100644
index 0000000..8ad29b3
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/Constants.java
@@ -0,0 +1,51 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation;
+
+/**
+ * Constants used by the country translation flow elements.
+ */
+public class Constants {
+
+ private Constants() {
+ }
+
+ /**
+ * Element data key used by
+ * {@link fiftyone.ipintelligence.translation.flowelements.CountryCodeTranslationEngine}.
+ */
+ public static final String COUNTRY_NAMES_KEY = "countrynames";
+
+ /**
+ * Element data key used by
+ * {@link fiftyone.ipintelligence.translation.flowelements.CountriesTranslationEngine}.
+ */
+ public static final String COUNTRY_NAMES_TRANSLATED_KEY =
+ "countrynamestranslated";
+
+ /**
+ * Element data key of the IP Intelligence engine, the source of the
+ * weighted country codes.
+ */
+ public static final String IP_INTELLIGENCE_KEY = "ip";
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/Resources.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/Resources.java
new file mode 100644
index 0000000..487ed14
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/Resources.java
@@ -0,0 +1,119 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Reads the translation YAML files that are wired in from the IP Intelligence
+ * data submodule and copied onto the classpath under this package by the build
+ * (see the module pom).
+ *
+ * The set of country name locale files is known and stable, so the files are
+ * loaded by name rather than by scanning the classpath, which keeps loading
+ * reliable whether running from a directory or a packaged jar. A file that is
+ * not present is skipped.
+ */
+public class Resources {
+
+ /**
+ * The single country-code to English-name file. This is also the list of
+ * all known countries.
+ */
+ private static final String COUNTRY_CODE_FILE = "countrycodes.en_GB.yml";
+
+ /**
+ * The country English-name to localized-name files, one per shipped
+ * locale.
+ */
+ private static final String[] COUNTRY_FILES = new String[]{
+ "countries.de_DE.yml",
+ "countries.es_ES.yml",
+ "countries.fr_FR.yml",
+ "countries.it_IT.yml",
+ "countries.nl_NL.yml",
+ "countries.pl_PL.yml",
+ "countries.pt_PT.yml",
+ "countries.sv_SE.yml",
+ "countries.tr_TR.yml",
+ "countries.uk_UA.yml"
+ };
+
+ private Resources() {
+ }
+
+ /**
+ * Get the country name translation YAML files (English name to localized
+ * name).
+ * @return map of file contents keyed on file name
+ */
+ public static Map getCountryResources() {
+ Map result = new LinkedHashMap<>();
+ for (String name : COUNTRY_FILES) {
+ String content = read(name);
+ if (content != null) {
+ result.put(name, content);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Get the country code translation YAML file (ISO code to English name).
+ * @return map of file contents keyed on file name
+ */
+ public static Map getCountryCodeResources() {
+ Map result = new LinkedHashMap<>();
+ String content = read(COUNTRY_CODE_FILE);
+ if (content != null) {
+ result.put(COUNTRY_CODE_FILE, content);
+ }
+ return result;
+ }
+
+ /**
+ * Read a resource from this package as a UTF-8 string, or null if it is
+ * not present.
+ */
+ private static String read(String name) {
+ try (InputStream stream = Resources.class.getResourceAsStream(name)) {
+ if (stream == null) {
+ return null;
+ }
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ byte[] chunk = new byte[4096];
+ int read;
+ while ((read = stream.read(chunk)) != -1) {
+ buffer.write(chunk, 0, read);
+ }
+ return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/CountriesTranslationData.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/CountriesTranslationData.java
new file mode 100644
index 0000000..c3e27ce
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/CountriesTranslationData.java
@@ -0,0 +1,101 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.data;
+
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.translation.data.TranslationData;
+import org.slf4j.Logger;
+
+import java.util.List;
+
+/**
+ * Concrete implementation of {@link ICountriesTranslationData}.
+ */
+public class CountriesTranslationData
+ extends TranslationData
+ implements ICountriesTranslationData {
+
+ /**
+ * Construct a new instance.
+ * @param logger used for logging
+ * @param flowData the {@link FlowData} the element data is added to
+ */
+ public CountriesTranslationData(Logger logger, FlowData flowData) {
+ super(logger, flowData);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue>>
+ getCountryNamesGeographicalTranslated() {
+ return getAs("CountryNamesGeographicalTranslated",
+ AspectPropertyValue.class, List.class, IWeightedValue.class,
+ String.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue>>
+ getCountryNamesPopulationTranslated() {
+ return getAs("CountryNamesPopulationTranslated",
+ AspectPropertyValue.class, List.class, IWeightedValue.class,
+ String.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue>
+ getCountryNamesGeographicalAllTranslated() {
+ return getAs("CountryNamesGeographicalAllTranslated",
+ AspectPropertyValue.class, List.class, String.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue>
+ getCountryNamesPopulationAllTranslated() {
+ return getAs("CountryNamesPopulationAllTranslated",
+ AspectPropertyValue.class, List.class, String.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue> getCountryCodesGeographicalAll() {
+ return getAs("CountryCodesGeographicalAll",
+ AspectPropertyValue.class, List.class, String.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue> getCountryCodesPopulationAll() {
+ return getAs("CountryCodesPopulationAll",
+ AspectPropertyValue.class, List.class, String.class);
+ }
+
+ @Override
+ public String getSortingCultureUsed() {
+ return getAs("SortingCultureUsed", String.class);
+ }
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/CountryCodeTranslationData.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/CountryCodeTranslationData.java
new file mode 100644
index 0000000..c803f0f
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/CountryCodeTranslationData.java
@@ -0,0 +1,64 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.data;
+
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.translation.data.TranslationData;
+import org.slf4j.Logger;
+
+import java.util.List;
+
+/**
+ * Concrete implementation of {@link ICountryCodeTranslationData}.
+ */
+public class CountryCodeTranslationData
+ extends TranslationData
+ implements ICountryCodeTranslationData {
+
+ /**
+ * Construct a new instance.
+ * @param logger used for logging
+ * @param flowData the {@link FlowData} the element data is added to
+ */
+ public CountryCodeTranslationData(Logger logger, FlowData flowData) {
+ super(logger, flowData);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue>>
+ getCountryNamesGeographical() {
+ return getAs("CountryNamesGeographical", AspectPropertyValue.class,
+ List.class, IWeightedValue.class, String.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public AspectPropertyValue>>
+ getCountryNamesPopulation() {
+ return getAs("CountryNamesPopulation", AspectPropertyValue.class,
+ List.class, IWeightedValue.class, String.class);
+ }
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/ICountriesTranslationData.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/ICountriesTranslationData.java
new file mode 100644
index 0000000..2ded4bf
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/ICountriesTranslationData.java
@@ -0,0 +1,97 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.data;
+
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.translation.data.ITranslationData;
+
+import java.util.List;
+
+/**
+ * Contains translated country names and the complete ordered lists of country
+ * names and codes, combining the weighted results from IP Intelligence with
+ * all known countries.
+ */
+public interface ICountriesTranslationData extends ITranslationData {
+
+ /**
+ * Translated list of country names based on the geographical weighted list
+ * from IP Intelligence, translated to the browser language.
+ * @return weighted list of localized country names
+ */
+ AspectPropertyValue>>
+ getCountryNamesGeographicalTranslated();
+
+ /**
+ * Translated list of country names based on the population weighted list
+ * from IP Intelligence, translated to the browser language.
+ * @return weighted list of localized country names
+ */
+ AspectPropertyValue>>
+ getCountryNamesPopulationTranslated();
+
+ /**
+ * Translated list of all country names ordered by geographical weighting
+ * (descending), followed by the remaining countries sorted alphabetically
+ * by translated name.
+ * @return ordered list of localized country names
+ */
+ AspectPropertyValue>
+ getCountryNamesGeographicalAllTranslated();
+
+ /**
+ * Translated list of all country names ordered by population weighting
+ * (descending), followed by the remaining countries sorted alphabetically
+ * by translated name.
+ * @return ordered list of localized country names
+ */
+ AspectPropertyValue>
+ getCountryNamesPopulationAllTranslated();
+
+ /**
+ * List of all country codes ordered by geographical weighting
+ * (descending), followed by the remaining country codes in alphabetical
+ * order of their translated country names. Index-aligned with
+ * {@link #getCountryNamesGeographicalAllTranslated()}.
+ * @return ordered list of country codes
+ */
+ AspectPropertyValue> getCountryCodesGeographicalAll();
+
+ /**
+ * List of all country codes ordered by population weighting (descending),
+ * followed by the remaining country codes in alphabetical order of their
+ * translated country names. Index-aligned with
+ * {@link #getCountryNamesPopulationAllTranslated()}.
+ * @return ordered list of country codes
+ */
+ AspectPropertyValue> getCountryCodesPopulationAll();
+
+ /**
+ * The culture used by the engine to sort the data. Exposed for test
+ * purposes; not intended for customer use. May be an empty string when the
+ * invariant (case-insensitive) ordering was used.
+ * @return the sorting culture, or an empty string
+ */
+ String getSortingCultureUsed();
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/ICountryCodeTranslationData.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/ICountryCodeTranslationData.java
new file mode 100644
index 0000000..b9a3c6a
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/data/ICountryCodeTranslationData.java
@@ -0,0 +1,50 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.data;
+
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.translation.data.ITranslationData;
+
+import java.util.List;
+
+/**
+ * Contains English country names translated from the country codes for both
+ * the geographical and population weighted lists from IP Intelligence.
+ */
+public interface ICountryCodeTranslationData extends ITranslationData {
+
+ /**
+ * List of country names based on the geographical weighted list of country
+ * codes from IP Intelligence.
+ * @return weighted list of English country names
+ */
+ AspectPropertyValue>> getCountryNamesGeographical();
+
+ /**
+ * List of country names based on the population weighted list of country
+ * codes from IP Intelligence.
+ * @return weighted list of English country names
+ */
+ AspectPropertyValue>> getCountryNamesPopulation();
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountriesTranslationEngine.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountriesTranslationEngine.java
new file mode 100644
index 0000000..d49b841
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountriesTranslationEngine.java
@@ -0,0 +1,381 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.flowelements;
+
+import fiftyone.ipintelligence.shared.IPIntelligenceData;
+import fiftyone.ipintelligence.translation.Constants;
+import fiftyone.ipintelligence.translation.data.ICountriesTranslationData;
+import fiftyone.pipeline.core.data.ElementPropertyMetaData;
+import fiftyone.pipeline.core.data.ElementPropertyMetaDataDefault;
+import fiftyone.pipeline.core.data.Evidence;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.core.data.factories.ElementDataFactory;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValueDefault;
+import fiftyone.pipeline.translation.data.Languages;
+import fiftyone.pipeline.translation.data.MissingTranslationBehavior;
+import fiftyone.pipeline.translation.data.TranslationProperty;
+import fiftyone.pipeline.translation.data.Translator;
+import fiftyone.pipeline.translation.flowelements.TranslationEngineBase;
+import org.slf4j.Logger;
+
+import java.text.Collator;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Engine which takes the English country name properties from
+ * {@link CountryCodeTranslationEngine} and the weighted country code
+ * properties from the IP Intelligence engine, translates the country names to
+ * the browser language, and produces complete ordered lists of all countries
+ * combining the weighted results with all known countries.
+ *
+ * The base class handles the weighted translations
+ * (CountryNamesGeographical/Population to their Translated variants). This
+ * subclass adds the ordered "All" lists on top. Results are stored using the
+ * {@link Constants#COUNTRY_NAMES_TRANSLATED_KEY} key.
+ */
+public class CountriesTranslationEngine
+ extends TranslationEngineBase {
+
+ private static final List EVIDENCE_KEYS = Arrays.asList(
+ "query.translation",
+ "query.accept-language",
+ "header.accept-language");
+
+ private static List translations() {
+ List list = new ArrayList<>();
+ list.add(new TranslationProperty(
+ "CountryNamesGeographical", "CountryNamesGeographicalTranslated"));
+ list.add(new TranslationProperty(
+ "CountryNamesPopulation", "CountryNamesPopulationTranslated"));
+ return list;
+ }
+
+ /**
+ * All known country codes and their English names, ordered as they appear
+ * in countrycodes.en_GB.yml.
+ */
+ private final List> allCountries;
+
+ /**
+ * The set of valid country codes (keys of {@link #allCountries}), used to
+ * drop the IP engine's no-match sentinel from the "All" lists.
+ */
+ private final Set validCodes;
+
+ private List allProperties;
+
+ /**
+ * Construct a new instance.
+ * @param logger logger instance
+ * @param sources the country name translation sources, keyed on file name
+ * @param allCountries all known country codes and their English names,
+ * ordered as in countrycodes.en_GB.yml
+ * @param elementDataFactory factory used to create the element data
+ */
+ public CountriesTranslationEngine(
+ Logger logger,
+ Map sources,
+ List> allCountries,
+ ElementDataFactory elementDataFactory) {
+ super(
+ Constants.COUNTRY_NAMES_KEY,
+ translations(),
+ sources,
+ null,
+ MissingTranslationBehavior.ORIGINAL,
+ ICountriesTranslationData.class,
+ logger,
+ elementDataFactory);
+ if (allCountries == null) {
+ throw new IllegalArgumentException("allCountries");
+ }
+ this.allCountries = Collections.unmodifiableList(
+ new ArrayList<>(allCountries));
+ this.validCodes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ for (Map.Entry country : this.allCountries) {
+ this.validCodes.add(country.getKey());
+ }
+ }
+
+ @Override
+ public String getElementDataKey() {
+ return Constants.COUNTRY_NAMES_TRANSLATED_KEY;
+ }
+
+ @Override
+ public List getProperties() {
+ if (allProperties == null) {
+ List list =
+ new ArrayList<>(super.getProperties());
+ String[] names = new String[]{
+ "CountryNamesGeographicalAllTranslated",
+ "CountryNamesPopulationAllTranslated",
+ "CountryCodesGeographicalAll",
+ "CountryCodesPopulationAll"};
+ for (String name : names) {
+ list.add(new ElementPropertyMetaDataDefault(
+ name, this, "", List.class, true));
+ }
+ allProperties = list;
+ }
+ return allProperties;
+ }
+
+ @Override
+ protected void processInternal(FlowData data) {
+ // The base class produces the weighted translated names and creates
+ // the element data, using the empty (pass-through) translator when the
+ // resolved language is English or unknown.
+ super.processInternal(data);
+
+ ICountriesTranslationData elementData =
+ data.getOrAdd(getTypedDataKey(), getDataFactory());
+
+ String[] cultureUsed = new String[]{""};
+ Translator translator;
+ Comparator comparator;
+ Languages.Match match = resolveMatch(data);
+ if (match != null) {
+ translator = match.getTranslator();
+ comparator = createComparator(match.getLocale(), cultureUsed);
+ } else {
+ translator = null;
+ comparator = String.CASE_INSENSITIVE_ORDER;
+ }
+ elementData.put("SortingCultureUsed", cultureUsed[0]);
+
+ List> geoCodes = readCodes(data, true);
+ List> popCodes = readCodes(data, false);
+ List> geoNames = readWeightedNames(
+ elementData.getCountryNamesGeographicalTranslated());
+ List> popNames = readWeightedNames(
+ elementData.getCountryNamesPopulationTranslated());
+
+ buildAndStore(elementData, geoNames, geoCodes, translator, comparator,
+ "CountryNamesGeographicalAllTranslated",
+ "CountryCodesGeographicalAll");
+ buildAndStore(elementData, popNames, popCodes, translator, comparator,
+ "CountryNamesPopulationAllTranslated",
+ "CountryCodesPopulationAll");
+ }
+
+ /**
+ * Build the complete "All" lists for one dimension and store them in the
+ * element data.
+ */
+ private void buildAndStore(
+ ICountriesTranslationData elementData,
+ List> translatedWeightedNames,
+ List> weightedCodes,
+ Translator translator,
+ Comparator comparator,
+ String namesProperty,
+ String codesProperty) {
+ // The weighted countries, most probable first. Drop any code that is
+ // not a real country (the IP engine's no-match sentinel) so it never
+ // leads the list nor is re-added by the remaining step below.
+ List> weighted = new ArrayList<>();
+ Set usedCodes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ for (Map.Entry pair :
+ buildWeightedTuples(
+ translatedWeightedNames, weightedCodes, comparator)) {
+ if (validCodes.contains(pair.getKey())) {
+ weighted.add(pair);
+ usedCodes.add(pair.getKey());
+ }
+ }
+
+ // Every remaining known country, alphabetical by translated name.
+ List errors = new ArrayList<>();
+ List> remaining = new ArrayList<>();
+ for (Map.Entry known : allCountries) {
+ if (usedCodes.contains(known.getKey()) == false) {
+ String name = known.getValue();
+ if (translator != null) {
+ Object translated = translator.translate(name, errors);
+ if (translated instanceof String) {
+ name = (String) translated;
+ }
+ }
+ remaining.add(new AbstractMap.SimpleImmutableEntry<>(
+ known.getKey(), name));
+ }
+ }
+ remaining.sort(new Comparator>() {
+ @Override
+ public int compare(
+ Map.Entry a, Map.Entry b) {
+ return comparator.compare(a.getValue(), b.getValue());
+ }
+ });
+
+ List outCodes = new ArrayList<>();
+ List outNames = new ArrayList<>();
+ for (Map.Entry pair : weighted) {
+ outCodes.add(pair.getKey());
+ outNames.add(pair.getValue());
+ }
+ for (Map.Entry pair : remaining) {
+ outCodes.add(pair.getKey());
+ outNames.add(pair.getValue());
+ }
+
+ elementData.put(codesProperty,
+ new AspectPropertyValueDefault>(outCodes));
+ elementData.put(namesProperty,
+ new AspectPropertyValueDefault>(outNames));
+ }
+
+ /**
+ * Zip the translated weighted names with the weighted codes by index, then
+ * order by weighting (descending) and translated name (ascending).
+ */
+ private static List> buildWeightedTuples(
+ List> translatedNames,
+ List> codes,
+ final Comparator comparator) {
+ List tuples = new ArrayList<>();
+ if (translatedNames != null && codes != null) {
+ int count = Math.min(translatedNames.size(), codes.size());
+ for (int i = 0; i < count; i++) {
+ IWeightedValue name = translatedNames.get(i);
+ IWeightedValue code = codes.get(i);
+ tuples.add(new WeightedTuple(
+ code.getValue(), name.getValue(), name.getRawWeighting()));
+ }
+ }
+ tuples.sort(new Comparator() {
+ @Override
+ public int compare(WeightedTuple a, WeightedTuple b) {
+ int byWeight = Integer.compare(b.weight, a.weight);
+ if (byWeight != 0) {
+ return byWeight;
+ }
+ return comparator.compare(a.name, b.name);
+ }
+ });
+ List> result = new ArrayList<>();
+ for (WeightedTuple tuple : tuples) {
+ result.add(new AbstractMap.SimpleImmutableEntry<>(
+ tuple.code, tuple.name));
+ }
+ return result;
+ }
+
+ /**
+ * Resolve the target language from the evidence using the base class's
+ * available languages. English is treated as the base language (no
+ * translation needed), so it yields no match.
+ */
+ private Languages.Match resolveMatch(FlowData data) {
+ Evidence evidence = data.getEvidence();
+ if (evidence == null) {
+ return null;
+ }
+ for (String key : EVIDENCE_KEYS) {
+ Object value = evidence.get(key);
+ if (value instanceof String &&
+ ((String) value).trim().isEmpty() == false) {
+ Languages.Match match = getLanguages().match((String) value);
+ if (match != null) {
+ return match;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static Comparator createComparator(
+ String locale, String[] cultureUsed) {
+ cultureUsed[0] = "";
+ if (locale == null) {
+ return String.CASE_INSENSITIVE_ORDER;
+ }
+ String tag = locale.replace('_', '-');
+ final Collator collator =
+ Collator.getInstance(Locale.forLanguageTag(tag));
+ cultureUsed[0] = tag;
+ return new Comparator() {
+ @Override
+ public int compare(String a, String b) {
+ return collator.compare(a, b);
+ }
+ };
+ }
+
+ private static List> readCodes(
+ FlowData data, boolean geographical) {
+ try {
+ IPIntelligenceData ipData = data.get(IPIntelligenceData.class);
+ if (ipData != null) {
+ AspectPropertyValue>> codes =
+ geographical
+ ? ipData.getCountryCodesGeographical()
+ : ipData.getCountryCodesPopulation();
+ if (codes != null && codes.hasValue()) {
+ return codes.getValue();
+ }
+ }
+ } catch (RuntimeException e) {
+ // The IP Intelligence data, or the specific property, may be
+ // absent. Treat that as no weighted codes.
+ }
+ return Collections.emptyList();
+ }
+
+ private static List> readWeightedNames(
+ AspectPropertyValue>> value) {
+ if (value != null && value.hasValue()) {
+ return value.getValue();
+ }
+ return Collections.emptyList();
+ }
+
+ /**
+ * A code, its translated name and the weighting used to order the weighted
+ * countries.
+ */
+ private static final class WeightedTuple {
+
+ private final String code;
+ private final String name;
+ private final int weight;
+
+ WeightedTuple(String code, String name, int weight) {
+ this.code = code;
+ this.name = name;
+ this.weight = weight;
+ }
+ }
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountriesTranslationEngineBuilder.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountriesTranslationEngineBuilder.java
new file mode 100644
index 0000000..2c43db1
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountriesTranslationEngineBuilder.java
@@ -0,0 +1,91 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.flowelements;
+
+import fiftyone.ipintelligence.translation.Resources;
+import fiftyone.ipintelligence.translation.data.CountriesTranslationData;
+import fiftyone.pipeline.annotations.ElementBuilder;
+import fiftyone.pipeline.translation.util.YamlTranslations;
+import org.slf4j.ILoggerFactory;
+import org.slf4j.LoggerFactory;
+
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Builder for {@link CountriesTranslationEngine}. Loads the country code
+ * resource (countrycodes.en_GB.yml), which lists all known country codes and
+ * their English names, and passes it to the engine as the set of all known
+ * countries. The country name translation resources (countries.*.yml) are
+ * handled by the base translation engine.
+ */
+@ElementBuilder
+public class CountriesTranslationEngineBuilder {
+
+ private final ILoggerFactory loggerFactory;
+
+ /**
+ * Construct a new builder using the default logger factory.
+ */
+ public CountriesTranslationEngineBuilder() {
+ this(LoggerFactory.getILoggerFactory());
+ }
+
+ /**
+ * Construct a new builder.
+ * @param loggerFactory the logger factory used by the engine and the
+ * element data it creates
+ */
+ public CountriesTranslationEngineBuilder(ILoggerFactory loggerFactory) {
+ this.loggerFactory = loggerFactory;
+ }
+
+ /**
+ * Build a new {@link CountriesTranslationEngine}.
+ * @return a new engine instance
+ */
+ public CountriesTranslationEngine build() {
+ Map codeResources =
+ Resources.getCountryCodeResources();
+ List> allCountries = new ArrayList<>();
+ if (codeResources.isEmpty() == false) {
+ String content = codeResources.values().iterator().next();
+ for (Map.Entry entry :
+ YamlTranslations.parse(content).entrySet()) {
+ allCountries.add(new AbstractMap.SimpleImmutableEntry<>(
+ entry.getKey(), entry.getValue()));
+ }
+ }
+
+ return new CountriesTranslationEngine(
+ loggerFactory.getLogger(CountriesTranslationEngine.class.getName()),
+ Resources.getCountryResources(),
+ allCountries,
+ (flowData, flowElement) -> new CountriesTranslationData(
+ loggerFactory.getLogger(
+ CountriesTranslationData.class.getName()),
+ flowData));
+ }
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountryCodeTranslationEngine.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountryCodeTranslationEngine.java
new file mode 100644
index 0000000..364c03b
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountryCodeTranslationEngine.java
@@ -0,0 +1,98 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.flowelements;
+
+import fiftyone.ipintelligence.translation.Constants;
+import fiftyone.ipintelligence.translation.Resources;
+import fiftyone.ipintelligence.translation.data.ICountryCodeTranslationData;
+import fiftyone.pipeline.core.data.factories.ElementDataFactory;
+import fiftyone.pipeline.translation.data.MissingTranslationBehavior;
+import fiftyone.pipeline.translation.data.TranslationProperty;
+import fiftyone.pipeline.translation.flowelements.TranslationEngineBase;
+import org.slf4j.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Engine which takes the weighted country code properties from IP Intelligence
+ * and translates them to country names. The country names are always in
+ * English. Results are stored using the {@link Constants#COUNTRY_NAMES_KEY}
+ * key.
+ *
+ * All translation files are loaded as resources, so no configuration is
+ * required.
+ */
+public class CountryCodeTranslationEngine
+ extends TranslationEngineBase {
+
+ private static List translations() {
+ List list = new ArrayList<>();
+ list.add(new TranslationProperty(
+ "CountryCodesGeographical", "CountryNamesGeographical"));
+ list.add(new TranslationProperty(
+ "CountryCodesPopulation", "CountryNamesPopulation"));
+ return list;
+ }
+
+ /**
+ * Construct a new instance reading from the default IP Intelligence
+ * element data key ({@link Constants#IP_INTELLIGENCE_KEY}).
+ * @param logger logger instance
+ * @param elementDataFactory factory used to create the element data
+ */
+ public CountryCodeTranslationEngine(
+ Logger logger,
+ ElementDataFactory elementDataFactory) {
+ this(Constants.IP_INTELLIGENCE_KEY, logger, elementDataFactory);
+ }
+
+ /**
+ * Construct a new instance reading from the supplied IP Intelligence
+ * element data key. The key is configurable because it has differed
+ * between IP Intelligence engine versions.
+ * @param sourceElementDataKey element data key of the IP Intelligence
+ * engine
+ * @param logger logger instance
+ * @param elementDataFactory factory used to create the element data
+ */
+ public CountryCodeTranslationEngine(
+ String sourceElementDataKey,
+ Logger logger,
+ ElementDataFactory elementDataFactory) {
+ super(
+ sourceElementDataKey,
+ translations(),
+ Resources.getCountryCodeResources(),
+ "en_GB",
+ MissingTranslationBehavior.ORIGINAL,
+ ICountryCodeTranslationData.class,
+ logger,
+ elementDataFactory);
+ }
+
+ @Override
+ public String getElementDataKey() {
+ return Constants.COUNTRY_NAMES_KEY;
+ }
+}
diff --git a/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountryCodeTranslationEngineBuilder.java b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountryCodeTranslationEngineBuilder.java
new file mode 100644
index 0000000..0a23fe9
--- /dev/null
+++ b/ip-intelligence.translation/src/main/java/fiftyone/ipintelligence/translation/flowelements/CountryCodeTranslationEngineBuilder.java
@@ -0,0 +1,84 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.flowelements;
+
+import fiftyone.ipintelligence.translation.Constants;
+import fiftyone.ipintelligence.translation.data.CountryCodeTranslationData;
+import fiftyone.pipeline.annotations.ElementBuilder;
+import org.slf4j.ILoggerFactory;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Builder for {@link CountryCodeTranslationEngine}. This requires no
+ * configuration as all the translation files are loaded as resources.
+ */
+@ElementBuilder
+public class CountryCodeTranslationEngineBuilder {
+
+ private final ILoggerFactory loggerFactory;
+ private String sourceElementDataKey = Constants.IP_INTELLIGENCE_KEY;
+
+ /**
+ * Construct a new builder using the default logger factory.
+ */
+ public CountryCodeTranslationEngineBuilder() {
+ this(LoggerFactory.getILoggerFactory());
+ }
+
+ /**
+ * Construct a new builder.
+ * @param loggerFactory the logger factory used by the engine and the
+ * element data it creates
+ */
+ public CountryCodeTranslationEngineBuilder(ILoggerFactory loggerFactory) {
+ this.loggerFactory = loggerFactory;
+ }
+
+ /**
+ * Set the element data key of the IP Intelligence engine to read the
+ * weighted country codes from. Defaults to
+ * {@link Constants#IP_INTELLIGENCE_KEY}.
+ * @param sourceElementDataKey the IP Intelligence element data key
+ * @return this builder
+ */
+ public CountryCodeTranslationEngineBuilder setSourceElementDataKey(
+ String sourceElementDataKey) {
+ this.sourceElementDataKey = sourceElementDataKey;
+ return this;
+ }
+
+ /**
+ * Build a new {@link CountryCodeTranslationEngine}.
+ * @return a new engine instance
+ */
+ public CountryCodeTranslationEngine build() {
+ return new CountryCodeTranslationEngine(
+ sourceElementDataKey,
+ loggerFactory.getLogger(
+ CountryCodeTranslationEngine.class.getName()),
+ (flowData, flowElement) -> new CountryCodeTranslationData(
+ loggerFactory.getLogger(
+ CountryCodeTranslationData.class.getName()),
+ flowData));
+ }
+}
diff --git a/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/CountryTranslationIntegrationTest.java b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/CountryTranslationIntegrationTest.java
new file mode 100644
index 0000000..a5eb58d
--- /dev/null
+++ b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/CountryTranslationIntegrationTest.java
@@ -0,0 +1,156 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation;
+
+import fiftyone.ipintelligence.engine.onpremise.flowelements.IPIntelligenceOnPremiseEngine;
+import fiftyone.ipintelligence.engine.onpremise.flowelements.IPIntelligenceOnPremiseEngineBuilder;
+import fiftyone.ipintelligence.translation.data.ICountriesTranslationData;
+import fiftyone.ipintelligence.translation.flowelements.CountriesTranslationEngineBuilder;
+import fiftyone.ipintelligence.translation.flowelements.CountryCodeTranslationEngineBuilder;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.core.flowelements.Pipeline;
+import fiftyone.pipeline.core.flowelements.PipelineBuilder;
+import fiftyone.pipeline.engines.Constants;
+import org.junit.Test;
+import org.slf4j.ILoggerFactory;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.PrintStream;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
+
+/**
+ * End-to-end test that drives the real on-premise IP Intelligence engine with
+ * a data file, then the two country translation engines, and checks that a
+ * complete, localized, ordered country dropdown is produced.
+ *
+ * The data file is resolved from the {@code TestDataFile} system property, the
+ * {@code 51DEGREES_IPI_PATH} environment variable, or a few well known
+ * locations, and the test is skipped if none is found.
+ *
+ * The IP Intelligence element data key has differed between engine versions
+ * (it is {@code "ip"} in current builds), so the test reads the actual key
+ * from the engine and configures the code translation engine with it. The
+ * country names engine reads the codes by {@code IPIntelligenceData} type, so
+ * it is unaffected.
+ */
+public class CountryTranslationIntegrationTest {
+
+ private static final String SAMPLE_IP = "8.8.8.8";
+
+ private static String resolveDataFile() {
+ String[] candidates = {
+ System.getProperty("TestDataFile"),
+ System.getenv("51DEGREES_IPI_PATH"),
+ "51Degrees-IPIV4EnterpriseIpiV41.ipi",
+ "../51Degrees-IPIV4EnterpriseIpiV41.ipi",
+ "../../51Degrees-IPIV4EnterpriseIpiV41.ipi",
+ "D:/WorkSpace/51Degrees-IPIV4EnterpriseIpiV41.ipi"
+ };
+ for (String candidate : candidates) {
+ if (candidate != null && new File(candidate).exists()) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void translatesRealIpThroughOnPremiseEngine() throws Exception {
+ String dataFile = resolveDataFile();
+ assumeTrue(
+ "Skipping integration test: no IP Intelligence data file found",
+ dataFile != null);
+
+ ILoggerFactory loggerFactory = LoggerFactory.getILoggerFactory();
+ PrintStream out = System.out;
+
+ IPIntelligenceOnPremiseEngine ipEngine =
+ new IPIntelligenceOnPremiseEngineBuilder(loggerFactory, null)
+ .setPerformanceProfile(Constants.PerformanceProfiles.LowMemory)
+ .setAutoUpdate(false)
+ .build(dataFile, false);
+ String ipKey = ipEngine.getElementDataKey();
+
+ try (Pipeline pipeline = new PipelineBuilder(loggerFactory)
+ .addFlowElement(ipEngine)
+ .addFlowElement(
+ new CountryCodeTranslationEngineBuilder(loggerFactory)
+ .setSourceElementDataKey(ipKey)
+ .build())
+ .addFlowElement(
+ new CountriesTranslationEngineBuilder(loggerFactory)
+ .build())
+ .build();
+ FlowData data = pipeline.createFlowData()) {
+
+ data.addEvidence("query.client-ip", SAMPLE_IP);
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List codes =
+ ct.getCountryCodesGeographicalAll().getValue();
+ List names =
+ ct.getCountryNamesGeographicalAllTranslated().getValue();
+ List> weighted =
+ ct.getCountryNamesGeographicalTranslated().getValue();
+
+ // Print the real result so the run is self-documenting.
+ out.println("Integration test data file: " + dataFile);
+ out.println("IP engine element data key: " + ipKey);
+ out.println("Client IP: " + SAMPLE_IP + ", language: fr_FR");
+ out.println("Weighted geographical countries (most probable first):");
+ for (IWeightedValue item : weighted) {
+ out.println(" " + item.getValue() +
+ " (weight " + item.getRawWeighting() + ")");
+ }
+ out.println("Dropdown (first 5 of " + names.size() + "):");
+ for (int i = 0; i < Math.min(5, names.size()); i++) {
+ out.println(" option value=" + codes.get(i) +
+ " text=" + names.get(i));
+ }
+
+ // A complete, index-aligned, localized dropdown.
+ assertEquals(250, codes.size());
+ assertEquals(codes.size(), names.size());
+ assertEquals("fr-FR", ct.getSortingCultureUsed());
+ // Localization works through the real engine: a tail country's
+ // code is aligned with its French name.
+ assertEquals("Allemagne", names.get(codes.indexOf("DE")));
+
+ // The sample IP resolves to weighted countries, which lead the
+ // dropdown ordered by weight descending.
+ assertTrue("Expected the sample IP to resolve to a country",
+ weighted.isEmpty() == false);
+ assertEquals(weighted.get(0).getValue(), names.get(0));
+ assertTrue(weighted.get(0).getRawWeighting() > 0);
+ }
+ }
+}
diff --git a/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/TranslationTests.java b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/TranslationTests.java
new file mode 100644
index 0000000..bdb26cc
--- /dev/null
+++ b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/TranslationTests.java
@@ -0,0 +1,478 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation;
+
+import fiftyone.ipintelligence.translation.data.ICountriesTranslationData;
+import fiftyone.ipintelligence.translation.data.ICountryCodeTranslationData;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.core.flowelements.Pipeline;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import org.junit.Test;
+
+import java.text.Collator;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static fiftyone.ipintelligence.translation.testhelpers.Fixtures.apv;
+import static fiftyone.ipintelligence.translation.testhelpers.Fixtures.build;
+import static fiftyone.ipintelligence.translation.testhelpers.Fixtures.buildCustom;
+import static fiftyone.ipintelligence.translation.testhelpers.Fixtures.noValue;
+import static fiftyone.ipintelligence.translation.testhelpers.Fixtures.weighted;
+import static fiftyone.ipintelligence.translation.testhelpers.Fixtures.weightify;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+public class TranslationTests {
+
+ private static List values(
+ AspectPropertyValue>> value) {
+ List result = new ArrayList<>();
+ for (IWeightedValue item : value.getValue()) {
+ result.add(item.getValue());
+ }
+ return result;
+ }
+
+ // ===================================================================
+ // 10a. Ported from the .NET suite.
+ // ===================================================================
+
+ @Test
+ public void countryNamesFromCodes() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("GB", "FR")), apv(weightify("GB", "FR")));
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ ICountryCodeTranslationData names =
+ data.get(ICountryCodeTranslationData.class);
+ assertEquals(Arrays.asList("United Kingdom", "France"),
+ values(names.getCountryNamesGeographical()));
+ assertEquals(Arrays.asList("United Kingdom", "France"),
+ values(names.getCountryNamesPopulation()));
+ }
+ }
+
+ @Test
+ public void translatedCountry() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("GB", "FR")), apv(weightify("GB", "FR")));
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ assertEquals(Arrays.asList("Royaume-Uni", "France"),
+ values(ct.getCountryNamesGeographicalTranslated()));
+ }
+ }
+
+ @Test
+ public void allListsProducedCorrectlySorted() throws Exception {
+ // FR has the lower weight, GB the higher: GB should lead.
+ List> codes = new ArrayList<>();
+ codes.add(weighted(30000, "FR"));
+ codes.add(weighted(35535, "GB"));
+ try (Pipeline pipeline = build(apv(codes), apv(codes));
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List allCodes =
+ ct.getCountryCodesGeographicalAll().getValue();
+ List allNames =
+ ct.getCountryNamesGeographicalAllTranslated().getValue();
+
+ assertEquals("GB", allCodes.get(0));
+ assertEquals("FR", allCodes.get(1));
+ assertEquals("Royaume-Uni", allNames.get(0));
+ assertEquals("France", allNames.get(1));
+ assertEquals(250, allCodes.size());
+ assertEquals(250, allNames.size());
+ // GB and FR are not repeated in the alphabetical tail.
+ assertEquals(1, frequency(allCodes, "GB"));
+ assertEquals(1, frequency(allCodes, "FR"));
+ // Tail is sorted by the locale comparer.
+ assertTailSorted(allNames, 2, "fr-FR");
+ }
+ }
+
+ @Test
+ public void allListsProducedCorrectly() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("GB", "FR")), apv(weightify("GB", "FR")));
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List allCodes =
+ ct.getCountryCodesGeographicalAll().getValue();
+ assertEquals("GB", allCodes.get(0));
+ assertEquals("FR", allCodes.get(1));
+ assertEquals(250, allCodes.size());
+ }
+ }
+
+ @Test
+ public void allListsWithoutLanguage() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("GB", "FR")), apv(weightify("GB", "FR")));
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List allCodes =
+ ct.getCountryCodesGeographicalAll().getValue();
+ List allNames =
+ ct.getCountryNamesGeographicalAllTranslated().getValue();
+ assertEquals("GB", allCodes.get(0));
+ assertEquals("United Kingdom", allNames.get(0));
+ // Tail is the English names, alphabetically.
+ assertEquals("Afghanistan", allNames.get(2));
+ assertEquals("", ct.getSortingCultureUsed());
+ }
+ }
+
+ @Test
+ public void allListsWithNoIpData() throws Exception {
+ try (Pipeline pipeline = build(noValue(), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "de_DE");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List allCodes =
+ ct.getCountryCodesGeographicalAll().getValue();
+ List allNames =
+ ct.getCountryNamesGeographicalAllTranslated().getValue();
+ // Every country is present and fully alphabetical (no weighted
+ // leaders).
+ assertEquals(250, allCodes.size());
+ assertEquals(250, allNames.size());
+ assertEquals("de-DE", ct.getSortingCultureUsed());
+ assertTailSorted(allNames, 0, "de-DE");
+ // The German name is present and aligned with its code.
+ assertEquals("Deutschland", allNames.get(allCodes.indexOf("DE")));
+ }
+ }
+
+ @Test
+ public void populationAndGeographicalAreIndependent() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("DE")), apv(weightify("US", "CN")));
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ assertEquals("DE",
+ ct.getCountryCodesGeographicalAll().getValue().get(0));
+ assertEquals("US",
+ ct.getCountryCodesPopulationAll().getValue().get(0));
+ assertEquals("CN",
+ ct.getCountryCodesPopulationAll().getValue().get(1));
+ }
+ }
+
+ @Test
+ public void germanTranslation() throws Exception {
+ try (Pipeline pipeline = build(apv(weightify("DE")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "de_DE");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ assertEquals("Deutschland",
+ values(ct.getCountryNamesGeographicalTranslated()).get(0));
+ assertEquals("Deutschland",
+ ct.getCountryNamesGeographicalAllTranslated().getValue().get(0));
+ assertEquals("DE",
+ ct.getCountryCodesGeographicalAll().getValue().get(0));
+ }
+ }
+
+ @Test
+ public void acceptLanguageWithDash() throws Exception {
+ try (Pipeline pipeline = build(apv(weightify("GB")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr-FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ assertEquals("Royaume-Uni",
+ values(ct.getCountryNamesGeographicalTranslated()).get(0));
+ }
+ }
+
+ @Test
+ public void englishLanguageNoTranslation() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("GB", "FR")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "en-US,en;q=0.9");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ assertEquals(Arrays.asList("United Kingdom", "France"),
+ values(ct.getCountryNamesGeographicalTranslated()));
+ assertEquals("", ct.getSortingCultureUsed());
+ }
+ }
+
+ @Test
+ public void englishPreferredOverOtherLanguages() throws Exception {
+ try (Pipeline pipeline = build(apv(weightify("DE")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence(
+ "header.accept-language", "en-US,en;q=0.9,de-DE;q=0.5");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ // English wins, so the name stays English (not "Deutschland").
+ assertEquals("Germany",
+ values(ct.getCountryNamesGeographicalTranslated()).get(0));
+ }
+ }
+
+ @Test
+ public void preferredLanguageMatchedBeforeLowerPriority() throws Exception {
+ try (Pipeline pipeline = build(apv(weightify("DE")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence(
+ "header.accept-language", "es,de-DE;q=0.8,fr;q=0.5");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ // Spanish (es -> es_ES) wins.
+ assertEquals("Alemania",
+ values(ct.getCountryNamesGeographicalTranslated()).get(0));
+ }
+ }
+
+ // ===================================================================
+ // 10b. Gaps not covered by the .NET suite.
+ // ===================================================================
+
+ @Test
+ public void queryTranslationEvidenceTranslates() throws Exception {
+ try (Pipeline pipeline = build(apv(weightify("GB")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("query.translation", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ assertEquals("Royaume-Uni",
+ values(ct.getCountryNamesGeographicalTranslated()).get(0));
+ }
+ }
+
+ @Test
+ public void queryTranslationOverridesHeader() throws Exception {
+ try (Pipeline pipeline = build(apv(weightify("GB")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("query.translation", "fr_FR");
+ data.addEvidence("header.accept-language", "de_DE");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ // query.translation has higher precedence, so French wins.
+ assertEquals("Royaume-Uni",
+ values(ct.getCountryNamesGeographicalTranslated()).get(0));
+ assertEquals("fr-FR", ct.getSortingCultureUsed());
+ }
+ }
+
+ @Test
+ public void weightsPreservedThroughTranslation() throws Exception {
+ List> codes = new ArrayList<>();
+ codes.add(weighted(30000, "FR"));
+ codes.add(weighted(35535, "GB"));
+ try (Pipeline pipeline = build(apv(codes), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List> translated =
+ ct.getCountryNamesGeographicalTranslated().getValue();
+ // Order is preserved (input FR, GB); only the names changed.
+ assertEquals("France", translated.get(0).getValue());
+ assertEquals(30000, translated.get(0).getRawWeighting());
+ assertEquals("Royaume-Uni", translated.get(1).getValue());
+ assertEquals(35535, translated.get(1).getRawWeighting());
+ }
+ }
+
+ @Test
+ public void equalWeightTieBreakByName() throws Exception {
+ List> codes = new ArrayList<>();
+ codes.add(weighted(20000, "GB"));
+ codes.add(weighted(20000, "FR"));
+ try (Pipeline pipeline = build(apv(codes), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List allCodes =
+ ct.getCountryCodesGeographicalAll().getValue();
+ // Equal weights, so the secondary sort by translated name applies:
+ // "France" < "Royaume-Uni", so FR leads despite GB being first in.
+ assertEquals("FR", allCodes.get(0));
+ assertEquals("GB", allCodes.get(1));
+ }
+ }
+
+ @Test
+ public void unknownLocaleFallsBackToEnglish() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("GB", "FR")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "zz-ZZ");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ assertEquals(Arrays.asList("United Kingdom", "France"),
+ values(ct.getCountryNamesGeographicalTranslated()));
+ // Every property is still populated.
+ assertEquals(250,
+ ct.getCountryCodesGeographicalAll().getValue().size());
+ assertEquals(250,
+ ct.getCountryNamesGeographicalAllTranslated().getValue().size());
+ assertEquals("", ct.getSortingCultureUsed());
+ }
+ }
+
+ @Test
+ public void missingSingleNameStaysEnglish() throws Exception {
+ // A custom locale map missing "France" but containing "Germany".
+ Map sources = new LinkedHashMap<>();
+ sources.put("countries.xx_XX.yml", "Germany: Schland\n");
+ List> allCountries = new ArrayList<>();
+ allCountries.add(new AbstractMap.SimpleImmutableEntry<>("DE", "Germany"));
+ allCountries.add(new AbstractMap.SimpleImmutableEntry<>("FR", "France"));
+
+ try (Pipeline pipeline = buildCustom(
+ apv(weightify("DE", "FR")), noValue(), sources, allCountries);
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "xx_XX");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ // Germany is translated; France is missing so stays English.
+ assertEquals(Arrays.asList("Schland", "France"),
+ values(ct.getCountryNamesGeographicalTranslated()));
+ }
+ }
+
+ @Test
+ public void fullIndexAlignmentIncludingTail() throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("GB", "FR")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ List allCodes =
+ ct.getCountryCodesGeographicalAll().getValue();
+ List allNames =
+ ct.getCountryNamesGeographicalAllTranslated().getValue();
+ assertEquals(allCodes.size(), allNames.size());
+ // Spot-check alignment across the alphabetical tail.
+ assertEquals("Allemagne", allNames.get(allCodes.indexOf("DE")));
+ assertEquals("Espagne", allNames.get(allCodes.indexOf("ES")));
+ assertEquals("Italie", allNames.get(allCodes.indexOf("IT")));
+ assertEquals("Chine", allNames.get(allCodes.indexOf("CN")));
+ assertEquals("Japon", allNames.get(allCodes.indexOf("JP")));
+ }
+ }
+
+ @Test
+ public void sortingCultureUsedReflectsResolvedCulture() throws Exception {
+ try (Pipeline pipeline = build(apv(weightify("GB")), noValue());
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ assertEquals("fr-FR",
+ data.get(ICountriesTranslationData.class)
+ .getSortingCultureUsed());
+ }
+ }
+
+ @Test
+ public void populationTranslatedNamesDifferFromGeographical()
+ throws Exception {
+ try (Pipeline pipeline =
+ build(apv(weightify("DE")), apv(weightify("FR")));
+ FlowData data = pipeline.createFlowData()) {
+ data.addEvidence("header.accept-language", "fr_FR");
+ data.process();
+ ICountriesTranslationData ct =
+ data.get(ICountriesTranslationData.class);
+ String geo =
+ values(ct.getCountryNamesGeographicalTranslated()).get(0);
+ String pop =
+ values(ct.getCountryNamesPopulationTranslated()).get(0);
+ assertEquals("Allemagne", geo);
+ assertEquals("France", pop);
+ assertNotEquals(geo, pop);
+ // The population All list carries the translated name, not a code.
+ assertEquals("France",
+ ct.getCountryNamesPopulationAllTranslated().getValue().get(0));
+ }
+ }
+
+ // ===================================================================
+ // Helpers
+ // ===================================================================
+
+ private static int frequency(List list, String value) {
+ int count = 0;
+ for (String item : list) {
+ if (item.equals(value)) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private static void assertTailSorted(
+ List names, int from, String cultureTag) {
+ Collator collator =
+ Collator.getInstance(Locale.forLanguageTag(cultureTag));
+ for (int i = from + 1; i < names.size(); i++) {
+ assertTrue(
+ "Tail not sorted at index " + i + ": '" +
+ names.get(i - 1) + "' then '" + names.get(i) + "'",
+ collator.compare(names.get(i - 1), names.get(i)) <= 0);
+ }
+ }
+}
diff --git a/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/Fixtures.java b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/Fixtures.java
new file mode 100644
index 0000000..71cdb7a
--- /dev/null
+++ b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/Fixtures.java
@@ -0,0 +1,187 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.testhelpers;
+
+import fiftyone.ipintelligence.shared.IPIntelligenceData;
+import fiftyone.ipintelligence.translation.flowelements.CountriesTranslationEngine;
+import fiftyone.ipintelligence.translation.flowelements.CountriesTranslationEngineBuilder;
+import fiftyone.ipintelligence.translation.flowelements.CountryCodeTranslationEngine;
+import fiftyone.ipintelligence.translation.flowelements.CountryCodeTranslationEngineBuilder;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.core.data.WeightedValue;
+import fiftyone.pipeline.core.data.factories.ElementDataFactory;
+import fiftyone.pipeline.core.flowelements.Pipeline;
+import fiftyone.pipeline.core.flowelements.PipelineBuilder;
+import fiftyone.pipeline.engines.data.AspectData;
+import fiftyone.pipeline.engines.data.AspectPropertyMetaData;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValueDefault;
+import fiftyone.pipeline.engines.flowelements.AspectEngine;
+import org.slf4j.ILoggerFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.mock;
+
+/**
+ * Helpers for building country translation pipelines in tests, with a stub IP
+ * Intelligence engine producing the weighted country codes.
+ */
+public final class Fixtures {
+
+ private static final ILoggerFactory LOGGER_FACTORY =
+ LoggerFactory.getILoggerFactory();
+
+ private Fixtures() {
+ }
+
+ /**
+ * Build a weighted list of codes with descending weights assigned by input
+ * order, so the first code is the most probable.
+ * @param codes the codes, most probable first
+ * @return the weighted list
+ */
+ public static List> weightify(String... codes) {
+ List> list = new ArrayList<>();
+ for (int i = 0; i < codes.length; i++) {
+ list.add(new WeightedValue(
+ (codes.length - i) * 1000, codes[i]));
+ }
+ return list;
+ }
+
+ /**
+ * Build a single weighted code with an explicit weight.
+ * @param rawWeight the raw weight (0..65535)
+ * @param code the code
+ * @return the weighted value
+ */
+ public static IWeightedValue weighted(int rawWeight, String code) {
+ return new WeightedValue(rawWeight, code);
+ }
+
+ /**
+ * Wrap a weighted list in an {@link AspectPropertyValue} with a value.
+ * @param list the weighted list
+ * @return the wrapped value
+ */
+ public static AspectPropertyValue>> apv(
+ List> list) {
+ return new AspectPropertyValueDefault>>(
+ list);
+ }
+
+ /**
+ * A no-value {@link AspectPropertyValue}, used when the IP engine produced
+ * no weighted codes for a dimension.
+ * @return a no-value wrapper
+ */
+ public static AspectPropertyValue>> noValue() {
+ return new AspectPropertyValueDefault>>();
+ }
+
+ /**
+ * Build a pipeline of: stub IP engine -> country code translation engine
+ * -> countries translation engine.
+ * @param geographical the geographical weighted codes
+ * @param population the population weighted codes
+ * @return the pipeline
+ * @throws Exception if the pipeline cannot be built
+ */
+ @SuppressWarnings("unchecked")
+ public static Pipeline build(
+ AspectPropertyValue>> geographical,
+ AspectPropertyValue>> population)
+ throws Exception {
+ final Logger logger = LOGGER_FACTORY.getLogger("StubIpEngine");
+ final AspectEngine extends AspectData, ? extends AspectPropertyMetaData>
+ mockEngine = mock(AspectEngine.class);
+ ElementDataFactory ipFactory =
+ (flowData, flowElement) ->
+ new TestIpiData(logger, flowData, mockEngine, null);
+
+ StubIpEngine stub =
+ new StubIpEngine(logger, ipFactory, geographical, population);
+ CountryCodeTranslationEngine codeEngine =
+ new CountryCodeTranslationEngineBuilder(LOGGER_FACTORY).build();
+ CountriesTranslationEngine countriesEngine =
+ new CountriesTranslationEngineBuilder(LOGGER_FACTORY).build();
+
+ return new PipelineBuilder(LOGGER_FACTORY)
+ .addFlowElement(stub)
+ .addFlowElement(codeEngine)
+ .addFlowElement(countriesEngine)
+ .build();
+ }
+
+ /**
+ * Build a pipeline as {@link #build} but with a countries translation
+ * engine using custom country name sources and a custom set of all known
+ * countries. Used to exercise the missing-single-name behaviour with a
+ * deliberately incomplete locale map.
+ * @param geographical the geographical weighted codes
+ * @param population the population weighted codes
+ * @param countriesSources custom country name sources, keyed on file name
+ * @param allCountries custom ordered (code, English name) list
+ * @return the pipeline
+ * @throws Exception if the pipeline cannot be built
+ */
+ @SuppressWarnings("unchecked")
+ public static Pipeline buildCustom(
+ AspectPropertyValue>> geographical,
+ AspectPropertyValue>> population,
+ Map countriesSources,
+ List> allCountries)
+ throws Exception {
+ final Logger logger = LOGGER_FACTORY.getLogger("StubIpEngine");
+ final AspectEngine extends AspectData, ? extends AspectPropertyMetaData>
+ mockEngine = mock(AspectEngine.class);
+ ElementDataFactory ipFactory =
+ (flowData, flowElement) ->
+ new TestIpiData(logger, flowData, mockEngine, null);
+
+ StubIpEngine stub =
+ new StubIpEngine(logger, ipFactory, geographical, population);
+ CountryCodeTranslationEngine codeEngine =
+ new CountryCodeTranslationEngineBuilder(LOGGER_FACTORY).build();
+ CountriesTranslationEngine countriesEngine =
+ new CountriesTranslationEngine(
+ LOGGER_FACTORY.getLogger("CountriesTranslationEngine"),
+ countriesSources,
+ allCountries,
+ (flowData, flowElement) -> new fiftyone.ipintelligence
+ .translation.data.CountriesTranslationData(
+ LOGGER_FACTORY.getLogger("CountriesTranslationData"),
+ flowData));
+
+ return new PipelineBuilder(LOGGER_FACTORY)
+ .addFlowElement(stub)
+ .addFlowElement(codeEngine)
+ .addFlowElement(countriesEngine)
+ .build();
+ }
+}
diff --git a/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/StubIpEngine.java b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/StubIpEngine.java
new file mode 100644
index 0000000..d9a58c0
--- /dev/null
+++ b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/StubIpEngine.java
@@ -0,0 +1,91 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.testhelpers;
+
+import fiftyone.ipintelligence.shared.IPIntelligenceData;
+import fiftyone.pipeline.core.data.ElementPropertyMetaData;
+import fiftyone.pipeline.core.data.EvidenceKeyFilter;
+import fiftyone.pipeline.core.data.EvidenceKeyFilterWhitelist;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.core.data.factories.ElementDataFactory;
+import fiftyone.pipeline.core.flowelements.FlowElementBase;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import org.slf4j.Logger;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Stub flow element with the IP Intelligence engine's element data key
+ * ({@code "ip"}) which exposes the weighted country code lists, standing in for
+ * the on-premise engine in tests.
+ */
+public class StubIpEngine
+ extends FlowElementBase {
+
+ private final AspectPropertyValue>>
+ geographical;
+ private final AspectPropertyValue>> population;
+
+ public StubIpEngine(
+ Logger logger,
+ ElementDataFactory elementDataFactory,
+ AspectPropertyValue>> geographical,
+ AspectPropertyValue>> population) {
+ super(logger, elementDataFactory);
+ this.geographical = geographical;
+ this.population = population;
+ }
+
+ @Override
+ public String getElementDataKey() {
+ return "ip";
+ }
+
+ @Override
+ public EvidenceKeyFilter getEvidenceKeyFilter() {
+ return new EvidenceKeyFilterWhitelist(Collections.emptyList());
+ }
+
+ @Override
+ public List getProperties() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ protected void processInternal(FlowData data) {
+ IPIntelligenceData elementData =
+ data.getOrAdd(getTypedDataKey(), getDataFactory());
+ elementData.put("countrycodesgeographical", geographical);
+ elementData.put("countrycodespopulation", population);
+ }
+
+ @Override
+ protected void managedResourcesCleanup() {
+ }
+
+ @Override
+ protected void unmanagedResourcesCleanup() {
+ }
+}
diff --git a/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/TestIpiData.java b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/TestIpiData.java
new file mode 100644
index 0000000..f287e6d
--- /dev/null
+++ b/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/testhelpers/TestIpiData.java
@@ -0,0 +1,48 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.ipintelligence.translation.testhelpers;
+
+import fiftyone.ipintelligence.shared.IPIntelligenceDataBase;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.engines.data.AspectData;
+import fiftyone.pipeline.engines.data.AspectPropertyMetaData;
+import fiftyone.pipeline.engines.flowelements.AspectEngine;
+import fiftyone.pipeline.engines.services.MissingPropertyService;
+import org.slf4j.Logger;
+
+/**
+ * Minimal concrete {@link IPIntelligenceDataBase} used by the tests to expose
+ * weighted country codes as if they came from the on-premise IP Intelligence
+ * engine.
+ */
+public class TestIpiData extends IPIntelligenceDataBase {
+
+ public TestIpiData(
+ Logger logger,
+ FlowData flowData,
+ AspectEngine extends AspectData, ? extends AspectPropertyMetaData>
+ engine,
+ MissingPropertyService missingPropertyService) {
+ super(logger, flowData, engine, missingPropertyService);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 62ea847..0f90529 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,6 +14,7 @@
ip-intelligence.shared
+ ip-intelligence.translation
ip-intelligence.engine.on-premise
ip-intelligence.cloud
ip-intelligence