From 439b9069845d38df4d03fb675bae7157c0b5e735 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 22 Oct 2025 07:53:19 +0300 Subject: [PATCH] Add designer CI workflow and localization tests --- .github/workflows/ant.yml | 4 +- .github/workflows/designer.yml | 47 ++++ .github/workflows/pr.yml | 2 + CodenameOneDesigner/build.xml | 15 ++ .../com/codename1/designer/css/CN1CSSCLI.java | 213 +++++++++++++++++- .../com/codename1/designer/css/CSSTheme.java | 33 ++- .../designer/css/CSSLocalizationTest.java | 163 ++++++++++++++ 7 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/designer.yml create mode 100644 CodenameOneDesigner/test/com/codename1/designer/css/CSSLocalizationTest.java diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml index cea30d03a7..9644107db0 100644 --- a/.github/workflows/ant.yml +++ b/.github/workflows/ant.yml @@ -1,9 +1,11 @@ name: Java CI -on: +on: push: branches: - master + paths-ignore: + - 'CodenameOneDesigner/**' jobs: build-linux-jdk8: diff --git a/.github/workflows/designer.yml b/.github/workflows/designer.yml new file mode 100644 index 0000000000..68c07c6ca6 --- /dev/null +++ b/.github/workflows/designer.yml @@ -0,0 +1,47 @@ +name: Codename One Designer CI + +on: + push: + branches: + - master + paths: + - 'CodenameOneDesigner/**' + - '.github/workflows/designer.yml' + pull_request: + branches: + - master + paths: + - 'CodenameOneDesigner/**' + - '.github/workflows/designer.yml' + +jobs: + build-designer: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + java-package: jdk + + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb unzip + + - name: Fetch cn1 binaries + run: | + wget https://github.com/codenameone/cn1-binaries/archive/refs/heads/master.zip + unzip master.zip -d .. + mv ../cn1-binaries-master ../cn1-binaries + + - name: Build core dependencies + run: | + xvfb-run -a ant -noinput -buildfile Ports/JavaSE/build.xml jar + xvfb-run -a ant -noinput -buildfile Ports/JavaSEWithSVGSupport/build.xml jar + xvfb-run -a ant -noinput -buildfile CodenameOne/build.xml jar + + - name: Run designer CSS localization tests + run: xvfb-run -a ant -noinput -buildfile CodenameOneDesigner/build.xml test-css-localization diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e21307eb19..79508d44fb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,6 +9,7 @@ on: - 'docs/**' - '**/*.md' - '.github/workflows/developer-guide-docs.yml' + - 'CodenameOneDesigner/**' push: branches: - master @@ -17,6 +18,7 @@ on: - 'docs/**' - '**/*.md' - '.github/workflows/developer-guide-docs.yml' + - 'CodenameOneDesigner/**' permissions: contents: write diff --git a/CodenameOneDesigner/build.xml b/CodenameOneDesigner/build.xml index 7b7f09fa93..c4316aedde 100644 --- a/CodenameOneDesigner/build.xml +++ b/CodenameOneDesigner/build.xml @@ -163,4 +163,19 @@ + + + + + + + + + + + + + diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java index b2fc1170c2..c0c3da67cb 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java +++ b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java @@ -52,6 +52,7 @@ import java.io.InputStream; import java.io.PrintWriter; import java.io.RandomAccessFile; +import java.io.UncheckedIOException; import java.math.BigInteger; import java.net.MalformedURLException; import java.net.Socket; @@ -73,6 +74,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; @@ -136,6 +138,7 @@ private static void relaunch() throws Exception { public static boolean mergeMode; public static boolean watchmode; private static Thread watchThread; + private static File localizationDir; @@ -609,6 +612,7 @@ public static void main(String[] args) throws Exception { System.out.println(" -i, -input Input CSS file path. Multiple files separated by commas."); System.out.println(" -o, -output Output res file path."); System.out.println(" -m, -merge Path to merge file, used in case there are multipl input files."); + System.out.println(" -l, -localization Directory containing Java resource bundle .properties files to include."); System.out.println(" -w, -watch Run in watch mode."); System.out.println(" Watches input files for changes and automatically recompiles."); System.out.println("\nSystem Properties:"); @@ -621,6 +625,15 @@ public static void main(String[] args) throws Exception { } statelessMode = getArgByName(args, "i", "input") != null; + String localizationPath = getArgByName(args, "l", "localization"); + if (localizationPath != null) { + if ("true".equals(localizationPath)) { + throw new IllegalArgumentException("Localization path is required when using -l or -localization"); + } + localizationDir = new File(localizationPath); + } else { + localizationDir = null; + } String inputPath; String outputPath; String mergedFile; @@ -919,8 +932,13 @@ public void call(Component c) { theme.loadSelectorCacheStatus(cacheFile); } + Map>> localizationBundles = loadLocalizationBundles(localizationDir); + theme.createImageBorders(webViewProvider); theme.updateResources(); + if (!localizationBundles.isEmpty()) { + theme.applyLocalizationBundles(localizationBundles); + } theme.save(outputFile); theme.saveSelectorChecksums(cacheFile); @@ -1022,6 +1040,199 @@ private static void saveChecksums(File baseDir, Map map) throws I out.println(key+":"+map.get(key)); } } - + } + + private static Map>> loadLocalizationBundles(File localizationDirectory) throws IOException { + Map>> bundles = new LinkedHashMap<>(); + if (localizationDirectory == null) { + return bundles; + } + if (!localizationDirectory.exists()) { + throw new IOException("Localization directory does not exist: " + localizationDirectory.getAbsolutePath()); + } + if (!localizationDirectory.isDirectory()) { + throw new IOException("Localization path is not a directory: " + localizationDirectory.getAbsolutePath()); + } + Path root = localizationDirectory.toPath(); + try (java.util.stream.Stream stream = Files.walk(root)) { + stream.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(".properties")) + .forEach(p -> { + try { + Path relPath = root.relativize(p); + String rel = relPath.toString().replace(File.separatorChar, '/'); + if (rel.isEmpty() || !rel.endsWith(".properties")) { + return; + } + String withoutExt = rel.substring(0, rel.length() - ".properties".length()); + int lastSlash = withoutExt.lastIndexOf('/'); + String packagePath = lastSlash >= 0 ? withoutExt.substring(0, lastSlash) : ""; + String fileNamePart = lastSlash >= 0 ? withoutExt.substring(lastSlash + 1) : withoutExt; + if (fileNamePart.isEmpty()) { + return; + } + String[] tokens = fileNamePart.split("_"); + String baseNamePart = fileNamePart; + String locale = ""; + if (tokens.length > 1) { + for (int start = 1; start < tokens.length; start++) { + String localeCandidate = joinTokens(tokens, start, tokens.length); + if (isValidLocale(localeCandidate)) { + baseNamePart = joinTokens(tokens, 0, start); + locale = normalizeLocale(localeCandidate); + break; + } + } + } + String baseName; + if (!packagePath.isEmpty()) { + baseName = packagePath.replace('/', '.'); + if (!baseNamePart.isEmpty()) { + baseName = baseName + "." + baseNamePart; + } + } else { + baseName = baseNamePart; + } + if (baseName == null || baseName.isEmpty()) { + return; + } + Properties props = new Properties(); + try (InputStream is = Files.newInputStream(p)) { + props.load(is); + } + Map> baseBundles = bundles.computeIfAbsent(baseName, k -> new LinkedHashMap<>()); + Map translations = new LinkedHashMap<>(); + for (Map.Entry entry : props.entrySet()) { + translations.put(entry.getKey().toString(), entry.getValue().toString()); + } + baseBundles.put(locale, translations); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }); + } catch (UncheckedIOException ex) { + throw ex.getCause(); + } + return bundles; + } + + private static String joinTokens(String[] parts, int start, int end) { + StringBuilder sb = new StringBuilder(); + for (int i = start; i < end; i++) { + if (i > start) { + sb.append('_'); + } + sb.append(parts[i]); + } + return sb.toString(); + } + + private static boolean isValidLocale(String localeCandidate) { + if (localeCandidate == null || localeCandidate.isEmpty()) { + return false; + } + String[] parts = localeCandidate.split("_"); + if (parts.length == 0 || !isValidLanguage(parts[0])) { + return false; + } + int index = 1; + if (index < parts.length && isValidScript(parts[index])) { + index++; + } + if (index < parts.length && isValidCountry(parts[index])) { + index++; + } + while (index < parts.length) { + if (!isValidVariant(parts[index])) { + return false; + } + index++; + } + return true; + } + + private static boolean isValidLanguage(String token) { + if (token.length() < 2 || token.length() > 8) { + return false; + } + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (!Character.isLetter(c) || !Character.isLowerCase(c)) { + return false; + } + } + return true; + } + + private static boolean isValidScript(String token) { + if (token.length() != 4) { + return false; + } + for (int i = 0; i < token.length(); i++) { + if (!Character.isLetter(token.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isValidCountry(String token) { + if (token.length() == 2) { + for (int i = 0; i < token.length(); i++) { + if (!Character.isLetter(token.charAt(i))) { + return false; + } + } + return true; + } + if (token.length() == 3) { + for (int i = 0; i < token.length(); i++) { + if (!Character.isDigit(token.charAt(i))) { + return false; + } + } + return true; + } + return false; + } + + private static boolean isValidVariant(String token) { + if (token.isEmpty()) { + return false; + } + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (!(Character.isLetterOrDigit(c) || c == '_')) { + return false; + } + } + return true; + } + + private static String normalizeLocale(String locale) { + if (locale == null || locale.isEmpty()) { + return ""; + } + String[] parts = locale.split("_"); + if (parts.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(parts[0].toLowerCase()); + int index = 1; + if (index < parts.length && isValidScript(parts[index])) { + String token = parts[index]; + sb.append('_').append(Character.toUpperCase(token.charAt(0))).append(token.substring(1).toLowerCase()); + index++; + } + if (index < parts.length && isValidCountry(parts[index])) { + sb.append('_').append(parts[index].toUpperCase()); + index++; + } + while (index < parts.length) { + sb.append('_').append(parts[index]); + index++; + } + return sb.toString(); + } } diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java b/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java index 5652272ef1..190dfeaad9 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java +++ b/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java @@ -1601,7 +1601,38 @@ private static String str(LexicalUnit lu, String defaultVal) { } return num+unitText; } - + + public void applyLocalizationBundles(Map>> bundles) { + if (bundles == null || bundles.isEmpty()) { + return; + } + if (res == null) { + res = new EditableResourcesForCSS(resourceFile); + } + for (Map.Entry>> entry : bundles.entrySet()) { + String bundleName = entry.getKey(); + Map> locales = entry.getValue(); + if (bundleName == null || bundleName.isEmpty() || locales == null || locales.isEmpty()) { + continue; + } + res.setL10N(bundleName, new Hashtable()); + for (Map.Entry> localeEntry : locales.entrySet()) { + String locale = localeEntry.getKey(); + if (locale == null) { + locale = ""; + } + res.addLocale(bundleName, locale); + Map translations = localeEntry.getValue(); + if (translations == null) { + continue; + } + for (Map.Entry translation : translations.entrySet()) { + res.setLocaleProperty(bundleName, locale, translation.getKey(), translation.getValue()); + } + } + } + } + public Map calculateSelectorCacheStatus(File cachedFile) throws IOException { try { Map current = calculateSelectorChecksums(); diff --git a/CodenameOneDesigner/test/com/codename1/designer/css/CSSLocalizationTest.java b/CodenameOneDesigner/test/com/codename1/designer/css/CSSLocalizationTest.java new file mode 100644 index 0000000000..e851078c4f --- /dev/null +++ b/CodenameOneDesigner/test/com/codename1/designer/css/CSSLocalizationTest.java @@ -0,0 +1,163 @@ +package com.codename1.designer.css; + +import com.codename1.ui.util.EditableResources; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Hashtable; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Basic regression tests for the CSS localization integration. + */ +public class CSSLocalizationTest { + + public static void main(String[] args) throws Exception { + testLoadLocalizationBundles(); + testApplyLocalizationBundles(); + } + + private static void testLoadLocalizationBundles() throws Exception { + Path tempDir = Files.createTempDirectory("cn1-css-localization"); + try { + Path localizationRoot = Files.createDirectory(tempDir.resolve("l10n")); + + writeProperties(localizationRoot.resolve("Messages.properties"), + "greeting=Hello" + System.lineSeparator()); + writeProperties(localizationRoot.resolve("Messages_fr.properties"), + "greeting=Bonjour" + System.lineSeparator()); + Path appDir = Files.createDirectories(localizationRoot.resolve("com/example")); + writeProperties(appDir.resolve("App.properties"), + "title=Base" + System.lineSeparator()); + writeProperties(appDir.resolve("App_en_ca.properties"), + "title=Canadian" + System.lineSeparator()); + Path nested = Files.createDirectories(localizationRoot.resolve("nested")); + writeProperties(nested.resolve("Bundle_sr_latn_RS.properties"), + "welcome=Welcome" + System.lineSeparator()); + + Map>> bundles = loadLocalizationBundles(localizationRoot.toFile()); + + assertTrue(bundles.containsKey("Messages"), "Expected Messages bundle to be detected"); + Map> messages = bundles.get("Messages"); + assertEquals(new TreeSet<>(messages.keySet()), setOf("", "fr"), "Messages locales"); + assertEquals(messages.get(""), stringMap("greeting", "Hello"), "Default Messages translation"); + assertEquals(messages.get("fr"), stringMap("greeting", "Bonjour"), "French Messages translation"); + + assertTrue(bundles.containsKey("com.example.App"), "Expected com.example.App bundle"); + Map> app = bundles.get("com.example.App"); + assertEquals(new TreeSet<>(app.keySet()), setOf("", "en_CA"), "App locales"); + assertEquals(app.get(""), stringMap("title", "Base"), "Default App translation"); + assertEquals(app.get("en_CA"), stringMap("title", "Canadian"), "Canadian App translation"); + + assertTrue(bundles.containsKey("nested.Bundle"), "Expected nested.Bundle bundle"); + Map> nestedBundle = bundles.get("nested.Bundle"); + assertEquals(new TreeSet<>(nestedBundle.keySet()), setOf("sr_Latn_RS"), "Nested bundle locale"); + assertEquals(nestedBundle.get("sr_Latn_RS"), stringMap("welcome", "Welcome"), "Serbian translation"); + } finally { + deleteRecursively(tempDir); + } + } + + private static void testApplyLocalizationBundles() throws Exception { + Map>> bundles = new LinkedHashMap<>(); + Map> messages = new LinkedHashMap<>(); + messages.put("", stringMap("greeting", "Hello")); + messages.put("fr", stringMap("greeting", "Bonjour")); + bundles.put("Messages", messages); + + Map> app = new LinkedHashMap<>(); + app.put("", stringMap("title", "Base")); + app.put("en_CA", stringMap("title", "Canadian")); + bundles.put("com.example.App", app); + + CSSTheme theme = new CSSTheme(); + Path tempRes = Files.createTempFile("css-localization", ".res"); + try { + theme.resourceFile = tempRes.toFile(); + theme.res = null; // force lazy initialization + theme.applyLocalizationBundles(bundles); + + EditableResources resources = theme.res; + assertTrue(resources != null, "Resources should be initialized"); + Set names = new TreeSet<>(Arrays.asList(resources.getL10NResourceNames())); + assertEquals(names, setOf("Messages", "com.example.App"), "Localization bundle names"); + + Hashtable defaultMessages = resources.getL10N("Messages", ""); + Hashtable frenchMessages = resources.getL10N("Messages", "fr"); + assertEquals(defaultMessages, stringMap("greeting", "Hello"), "Default Messages values"); + assertEquals(frenchMessages, stringMap("greeting", "Bonjour"), "French Messages values"); + + Hashtable defaultApp = resources.getL10N("com.example.App", ""); + Hashtable canadianApp = resources.getL10N("com.example.App", "en_CA"); + assertEquals(defaultApp, stringMap("title", "Base"), "Default App values"); + assertEquals(canadianApp, stringMap("title", "Canadian"), "Canadian App values"); + } finally { + Files.deleteIfExists(tempRes); + } + } + + private static Map stringMap(String... entries) { + if (entries.length % 2 != 0) { + throw new IllegalArgumentException("Entries must be key/value pairs"); + } + Map result = new LinkedHashMap<>(); + for (int i = 0; i < entries.length; i += 2) { + result.put(entries[i], entries[i + 1]); + } + return result; + } + + private static Set setOf(String... values) { + return new TreeSet<>(Arrays.asList(values)); + } + + private static void writeProperties(Path path, String contents) throws IOException { + Files.write(path, contents.getBytes(StandardCharsets.ISO_8859_1), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + @SuppressWarnings("unchecked") + private static Map>> loadLocalizationBundles(File dir) + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Method m = CN1CSSCLI.class.getDeclaredMethod("loadLocalizationBundles", File.class); + m.setAccessible(true); + return (Map>>) m.invoke(null, dir); + } + + private static void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + Files.walk(path) + .sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + private static void assertTrue(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static void assertEquals(Object actual, Object expected, String message) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new AssertionError(message + " expected=" + expected + " actual=" + actual); + } + } +}