diff --git a/Ports/JavaSE/nbproject/project.properties b/Ports/JavaSE/nbproject/project.properties index 956da8fb2a..0667e1ef09 100644 --- a/Ports/JavaSE/nbproject/project.properties +++ b/Ports/JavaSE/nbproject/project.properties @@ -30,7 +30,12 @@ dist.dir=dist dist.jar=${dist.dir}/JavaSE.jar dist.javadoc.dir=${dist.dir}/javadoc endorsed.classpath= -excludes= +# The com/codename1/testing/junit package depends on org.junit.jupiter +# (only on the Maven build's classpath as a "provided" dep). The Ant +# build here does not link JUnit in, so skip those sources -- they are +# unused by the simulator integration screenshot tests that drive this +# build, and the user-facing codenameone-javase jar is the Maven one. +excludes=com/codename1/testing/junit/** file.reference.Filters.jar=../../../cn1-binaries/javase/Filters.jar file.reference.jcef.jar=../../../cn1-binaries/javase/jcef.jar file.reference.jmf-2.1.1e.jar=../../../cn1-binaries/javase/jmf-2.1.1e.jar diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index fcec559d41..0aa6ddadd7 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3154,6 +3154,64 @@ private void setPortrait(boolean portraitValue) { } } + @Override + public boolean isPortrait() { + // When setSimulatorPortrait has been called explicitly (e.g. by the + // @Orientation JUnit annotation), honor that flag rather than the + // canvas-derived inference. The canvas inherits the host window's + // dimensions, which in unit-test JVMs almost always read as + // landscape regardless of what the test asked for. + if (simulatorPortraitExplicit) { + return portrait; + } + return super.isPortrait(); + } + + /** + * Programmatically flips the simulator between portrait and landscape + * without persisting the choice to user preferences. The menu's Rotate + * action goes through the private {@link #setPortrait(boolean)} helper + * (which does persist, since it is driven by an explicit user click); + * this entry point is meant for runtime / test callers that want the + * orientation state to last only for the current JVM — in + * particular the {@code @Orientation} JUnit annotation. + * + *

Sets an explicit-override flag so that {@link #isPortrait()} returns + * this value instead of inferring orientation from canvas dimensions. + * In tests the canvas inherits the host frame's size and the inference + * would otherwise read "landscape" on any wide screen. + * + * @param portraitValue true for portrait, false for landscape + */ + public void setSimulatorPortrait(boolean portraitValue) { + simulatorPortraitExplicit = true; + if (portrait != portraitValue) { + portrait = portraitValue; + updateFrameUI(); + } + } + + private boolean simulatorPortraitExplicit = false; + + /** + * Sets the simulator's accessibility text-scale multiplier. Does not + * persist the value to user preferences — the Simulate > + * Larger Text menu remains the only restart-stable source of truth. + * Does not refresh the active theme either; the caller decides when + * to redraw (the {@code @LargerText} JUnit annotation, for instance, + * batches several config changes and then issues one refresh). + * + *

A value of {@code 1.0f} restores the default size; values like + * {@code 1.3f}, {@code 1.6f}, {@code 2.0f} mirror the menu's + * "AX2 / AX3 / AX5" presets. + * + * @param scale text-scale multiplier; {@code 1.0f} for default + */ + public void setSimulatorLargerTextScale(float scale) { + largerTextScale = scale; + largerTextEnabled = scale > 1.0f + 0.001f; + } + private void updateFrameUI() { if (instance.appFrame != null) { diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java new file mode 100644 index 0000000000..1185ab0b52 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java @@ -0,0 +1,471 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.opentest4j.TestAbortedException; + +import java.awt.GraphicsEnvironment; +import java.lang.reflect.Method; +import java.util.Hashtable; +import java.util.concurrent.atomic.AtomicReference; + +/** + * JUnit 5 extension that boots the Codename One simulator on demand and + * routes annotated work onto the EDT. + * + *

Register it via {@link CodenameOneTest @CodenameOneTest} or + * {@code @ExtendWith(CodenameOneExtension.class)}. The extension is + * intentionally lightweight: + *

+ * + *

The extension does not reach into Display's private state to reset + * pending serial calls or pointer flags between tests — that pattern + * exists inside the framework's own unit tests but is too invasive for the + * public API. If a test class needs cross-test cleanup, do it explicitly + * in {@code @AfterEach}. + */ +public class CodenameOneExtension + implements BeforeAllCallback, BeforeEachCallback, InvocationInterceptor { + + private static final Object DISPLAY_BOOT_LOCK = new Object(); + + @Override + public void beforeAll(ExtensionContext context) { + // The simulator's Display.init eventually constructs a JFrame to host + // the canvas, so a true-headless JVM (no DISPLAY, no Xvfb) will throw + // HeadlessException the moment we touch it. Abort the whole class + // instead so the headless CI run reports it as a skip rather than + // a failure -- and crucially, before we leave Display half-init'd + // and risk poisoning later tests in the same JVM. + if (GraphicsEnvironment.isHeadless()) { + throw new TestAbortedException( + "Codename One simulator tests require a graphical display; " + + "skipping " + context.getRequiredTestClass().getName() + + " because GraphicsEnvironment.isHeadless() is true. " + + "Run with Xvfb (or remove java.awt.headless=true) to enable."); + } + Class testClass = context.getRequiredTestClass(); + applyProperties(testClass.getAnnotation(SimulatorProperty.class), + testClass.getAnnotation(SimulatorProperties.class), + /*displayReady*/ false); + ensureDisplayInitialized(); + applyProperties(testClass.getAnnotation(SimulatorProperty.class), + testClass.getAnnotation(SimulatorProperties.class), + /*displayReady*/ true); + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + Method method = context.getRequiredTestMethod(); + Class testClass = context.getRequiredTestClass(); + + applyProperties(method.getAnnotation(SimulatorProperty.class), + method.getAnnotation(SimulatorProperties.class), + /*displayReady*/ true); + + final ResolvedVisualConfig config = ResolvedVisualConfig.resolve(testClass, method); + if (config.hasAny()) { + try { + applyVisualConfigOnEdt(config); + } catch (Exception e) { + throw e; + } catch (Throwable t) { + // Errors thrown from the EDT-dispatched apply step bubble up as + // Throwable; rewrap so JUnit's beforeEach contract (throws + // Exception only) is satisfied without losing the cause. + throw new RuntimeException(t); + } + } + } + + @Override + public void interceptTestMethod(Invocation invocation, + ReflectiveInvocationContext ctx, + ExtensionContext extensionContext) throws Throwable { + Method m = ctx.getExecutable(); + RunOnEdt onEdt = resolveRunOnEdt(m, extensionContext.getRequiredTestClass()); + if (onEdt != null) { + dispatchOnEdt(invocation, onEdt.timeoutMillis(), describe(m)); + } else { + invocation.proceed(); + } + } + + @Override + public void interceptBeforeEachMethod(Invocation invocation, + ReflectiveInvocationContext ctx, + ExtensionContext extensionContext) throws Throwable { + // Only dispatch lifecycle methods onto the EDT when the *class* asks + // for it. Method-level @RunOnEdt is scoped to that single @Test. + RunOnEdt classLevel = + extensionContext.getRequiredTestClass().getAnnotation(RunOnEdt.class); + if (classLevel != null) { + dispatchOnEdt(invocation, classLevel.timeoutMillis(), describe(ctx.getExecutable())); + } else { + invocation.proceed(); + } + } + + @Override + public void interceptAfterEachMethod(Invocation invocation, + ReflectiveInvocationContext ctx, + ExtensionContext extensionContext) throws Throwable { + RunOnEdt classLevel = + extensionContext.getRequiredTestClass().getAnnotation(RunOnEdt.class); + if (classLevel != null) { + dispatchOnEdt(invocation, classLevel.timeoutMillis(), describe(ctx.getExecutable())); + } else { + invocation.proceed(); + } + } + + private static RunOnEdt resolveRunOnEdt(Method method, Class testClass) { + RunOnEdt methodLevel = method.getAnnotation(RunOnEdt.class); + if (methodLevel != null) { + return methodLevel; + } + return testClass.getAnnotation(RunOnEdt.class); + } + + private static void ensureDisplayInitialized() { + if (Display.isInitialized()) { + return; + } + synchronized (DISPLAY_BOOT_LOCK) { + if (!Display.isInitialized()) { + Display.init(null); + } + } + } + + private static void applyProperties(SimulatorProperty single, + SimulatorProperties multi, + boolean displayReady) { + if (single != null) { + applyProperty(single, displayReady); + } + if (multi != null) { + SimulatorProperty[] entries = multi.value(); + if (entries != null) { + for (int i = 0; i < entries.length; i++) { + applyProperty(entries[i], displayReady); + } + } + } + } + + private static void applyProperty(SimulatorProperty prop, boolean displayReady) { + switch (prop.scope()) { + case SYSTEM: + if (!displayReady) { + System.setProperty(prop.name(), prop.value()); + } + break; + case DISPLAY: + if (displayReady) { + Display.getInstance().setProperty(prop.name(), prop.value()); + } + break; + default: + // Unknown scope - ignore so unknown future values don't break old tests. + } + } + + /** + * Applies the resolved theming / accessibility / orientation / RTL / + * dark-mode configuration on the Codename One EDT, then triggers one + * theme refresh so the live form picks up every change in a single pass. + * Mirrors the body of {@code JavaSEPort.applyThemeOnlyRefresh} via the + * publicly visible {@link UIManager}/{@link Form} APIs. + */ + private static void applyVisualConfigOnEdt(final ResolvedVisualConfig cfg) throws Throwable { + final AtomicReference thrown = new AtomicReference(); + final Object lock = new Object(); + final boolean[] done = new boolean[1]; + + Runnable apply = new Runnable() { + @Override + public void run() { + try { + if (cfg.theme != null) { + installTheme(cfg.theme); + } + if (cfg.darkMode != null) { + Display.getInstance().setDarkMode(cfg.darkMode); + } + if (cfg.largerTextScale != null) { + JavaSEPort port = JavaSEPort.instance; + if (port != null) { + port.setSimulatorLargerTextScale(cfg.largerTextScale.floatValue()); + } + } + if (cfg.orientationPortrait != null) { + JavaSEPort port = JavaSEPort.instance; + if (port != null) { + port.setSimulatorPortrait(cfg.orientationPortrait.booleanValue()); + } + } + if (cfg.rtl != null) { + UIManager.getInstance().getLookAndFeel().setRTL(cfg.rtl.booleanValue()); + } + refreshThemeInline(); + } catch (Throwable t) { + thrown.set(t); + } finally { + synchronized (lock) { + done[0] = true; + lock.notifyAll(); + } + } + } + }; + + if (CN.isEdt()) { + apply.run(); + } else { + CN.callSerially(apply); + synchronized (lock) { + while (!done[0]) { + try { + lock.wait(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ie; + } + } + } + } + + Throwable t = thrown.get(); + if (t != null) { + throw t; + } + } + + private static void installTheme(String resourcePath) throws java.io.IOException { + Resources r = Resources.open(resourcePath); + String[] names = r.getThemeResourceNames(); + if (names == null || names.length == 0) { + throw new IllegalStateException( + "Theme resource " + resourcePath + " contains no themes"); + } + Hashtable themeProps = r.getTheme(names[0]); + UIManager.getInstance().setThemeProps(themeProps); + } + + /** + * Runs the theme-refresh sequence inline on the current thread. Caller + * must already be on the Codename One EDT. Public-API equivalent of + * {@code JavaSEPort.applyThemeOnlyRefresh}. + */ + private static void refreshThemeInline() { + UIManager.getInstance().refreshTheme(); + Form curr = Display.getInstance().getCurrent(); + if (curr != null) { + curr.refreshTheme(true); + curr.revalidate(); + curr.repaint(); + } + } + + /** + * Runs {@code invocation.proceed()} on the Codename One EDT and rethrows + * any thrown exception on the calling thread once the EDT has finished + * (or the timeout expires). The single use of {@link CN#callSerially} + * with a busy-wait latch — rather than {@link CN#callSeriallyAndWait(Runnable, int)} + * — is so we can propagate the original throwable instance instead + * of having it logged-and-swallowed inside the EDT helper. + */ + private static void dispatchOnEdt(Invocation invocation, + long timeoutMillis, + String description) throws Throwable { + if (CN.isEdt()) { + invocation.proceed(); + return; + } + final AtomicReference thrown = new AtomicReference(); + final Object lock = new Object(); + final boolean[] completed = new boolean[1]; + final Invocation capturedInvocation = invocation; + + CN.callSerially(new Runnable() { + @Override + public void run() { + try { + capturedInvocation.proceed(); + } catch (Throwable t) { + thrown.set(t); + } + synchronized (lock) { + completed[0] = true; + lock.notifyAll(); + } + } + }); + + long deadline = System.currentTimeMillis() + timeoutMillis; + synchronized (lock) { + while (!completed[0]) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0L) { + break; + } + try { + lock.wait(remaining); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ie; + } + } + if (!completed[0]) { + throw new AssertionError( + description + " did not complete within " + + timeoutMillis + "ms on the Codename One EDT"); + } + } + Throwable t = thrown.get(); + if (t != null) { + throw t; + } + } + + private static String describe(Method m) { + return m.getDeclaringClass().getSimpleName() + "#" + m.getName(); + } + + /** + * Snapshot of the visual-environment annotations resolved for a single + * test method. Method-level annotations take precedence over the + * class-level ones; absent annotations stay null so {@link + * #applyVisualConfigOnEdt} can skip them entirely (the extension never + * "resets" state that the caller did not ask for). + */ + private static final class ResolvedVisualConfig { + final String theme; + final Boolean darkMode; + final Float largerTextScale; + final Boolean orientationPortrait; + final Boolean rtl; + + ResolvedVisualConfig(String theme, Boolean darkMode, Float largerTextScale, + Boolean orientationPortrait, Boolean rtl) { + this.theme = theme; + this.darkMode = darkMode; + this.largerTextScale = largerTextScale; + this.orientationPortrait = orientationPortrait; + this.rtl = rtl; + } + + boolean hasAny() { + return theme != null || darkMode != null || largerTextScale != null + || orientationPortrait != null || rtl != null; + } + + static ResolvedVisualConfig resolve(Class testClass, Method method) { + Theme theme = pickTheme(testClass, method); + DarkMode dark = pickDarkMode(testClass, method); + LargerText lt = pickLargerText(testClass, method); + Orientation o = pickOrientation(testClass, method); + RTL rtl = pickRtl(testClass, method); + return new ResolvedVisualConfig( + resolveThemePath(theme), + dark == null ? null : Boolean.valueOf(dark.enabled()), + lt == null ? null : Float.valueOf(lt.scale()), + o == null ? null : Boolean.valueOf(o.value() == Orientation.Value.PORTRAIT), + rtl == null ? null : Boolean.valueOf(rtl.enabled())); + } + + /** + * Reduces a {@link Theme} annotation to the .res path the extension + * should install. The {@link Theme#nativeTheme()} enum wins over the + * {@link Theme#value()} path because it is the more specific signal; + * a non-empty path is used only when the enum is left at its + * {@code NONE} default. Returns null when the annotation is absent + * or carries neither input, so {@link ResolvedVisualConfig#hasAny()} + * treats it as "nothing to apply" and the extension leaves the + * current theme alone. + */ + private static String resolveThemePath(Theme theme) { + if (theme == null) { + return null; + } + NativeTheme nt = theme.nativeTheme(); + if (nt != null && nt != NativeTheme.NONE) { + return nt.resourcePath(); + } + String value = theme.value(); + return (value == null || value.isEmpty()) ? null : value; + } + + private static Theme pickTheme(Class c, Method m) { + Theme t = m.getAnnotation(Theme.class); + return t != null ? t : c.getAnnotation(Theme.class); + } + + private static DarkMode pickDarkMode(Class c, Method m) { + DarkMode d = m.getAnnotation(DarkMode.class); + return d != null ? d : c.getAnnotation(DarkMode.class); + } + + private static LargerText pickLargerText(Class c, Method m) { + LargerText l = m.getAnnotation(LargerText.class); + return l != null ? l : c.getAnnotation(LargerText.class); + } + + private static Orientation pickOrientation(Class c, Method m) { + Orientation o = m.getAnnotation(Orientation.class); + return o != null ? o : c.getAnnotation(Orientation.class); + } + + private static RTL pickRtl(Class c, Method m) { + RTL r = m.getAnnotation(RTL.class); + return r != null ? r : c.getAnnotation(RTL.class); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneTest.java b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneTest.java new file mode 100644 index 0000000000..bc3cd14087 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Convenience meta-annotation that registers + * {@link CodenameOneExtension} on a JUnit 5 test class. Equivalent to + * writing {@code @ExtendWith(CodenameOneExtension.class)} but reads more + * naturally on a Codename One test: + * + *

+ * @CodenameOneTest
+ * class GreetingFormTest {
+ *
+ *     @Test
+ *     @RunOnEdt
+ *     void formShowsExpectedTitle() {
+ *         Form f = new GreetingForm();
+ *         f.show();
+ *         assertEquals("Hello", Display.getInstance().getCurrent().getTitle());
+ *     }
+ * }
+ * 
+ * + *

The extension boots {@link com.codename1.ui.Display} lazily on the + * first test in the JVM and leaves it running for the rest of the test + * run, which is faster than spinning Display up and down per class. + * Combine with {@link SimulatorProperty} / {@link SimulatorProperties} + * to seed property state, and with {@link RunOnEdt} to dispatch the test + * body to the EDT. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ExtendWith(CodenameOneExtension.class) +public @interface CodenameOneTest { +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/DarkMode.java b/Ports/JavaSE/src/com/codename1/testing/junit/DarkMode.java new file mode 100644 index 0000000000..c5b5773f6b --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/DarkMode.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Forces dark mode (or light mode, with {@code enabled=false}) for the + * duration of the test. Delegates to + * {@code Display.getInstance().setDarkMode(Boolean)} and triggers a theme + * refresh so any {@code shouldUseDarkStyle}-aware UIIDs re-resolve before + * the test body runs. + * + *

+ * @Test @DarkMode             void mainFormIsLegibleInDark()  { ... }
+ * @Test @DarkMode(enabled=false) void mainFormIsLegibleInLight() { ... }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface DarkMode { + /** {@code true} for dark mode, {@code false} for light mode. */ + boolean enabled() default true; +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/LargerText.java b/Ports/JavaSE/src/com/codename1/testing/junit/LargerText.java new file mode 100644 index 0000000000..a7bbe80b8a --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/LargerText.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Runs the test with the simulator's accessibility text-scale set to + * {@link #scale()}. Mirrors the Simulate > Larger Text menu (AX2, AX3, + * AX5...) and exists so layout regressions at larger font sizes can be + * caught in a regular test run instead of by hand. + * + *

The default scale is {@code 1.3f}, which corresponds to the menu's + * AX2 (the first step above default). Setting {@link #scale()} back to + * {@code 1.0f} on a per-method annotation restores the default size for + * that one test while a class-level annotation keeps the rest scaled. + * + *

+ * @CodenameOneTest
+ * @LargerText                 // class-level: 1.3x for every test
+ * class AccessibilityTest {
+ *
+ *     @Test
+ *     void buttonsStillFit() { ... }
+ *
+ *     @Test
+ *     @LargerText(scale = 2.0f) // method-level override: AX5
+ *     void buttonsAtExtremeScale() { ... }
+ * }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface LargerText { + /** + * Text-scale multiplier. {@code 1.0f} disables the larger-text mode. + */ + float scale() default 1.3f; +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java b/Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java new file mode 100644 index 0000000000..6e011f39ee --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +/** + * Enumerates the native themes bundled into the JavaSE simulator jar, in the + * exact form they appear under the simulator's Simulate > Native Theme + * menu. Use these constants with {@link Theme#nativeTheme()} so a test can + * declare its target look-and-feel by name instead of by resource path. + * + *

Each constant carries the {@code .res} resource path the + * {@link CodenameOneExtension} loads at test time via + * {@code Resources.open(resourcePath())}, plus the human-readable label the + * simulator menu shows so test reports and review tooling can render them + * consistently with the simulator UI. + */ +public enum NativeTheme { + + /** + * Sentinel meaning "no native theme picked" -- the default for + * {@link Theme#nativeTheme()}. When this is set, the extension falls + * back to {@link Theme#value()} if a resource path was supplied, + * otherwise the annotation is a no-op. + */ + NONE("", ""), + + /** "iOS Modern (Liquid Glass)" -- the iOS Modern look. */ + IOS_MODERN("/iOSModernTheme.res", "iOS Modern (Liquid Glass)"), + + /** "iOS 7 (Flat)" -- the flat post-iOS-7 styling. */ + IOS_FLAT("/iOS7Theme.res", "iOS 7 (Flat)"), + + /** "iPhone (Pre-Flat)" -- the pre-iOS-7 skeuomorphic look. */ + IPHONE_PRE_FLAT("/iPhoneTheme.res", "iPhone (Pre-Flat)"), + + /** "Android Material" -- the Material Design theme. */ + ANDROID_MATERIAL("/AndroidMaterialTheme.res", "Android Material"), + + /** "Android Holo Light" -- the Holo era light theme. */ + ANDROID_HOLO_LIGHT("/android_holo_light.res", "Android Holo Light"), + + /** "Android Legacy" -- the pre-Material Android look. */ + ANDROID_LEGACY("/androidTheme.res", "Android Legacy"); + + private final String resourcePath; + private final String displayName; + + NativeTheme(String resourcePath, String displayName) { + this.resourcePath = resourcePath; + this.displayName = displayName; + } + + /** + * Returns the classpath path of the {@code .res} file bundled into the + * simulator jar that backs this native theme, in the form expected by + * {@code Resources.open(...)} (leading slash). + */ + public String resourcePath() { + return resourcePath; + } + + /** + * Returns the human-readable label this theme carries in the simulator's + * Native Theme menu, suitable for inclusion in test reports. + */ + public String displayName() { + return displayName; + } +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/Orientation.java b/Ports/JavaSE/src/com/codename1/testing/junit/Orientation.java new file mode 100644 index 0000000000..4f2af7de25 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/Orientation.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Forces the simulator into a specific orientation for the test. Equivalent + * to clicking the Rotate action in the simulator menu but the change is + * not persisted to user preferences — it only lasts for the JVM. + * + *

+ * @Test
+ * @Orientation(Orientation.Value.LANDSCAPE)
+ * void formStillFitsInLandscape() {
+ *     assertFalse(Display.getInstance().isPortrait());
+ *     // ...
+ * }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Orientation { + + /** Which orientation to set. */ + Value value(); + + /** Orientation choices. */ + enum Value { + PORTRAIT, + LANDSCAPE + } +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/RTL.java b/Ports/JavaSE/src/com/codename1/testing/junit/RTL.java new file mode 100644 index 0000000000..aca925e2a0 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/RTL.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Flips the look-and-feel into right-to-left mode for the test, the way + * it would render for Arabic / Hebrew users. Delegates to + * {@code UIManager.getInstance().getLookAndFeel().setRTL(true)}. + * + *

The current form is revalidated after the flip so existing layouts + * reflow before the test body asserts on them. + * + *

+ * @Test
+ * @RTL
+ * void rightAlignedLayoutMirrorsCorrectly() {
+ *     // ... asserts about Component.getX() / alignment ...
+ * }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RTL { + /** {@code true} for RTL, {@code false} to restore LTR. Defaults to {@code true}. */ + boolean enabled() default true; +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/RunOnEdt.java b/Ports/JavaSE/src/com/codename1/testing/junit/RunOnEdt.java new file mode 100644 index 0000000000..0cb2bf1610 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/RunOnEdt.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a test (or every test in an annotated class) to execute on the + * Codename One EDT rather than on the JUnit worker thread. The body of the + * test method runs via {@code CN.callSeriallyAndWait}; assertion failures + * and other throwables are rethrown to JUnit on the calling thread so + * stack traces remain useful. + * + *

Use this when the test touches UI state that must be mutated from the + * EDT — constructing forms, calling {@code Form.show()}, walking the + * component tree, etc. Tests that only exercise pure model/utility code + * can be left off the EDT and will run faster. + * + *

A method-level {@code @RunOnEdt} takes precedence over the class + * level: place it on the class to opt the whole class in, and (rarely) on + * a single method to override the default for that one test. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RunOnEdt { + /** + * Maximum time to wait for the EDT-dispatched test body to finish, in + * milliseconds. Defaults to 30 seconds, which is generous for unit-style + * UI tests but short enough to fail fast when the EDT deadlocks. + */ + long timeoutMillis() default 30000L; +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperties.java b/Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperties.java new file mode 100644 index 0000000000..d94705194d --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperties.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Container for declaring more than one {@link SimulatorProperty} on the + * same target. The JavaSE port is compiled at source 1.7 which predates + * {@code java.lang.annotation.Repeatable}, so {@code @SimulatorProperty} + * cannot itself be repeated — wrap multiple entries in this container + * instead. + * + *

+ * @CodenameOneTest
+ * @SimulatorProperties({
+ *     @SimulatorProperty(name = "user.id",     value = "42"),
+ *     @SimulatorProperty(name = "feature.dev", value = "true")
+ * })
+ * class MyFormTest { ... }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface SimulatorProperties { + SimulatorProperty[] value(); +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperty.java b/Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperty.java new file mode 100644 index 0000000000..a32f7afb96 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperty.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Sets a property visible to the Codename One simulator before a test runs. + * + *

The {@link #scope() scope} controls when and where the value lands: + *

+ * + *

For visual / theming concerns prefer the dedicated annotations + * ({@link Theme}, {@link DarkMode}, {@link LargerText}, {@link Orientation}, + * {@link RTL}) — they apply on the EDT and trigger the right refresh + * sequence. Build hints (the {@code codename1.arg.*} keys consumed by the + * Maven plugin) are intentionally not supported here: they only mean + * something to the build server, not to runtime code. + * + *

The annotation can be placed on a test class (applies to every test in + * that class) or on a single {@code @Test} method (applies just to that + * method, after any class-level properties). To set more than one property + * on the same target wrap them in {@link SimulatorProperties} — the + * source level of this module predates {@code @Repeatable}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface SimulatorProperty { + + /** Property key. */ + String name(); + + /** Property value. */ + String value(); + + /** Where the property is applied. Defaults to {@link Scope#DISPLAY}. */ + Scope scope() default Scope.DISPLAY; + + /** + * Where a {@link SimulatorProperty} value is applied. + */ + enum Scope { + /** + * Set via {@code Display.getInstance().setProperty(name, value)} + * after Display init — the typical case for properties your + * app reads with {@code Display.getProperty}. + */ + DISPLAY, + + /** + * Set via {@code System.setProperty(name, value)} before Display + * init. Use for things the simulator reads at startup. + */ + SYSTEM + } +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/Theme.java b/Ports/JavaSE/src/com/codename1/testing/junit/Theme.java new file mode 100644 index 0000000000..5b3ec75a4b --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/Theme.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Applies a base theme before the test runs — the same effect as the + * simulator's "Native Theme" menu, but scoped to a single test (or to a + * class when placed at the class level). + * + *

Pick the theme one of two ways: + * + *

+ * + *

When both are set the {@link #nativeTheme()} wins and {@link #value()} + * is ignored. When neither is set the annotation is a no-op — the + * same as not annotating at all. The selected resource is loaded via + * {@code Resources.open(...)} and the first theme inside the resource is + * installed via {@code UIManager.setThemeProps(...)} on the EDT, after + * which the active form is refreshed. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Theme { + /** + * Classpath path to a {@code .res} file (must start with {@code /}). + * Defaults to the empty string, which means "use {@link #nativeTheme()} + * if set, otherwise treat this annotation as a no-op". + */ + String value() default ""; + + /** + * One of the native themes bundled into the simulator jar. Defaults to + * {@link NativeTheme#NONE}, which means "fall back to {@link #value()}". + */ + NativeTheme nativeTheme() default NativeTheme.NONE; +} diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/package-info.java b/Ports/JavaSE/src/com/codename1/testing/junit/package-info.java new file mode 100644 index 0000000000..6310211a84 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/package-info.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/** + * Standard JUnit 5 support for Codename One tests running inside the JavaSE + * simulator. Annotate a test class with {@link com.codename1.testing.junit.CodenameOneTest} + * (or {@code @ExtendWith(CodenameOneExtension.class)}) and the extension + * boots {@link com.codename1.ui.Display} on demand, so plain {@code @Test} + * methods can construct {@link com.codename1.ui.Form forms}, exercise UI + * code, and use the full JVM (reflection, mocking libraries, etc.) which is + * unavailable inside ParparVM on iOS. + * + *

This package is intentionally limited to the JavaSE port: it is the + * simulator's home, and apps only get the extra JUnit dependency when they + * opt in to writing JUnit tests against the simulator. + */ +package com.codename1.testing.junit; diff --git a/docs/developer-guide/Testing-with-JUnit.adoc b/docs/developer-guide/Testing-with-JUnit.adoc new file mode 100644 index 0000000000..633922ede3 --- /dev/null +++ b/docs/developer-guide/Testing-with-JUnit.adoc @@ -0,0 +1,348 @@ +== Testing with JUnit 5 + +Codename One ships two compatible test frameworks. `com.codename1.testing.AbstractTest`, driven by the `cn1:test` Maven goal, is the only way to run tests on a physical iOS or Android device — JUnit Jupiter isn't available on ParparVM. The JavaSE simulator also exposes a standard **JUnit 5** integration so you can write tests that run through Surefire and your IDE's native test runner; those tests run only in the simulator JVM but in exchange give you reflection, mocking libraries, and the rest of the standard Java testing ecosystem. + +Both styles coexist in the same project under `common/src/test/java`. You pick per test class. This chapter explains when to reach for which, walks through the JUnit annotations, and shows how the two frameworks compare side by side. + +=== When to use JUnit versus AbstractTest + +[cols="1,3,3", options="header"] +|=== +|Framework |Use it for |Don't use it for + +|`AbstractTest` + `cn1:test` +|Tests that must execute on a real device (`mvn cn1:test -Dtarget=ios` / `-Dtarget=android`). Tests that must compile under the strict Codename One device subset (no reflection, no `java.net.http.*`, no `java.nio.file.*`). +|Tests that need reflection, Mockito, AssertJ, parameterized data sets, `assertThrows`, or anything else that lives outside the device subset. + +|`@CodenameOneTest` (JUnit 5) +|Simulator-only tests. Anything that wants a full JVM at test time — reflection, mocking libraries, parameterized tests, IDE green-bar integration, `mvn test -Dtest=Foo#bar` filtering, `@BeforeEach`/`@AfterEach` lifecycle methods. +|Anything that must also run on a device — JUnit Jupiter isn't available on ParparVM. +|=== + +The two runners discover disjoint sets of test classes (cn1:test looks for `com.codename1.testing.UnitTest` implementers; Surefire looks for `@Test`-annotated methods), so a project can mix both. `mvn install` runs Surefire during the `test` phase and `cn1:test` from the javase module's `test` profile in the same phase — you don't lose either test pass. + +=== Dependencies + +The cn1app archetype generates a `common/pom.xml` and `javase/pom.xml` that already pull in `junit-jupiter` and `codenameone-javase` at test scope. If your project predates that wiring, add these dependency blocks yourself: + +[source,xml,title='common/pom.xml'] +---- + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + +---- + +[source,xml,title='javase/pom.xml'] +---- + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + +---- + +Tests live in `common/src/test/java`. Surefire only runs from the `javase` module — the `common` module keeps Surefire skipped (`true`) to avoid running each test twice, since `javase/pom.xml` mounts the same sources via `${project.basedir}/../common/src/test/java`. + +=== A minimal JUnit test + +[source,java] +---- +package com.example.myapp; + +import com.codename1.testing.junit.CodenameOneTest; +import com.codename1.testing.junit.RunOnEdt; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.Form; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@CodenameOneTest +class GreetingFormTest { + + @Test + @RunOnEdt + void formShowsExpectedTitle() { + new Form("Hello").show(); + assertEquals("Hello", Display.getInstance().getCurrent().getTitle()); + assertTrue(CN.isEdt(), "@RunOnEdt method runs on the Codename One EDT"); + } +} +---- + +Run with: + +[source,bash] +---- +mvn -pl javase test # all JUnit + cn1:test +mvn -pl javase test -Dtest=GreetingFormTest # one class +mvn -pl javase test -Dtest=GreetingFormTest#formShowsExpectedTitle # one method +---- + +=== `@CodenameOneTest` + +A class-level meta-annotation equivalent to `@ExtendWith(CodenameOneExtension.class)`. The extension does four things: + +. If `GraphicsEnvironment.isHeadless()` is true, throw `TestAbortedException` from `@BeforeAll`. JUnit reports the class as *skipped* rather than failed, and the Display singleton is never left half-initialized -- so subsequent test classes in the same JVM aren't poisoned. This makes JUnit-style Codename One tests safe to run on a headless CI runner; they simply skip. +. Boot `Display` once per JVM via `Display.init(null)`. The call is idempotent, so multiple `@CodenameOneTest` classes share one Display instance for the rest of the test run. +. Apply any visual-config annotations (`@Theme`, `@DarkMode`, `@LargerText`, `@Orientation`, `@RTL`, `@SimulatorProperty`) on the EDT in one batch before each test, followed by a single theme refresh. +. Dispatch `@RunOnEdt` test methods through `CN.callSerially` and rethrow their exceptions on the JUnit thread so stack traces stay clickable in IDEs. + +=== `@RunOnEdt` + +Place on a method (or on the class for all tests) when the body mutates UI state. + +[source,java] +---- +@Test +@RunOnEdt // body runs on the EDT +void canMutateUiSafely() { + Form f = new Form("X"); + f.add(new com.codename1.ui.Button("Tap")); + f.show(); // safe -- we are on the EDT +} + +@Test +@RunOnEdt(timeoutMillis = 60000) // default 30s; override for slow form builds +void slowFormBuild() { /* ... */ } +---- + +Class-level `@RunOnEdt` also routes `@BeforeEach`/`@AfterEach` methods through the EDT. Method-level `@RunOnEdt` is scoped to that one `@Test`. + +Tests that only exercise pure model or utility code can omit `@RunOnEdt` and run on the JUnit worker thread — they are faster because nothing pumps the EDT. + +=== `@SimulatorProperty` / `@SimulatorProperties` + +Set a property visible to the simulator before the test runs. + +[source,java] +---- +@Test +@SimulatorProperty(name = "feature.flag", value = "on") +void appReadsFlagFromDisplayProperty() { + assertEquals("on", Display.getInstance().getProperty("feature.flag", null)); +} + +@Test +@SimulatorProperties({ + @SimulatorProperty(name = "user.tier", value = "pro"), + @SimulatorProperty(name = "user.region", value = "eu") +}) +void multipleAppProperties() { /* ... */ } +---- + +The `scope` field selects where the value lands: + +[cols="1,3,3", options="header"] +|=== +|Scope |Where |When applied + +|`DISPLAY` (default) +|`Display.getInstance().setProperty(name, value)` +|After Display init. Use for properties your app reads via `Display.getProperty(...)`. + +|`SYSTEM` +|`System.setProperty(name, value)` +|Before Display init (class-level only). Use for things the simulator reads at startup, for example `java.awt.headless`. Method-level SYSTEM properties are ignored since Display is already up by the time `@BeforeEach` runs. +|=== + +The JavaSE port compiles at source 1.7, which predates `@Repeatable`. To set more than one property on the same target, wrap multiple `@SimulatorProperty` entries inside `@SimulatorProperties({...})` rather than repeating the annotation directly. + +=== `@Theme` + +Loads a base theme resource and installs it through `UIManager.setThemeProps`, then triggers a refresh. The annotation accepts either of two mutually exclusive inputs. + +*By native theme.* Use the `NativeTheme` enum for the themes bundled into the simulator jar -- the same set the simulator's *Simulate > Native Theme* menu offers: + +[source,java] +---- +@Test @Theme(nativeTheme = NativeTheme.IOS_MODERN) void rendersUnderIosModern() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.IOS_FLAT) void rendersUnderIosFlat() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.IPHONE_PRE_FLAT) void rendersUnderClassicIos() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.ANDROID_MATERIAL) void rendersUnderAndroid() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.ANDROID_HOLO_LIGHT) void rendersUnderHoloLight() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.ANDROID_LEGACY) void rendersUnderAndroidLegacy(){ /* ... */ } +---- + +The enum carries the resource path (`NativeTheme.IOS_MODERN.resourcePath()` returns `/iOSModernTheme.res`) and the simulator-menu label (`displayName()`), so test reports can render the same name a user sees in the simulator UI. + +*By resource path.* For app themes shipped under `src/main/resources` or `src/test/resources`, point `value` at the `.res` file with a leading slash: + +[source,java] +---- +@Test @Theme("/MyAppTheme.res") void rendersAppTheme() { /* ... */ } +---- + +If both `nativeTheme` and `value` are set, `nativeTheme` wins. If neither is set, the annotation is a no-op. + +=== `@DarkMode` + +Toggles dark/light mode via `Display.setDarkMode(Boolean)` and refreshes the active form. + +[source,java] +---- +@Test @DarkMode void mainFormIsLegibleInDark() { /* ... */ } +@Test @DarkMode(enabled = false) void mainFormIsLegibleInLight() { /* ... */ } +---- + +=== `@LargerText` + +Sets the accessibility text-scale multiplier — the same knob exposed by the simulator's *Simulate → Larger Text* submenu. Useful for catching layout regressions at accessibility font sizes. + +[source,java] +---- +@CodenameOneTest +@LargerText // class-level: every test at 1.3x (AX2) +class AccessibilityTest { + + @Test void buttonsStillFit() { /* ... */ } + + @Test + @LargerText(scale = 2.0f) // method-level override: AX5 + void buttonsAtExtremeScale() { /* ... */ } +} +---- + +`scale = 1.0f` restores the default size; common values mirror the menu's `1.3f` / `1.6f` / `2.0f` presets. + +=== `@Orientation` + +Forces the simulator into portrait or landscape for the test. + +[source,java] +---- +@Test +@Orientation(Orientation.Value.LANDSCAPE) +void formStillFitsInLandscape() { + assertFalse(Display.getInstance().isPortrait()); + // ... layout assertions ... +} +---- + +This calls a non-persisting setter on `JavaSEPort` and sets an explicit-portrait flag honored by `Display.isPortrait()` — so unit-test JVMs (where the canvas inherits the host window's full size and would otherwise read landscape on every wide screen) get the expected orientation back. + +=== `@RTL` + +Flips the look-and-feel into right-to-left mode (Arabic, Hebrew) via `UIManager.getInstance().getLookAndFeel().setRTL(...)`. The active form is revalidated so existing layouts reflow before the test body asserts. + +[source,java] +---- +@Test @RTL void mirrorsCorrectly() { /* ... */ } +@Test @RTL(enabled = false) void restoresLtrLayout() { /* ... */ } +---- + +=== Annotation resolution + +When the same annotation appears at both the class and the method level, *method wins*. Annotations the method doesn't override are inherited from the class. Annotations that appear on neither leave Display state alone — the extension never resets a knob the caller didn't ask for. Use `@AfterEach` for cross-test cleanup if a class must leave the simulator pristine for the next one. + +[source,java] +---- +@CodenameOneTest +@LargerText(scale = 1.3f) // class default +@DarkMode // class default +class LayoutTest { + + @Test + @LargerText(scale = 2.0f) // overrides class -> 2.0x for this test + void extremeScale() { /* runs at 2.0x scale + dark mode */ } + + @Test + void defaultScale() { /* runs at 1.3x scale + dark mode */ } +} +---- + +=== EDT semantics + +`@RunOnEdt` dispatches the test body through `CN.callSerially(...)` and uses a latch with `wait/notify` to block the JUnit worker thread until the EDT-side runnable finishes or `timeoutMillis` elapses. Throwables from inside the EDT runnable are captured and rethrown on the JUnit thread so the assertion stack trace lands in the IDE as if the test had been invoked directly. + +Without `@RunOnEdt`, the test body runs on the Surefire worker thread — fine for pure model assertions, broken for UI mutation. Forms, components, and `UIManager` are EDT-confined; touch them off the EDT and you get sporadic deadlocks and rendering glitches. + +=== Headless behavior + +`Display.init(null)` eventually calls `JavaSEPort.init(null)`, which constructs a `javax.swing.JFrame` to host the simulator canvas. JFrame construction in a headless JVM throws `HeadlessException` immediately. Therefore: + +* *Local dev (macOS / Windows / Linux desktop)*: works out of the box. +* *CI with an X server or Xvfb*: works -- wrap your build with `xvfb-run mvn test` if the runner is otherwise headless. +* *CI without DISPLAY, or with explicit `-Djava.awt.headless=true`*: every `@CodenameOneTest` class aborts via `TestAbortedException` from `@BeforeAll`. JUnit reports the class as *skipped*, not *failed*, and crucially the Display singleton is never left half-initialized -- so subsequent test classes in the same JVM aren't poisoned. + +This is why the framework's internal `CodenameOneExtensionTest` (in the Codename One sources) carries `@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true")`. The extension catches the headless case AWT detects automatically (Linux without DISPLAY); the annotation catches the explicit `-Djava.awt.headless=true` case. Together they let the test class skip cleanly on a headless runner. + +Pure-logic tests (no UI, no `@CodenameOneTest`) can run on any headless runner without configuration. + +=== Coexistence with `cn1:test` + +The two runners discover disjoint sets of classes: + +* `cn1:test` looks for classes that `implements com.codename1.testing.UnitTest` (which `AbstractTest` extends). +* Surefire (JUnit Jupiter) looks for `@Test`-annotated methods. + +They don't trip over each other. `mvn install` runs Surefire during the `test` phase, then the `cn1:test` execution bound in the `javase/pom.xml` `test` profile runs in the same phase. To target just one runner: + +[source,bash] +---- +mvn -pl javase test # both runners +mvn -pl javase test -DskipTests # skip Surefire, cn1:test still runs +mvn -pl javase test -Dtest=NoMatch # filter Surefire to nothing, + # cn1:test still runs +---- + +=== Side-by-side example + +The same "sign in, assert we land on Home" check, written both ways: + +.AbstractTest / `cn1:test` +[source,java] +---- +public class LoginNavTest extends com.codename1.testing.AbstractTest { + @Override public boolean shouldExecuteOnEDT() { return true; } + + @Override public boolean runTest() throws Exception { + new MyApp().runApp(); + TestUtils.waitForFormTitle("Login"); + TestUtils.setText("usernameField", "alice"); + TestUtils.clickButtonByLabel("Sign In"); + TestUtils.waitForFormTitle("Home"); + return TestUtils.screenshotTest("home-screen"); + } +} +---- + +.JUnit 5 / Surefire +[source,java] +---- +@CodenameOneTest +class LoginNavTest { + + @Test + @RunOnEdt + void signingInLandsOnHome() throws Exception { + new MyApp().runApp(); + TestUtils.waitForFormTitle("Login"); + TestUtils.setText("usernameField", "alice"); + TestUtils.clickButtonByLabel("Sign In"); + TestUtils.waitForFormTitle("Home"); + assertTrue(TestUtils.screenshotTest("home-screen"), "home screenshot regressed"); + } +} +---- + +The UI driving (`TestUtils.*`) is identical -- `TestUtils` is independent of the runner. What changes is the lifecycle plumbing: `shouldExecuteOnEDT()` + `runTest() returns boolean` becomes `@RunOnEdt` + `@Test void` + a standard assertion library. + +=== Cross-reference + +* For the `AbstractTest` framework's full API surface (`TestUtils` helpers, `screenshotTest` tolerance algorithm, baseline management), see <> in the performance chapter. +* For the `cn1:test` Maven goal, see <>. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 2a0c7bcbee..038f166c15 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -75,6 +75,8 @@ include::Miscellaneous-Features.asciidoc[] include::performance.asciidoc[] +include::Testing-with-JUnit.adoc[] + include::Monetization.asciidoc[] include::Advanced-Topics-Under-The-Hood.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index b564d67cb2..068a17e592 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -491,6 +491,11 @@ jdb loopback rethrow rethrows +rethrown +Throwable +Throwables +javase +Xvfb # ----------------------------------------------------------------------------- # Android tooling — names from the Android SDK / platform-tools that the diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml index 8e7bb8b8c6..e8405cf321 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml @@ -19,6 +19,31 @@ provided + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + @@ -362,6 +387,15 @@ org.apache.maven.plugins maven-surefire-plugin + true diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml index e7204a9aa8..979c5d7cc7 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml @@ -72,6 +72,23 @@ codenameone-javase provided + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index 2585df0123..f82cedfaa1 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -52,11 +52,20 @@ local-dev-javase + - - codename1.platform - javase - + ${cn1.binaries} + cn1.binaries @@ -101,10 +110,15 @@ 2.1.1e provided + org.junit.jupiter junit-jupiter - test + provided diff --git a/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java b/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java new file mode 100644 index 0000000000..c14a71377a --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.plaf.UIManager; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Sanity tests for {@link CodenameOneExtension}. The Codename One simulator + * needs a real AWT environment (it constructs a {@code JFrame} during init); + * on a true-headless JVM the extension itself aborts the class via + * {@code TestAbortedException} (see {@link CodenameOneExtension#beforeAll}), + * which JUnit reports as "skipped" rather than "failed". This local + * annotation only catches the case where {@code java.awt.headless=true} + * is explicitly set; the AWT auto-detected headless case is handled by + * the extension. + */ +@CodenameOneTest +@SimulatorProperty(name = "cn1.test.classLevel", value = "yes") +@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true") +public class CodenameOneExtensionTest { + + @Test + public void displayIsInitializedAndClassLevelPropertyApplied() { + assertTrue(Display.isInitialized(), "Display should be running by the time a test executes"); + assertEquals("yes", Display.getInstance().getProperty("cn1.test.classLevel", null), + "class-level @SimulatorProperty must reach Display.getProperty"); + } + + @Test + @RunOnEdt + public void runsOnEdtWhenAnnotated() { + assertTrue(CN.isEdt(), "@RunOnEdt method must execute on the Codename One EDT"); + } + + @Test + public void doesNotRunOnEdtWithoutAnnotation() { + assertFalse(CN.isEdt(), "tests without @RunOnEdt should stay on the JUnit worker thread"); + } + + @Test + @SimulatorProperty(name = "cn1.test.methodLevel", value = "ok") + public void methodLevelPropertyApplied() { + assertEquals("ok", Display.getInstance().getProperty("cn1.test.methodLevel", null), + "method-level @SimulatorProperty must be visible inside the test body"); + } + + @Test + @SimulatorProperties({ + @SimulatorProperty(name = "cn1.test.multi.a", value = "1"), + @SimulatorProperty(name = "cn1.test.multi.b", value = "2") + }) + public void containerAnnotationAppliesAllEntries() { + Display d = Display.getInstance(); + assertEquals("1", d.getProperty("cn1.test.multi.a", null)); + assertEquals("2", d.getProperty("cn1.test.multi.b", null)); + } + + @Test + @SimulatorProperty(name = "cn1.test.systemScoped", value = "v", scope = SimulatorProperty.Scope.SYSTEM) + public void systemScopedPropertyAfterInitIsIgnoredByExtension() { + // System-scoped properties only take effect *before* Display init; once + // Display is up they would be a no-op. The extension deliberately + // skips them so this test guards against a regression that would + // silently start mutating the JVM's System properties from a method- + // level annotation. + assertFalse("v".equals(System.getProperty("cn1.test.systemScoped")), + "SYSTEM-scoped properties must not be applied after Display init"); + } + + @Test + @LargerText(scale = 1.6f) + public void largerTextScaleApplied() { + assertEquals(1.6f, Display.getInstance().getLargerTextScale(), 0.001f, + "@LargerText must flow through to Display.getLargerTextScale()"); + assertTrue(Display.getInstance().isLargerTextEnabled(), + "scale > 1.0 should flip the larger-text flag on"); + } + + @Test + @LargerText(scale = 1.0f) + public void largerTextScaleDefaultClears() { + // The method-level annotation must override any inherited class-level + // state and put us back at 1.0x. There is no class-level @LargerText + // here, but the assertion still guards the "@LargerText(scale=1.0f) + // turns the mode off" contract documented on the annotation. + assertEquals(1.0f, Display.getInstance().getLargerTextScale(), 0.001f); + assertFalse(Display.getInstance().isLargerTextEnabled()); + } + + @Test + @Orientation(Orientation.Value.LANDSCAPE) + public void orientationLandscapeApplied() { + assertFalse(Display.getInstance().isPortrait(), + "@Orientation(LANDSCAPE) must flip Display.isPortrait() to false"); + } + + @Test + @Orientation(Orientation.Value.PORTRAIT) + public void orientationPortraitApplied() { + assertTrue(Display.getInstance().isPortrait(), + "@Orientation(PORTRAIT) must keep Display.isPortrait() true"); + } + + @Test + @DarkMode + public void darkModeEnabled() { + Boolean dark = Display.getInstance().isDarkMode(); + assertNotNull(dark, "Display.isDarkMode() must reflect the override"); + assertTrue(dark.booleanValue(), "@DarkMode must put Display in dark mode"); + } + + @Test + @DarkMode(enabled = false) + public void darkModeDisabled() { + Boolean dark = Display.getInstance().isDarkMode(); + assertNotNull(dark); + assertFalse(dark.booleanValue(), + "@DarkMode(enabled=false) must put Display in light mode"); + } + + @Test + @RTL + public void rtlEnabled() { + assertTrue(UIManager.getInstance().getLookAndFeel().isRTL(), + "@RTL must flip the look-and-feel into right-to-left mode"); + } + + @Test + @RTL(enabled = false) + public void rtlExplicitlyDisabled() { + assertFalse(UIManager.getInstance().getLookAndFeel().isRTL(), + "@RTL(enabled=false) must restore left-to-right"); + } + + @Test + @Theme("/iOSModernTheme.res") + public void themeLoadedByResourcePath() { + // Verifies the path-based form of @Theme runs end-to-end against a + // real .res bundled into the simulator jar. Asserting on individual + // theme keys would bind this test to theme internals, so we only + // check that the UIManager now reports an installed theme by name. + assertNotNull(UIManager.getInstance().getThemeName(), + "@Theme must leave a named theme installed on UIManager"); + } + + @Test + @Theme(nativeTheme = NativeTheme.ANDROID_MATERIAL) + public void themeLoadedByNativeThemeEnum() { + // Verifies the enum-based form of @Theme resolves to the correct + // bundled .res. ANDROID_MATERIAL is deliberately different from the + // path-based test above so a stale theme leaking across tests would + // be caught by the name check below. + assertNotNull(UIManager.getInstance().getThemeName(), + "@Theme(nativeTheme=...) must leave a named theme installed on UIManager"); + assertEquals("/AndroidMaterialTheme.res", + NativeTheme.ANDROID_MATERIAL.resourcePath(), + "NativeTheme.ANDROID_MATERIAL.resourcePath() must point at the bundled .res"); + assertEquals("Android Material", + NativeTheme.ANDROID_MATERIAL.displayName(), + "NativeTheme.ANDROID_MATERIAL.displayName() must mirror the simulator menu label"); + } +} diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 753a13ccad..d6f730b088 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -29,6 +29,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/html-css-cheatsheet.md` — Converting common HTML/CSS snippets to CN1 components + CSS. - `references/android-to-cn1.md` — Porting Android (XML + Kotlin/Java) screens to Codename One. - `references/testing-and-screenshots.md` — `AbstractTest`, `TestUtils`, `screenshotTest`, the `cn1:test` Maven goal, the screenshot tolerance algorithm. +- `references/junit-testing.md` — Standard JUnit 5 tests against the simulator via `@CodenameOneTest`. Annotations (`@RunOnEdt`, `@Theme`, `@DarkMode`, `@LargerText`, `@Orientation`, `@RTL`, `@SimulatorProperty`), how it coexists with `cn1:test`, and why a headless CI runner has to be configured with Xvfb (or accepts that JUnit test classes will be skipped). - `references/mobile-adaptability.md` — Density-independent units (mm), `convertToPixels`, `LayeredLayout` for responsive design, `Display.isTablet()`, font scaling. - `references/native-interfaces.md` — Authoring native interfaces for iOS/Android/JavaScript/Desktop with `cn1:generate-native-interfaces` and platform callbacks. - `references/cn1libs.md` — Creating, packaging, and consuming Codename One libraries (Maven and legacy `.cn1lib`). @@ -192,15 +193,18 @@ See `references/mobile-adaptability.md` for patterns: phone-vs-tablet master-det ## Testing -CN1 has its own test runner (`cn1:test`), not surefire. Tests extend `com.codename1.testing.AbstractTest`: +CN1 supports two compatible test styles in the same project: + +1. **Legacy `AbstractTest` + `cn1:test`.** Required for tests that must also run on a device (`mvn cn1:test -Dtarget=ios`). Compiles under the device subset (no reflection, no JavaSE APIs). See `references/testing-and-screenshots.md`. +2. **Standard JUnit 5 + `@CodenameOneTest`.** Runs only in the simulator JVM via Surefire, so you get reflection, Mockito, AssertJ, IDE green-bar integration, `-Dtest=Foo#bar` filtering. Faster startup. See `references/junit-testing.md`. + +Both runners coexist — `cn1:test` discovers `UnitTest` implementers, Surefire discovers `@Test` methods, they don't trip over each other. Pick per test class. ```java +// Legacy AbstractTest -- compiles under the device subset, runs via `cn1:test`. public class LoginFormTest extends AbstractTest { - @Override - public boolean shouldExecuteOnEDT() { return true; } - - @Override - public boolean runTest() throws Exception { + @Override public boolean shouldExecuteOnEDT() { return true; } + @Override public boolean runTest() throws Exception { new MyAppName().runApp(); TestUtils.waitForFormTitle("Login"); TestUtils.setText("usernameField", "alice"); @@ -209,14 +213,27 @@ public class LoginFormTest extends AbstractTest { return screenshotTest("home-screen-baseline"); } } + +// JUnit 5 -- simulator-only, runs via `mvn test` / Surefire. +@CodenameOneTest +class GreetingFormTest { + @Test + @RunOnEdt + void formShowsExpectedTitle() { + new Form("Hello").show(); + assertEquals("Hello", Display.getInstance().getCurrent().getTitle()); + } +} ``` -Run with `mvn -pl common cn1:test` or `mvn test`. +Run with `mvn -pl common cn1:test` (cn1:test runner only) or `mvn test` (both runners). The cn1app archetype already wires up Surefire + JUnit Jupiter in the generated POMs. `screenshotTest(name)` captures the current form, compares against a stored baseline under `Storage`, and returns `true` if within tolerance. First run records the baseline. See `references/testing-and-screenshots.md` for the tolerance algorithm and how to validate UI you just wrote. > Important: a "screenshot matches baseline" only proves consistency, **not** correctness. If you just generated the baseline yourself, you have not validated the screen — visually inspect at least once before treating that baseline as ground truth. +> Headless caveat: any simulator-driven test (both flavors) needs an X server / Xvfb to construct the simulator's `JFrame`. The `@CodenameOneTest` extension auto-aborts the class on a headless JVM so you get "skipped" instead of "errored"; the `cn1:test` runner needs you to skip with `-DskipTests` or run under `xvfb-run`. + ## Build and run commands From the project root: diff --git a/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md new file mode 100644 index 0000000000..2c07947cac --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md @@ -0,0 +1,309 @@ +# JUnit 5 Testing Reference + +Codename One ships two compatible ways to write tests: + +| Framework | Runs through | When to reach for it | +| --- | --- | --- | +| `com.codename1.testing.AbstractTest` + `cn1:test` | The Codename One Maven plugin's own runner | Tests that must also execute **on a real device** (`mvn cn1:test -Dtarget=ios`), and anything that must compile under the CN1 device subset (no reflection, no JavaSE APIs). | +| `@CodenameOneTest` (JUnit 5) + Surefire | Standard `mvn test` via the JUnit Jupiter engine | Simulator-only tests. You get a real JVM, so you can use **reflection**, **Mockito**, **AssertJ**, parameterized tests, and any other library that would fail under ParparVM. Faster startup than `cn1:test`, integrates with your IDE's standard JUnit runner, plays nicely with `mvn -pl common test -Dtest=MyTest#oneMethod` filtering. | + +Pick per test class. Both run in the same project from the same `common/src/test/java` directory; the runners don't interfere with each other (`cn1:test` discovers classes that implement `com.codename1.testing.UnitTest`, Surefire discovers `@Test`-annotated methods). + +See `references/testing-and-screenshots.md` for the AbstractTest path, including the `screenshotTest` baseline algorithm. + +## Project setup + +The cn1app archetype generates a `common/pom.xml` and `javase/pom.xml` that already pull in `junit-jupiter` and `codenameone-javase` at test scope. If your project predates that wiring, add these two blocks yourself: + +```xml + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + +``` + +```xml + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + +``` + +The `common` module has Surefire skipped (`true`) — JUnit tests actually execute from the `javase` module, which mounts `common/src/test/java` via ``. That avoids running each test twice. + +## A minimal JUnit test + +```java +package com.example.myapp; + +import com.codename1.testing.junit.CodenameOneTest; +import com.codename1.testing.junit.RunOnEdt; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.Form; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@CodenameOneTest +class GreetingFormTest { + + @Test + @RunOnEdt + void formShowsExpectedTitle() { + Form f = new Form("Hello"); + f.show(); + assertEquals("Hello", Display.getInstance().getCurrent().getTitle()); + assertTrue(CN.isEdt(), "EDT-bound assertions run on the EDT"); + } +} +``` + +Run it with `mvn -pl javase test` (or `mvn test` from the project root). Filter to one method with `mvn -pl javase test -Dtest=GreetingFormTest#formShowsExpectedTitle`. + +## Annotation reference + +All annotations live under `com.codename1.testing.junit.*` (in the `codenameone-javase` artifact). + +### `@CodenameOneTest` + +Class-level meta-annotation that registers `CodenameOneExtension` on the class. Equivalent to `@ExtendWith(CodenameOneExtension.class)`. The extension: + +1. Aborts the class with `TestAbortedException` if `GraphicsEnvironment.isHeadless()` (so a Linux CI runner without Xvfb reports **skipped** instead of failing — see "Why the simulator needs a display" below). +2. Boots `Display` once per JVM via `Display.init(null)` (idempotent — multiple test classes share one Display). +3. Applies any `@SimulatorProperty`, `@Theme`, `@DarkMode`, `@LargerText`, `@Orientation`, `@RTL` annotations on the EDT in one batch before each test. +4. Dispatches `@RunOnEdt`-annotated methods through `CN.callSerially` and rethrows their exceptions on the JUnit thread. + +### `@RunOnEdt` + +Place on a single test method, or on the test class to apply to every `@Test` (and `@BeforeEach` / `@AfterEach`). The body runs on the Codename One EDT via `CN.callSerially`. Failures rethrow on the JUnit worker thread so the stack trace IDE-jumps cleanly. + +```java +@Test +@RunOnEdt +void canMutateUiSafely() { + Form f = new Form("X"); + f.add(new com.codename1.ui.Button("Tap")); + f.show(); // safe -- we are on the EDT +} + +@Test +@RunOnEdt(timeoutMillis = 60000) // override the default 30s timeout +void slowFormBuild() { /* ... */ } +``` + +Tests that exercise pure model / utility code can skip `@RunOnEdt` and run on the JUnit worker — they're faster. + +### `@SimulatorProperty` / `@SimulatorProperties` + +Set a property visible to the simulator before the test runs. + +```java +@Test +@SimulatorProperty(name = "feature.flag", value = "on") +void appReadsFlagFromDisplayProperty() { + assertEquals("on", Display.getInstance().getProperty("feature.flag", null)); +} + +@Test +@SimulatorProperties({ + @SimulatorProperty(name = "user.tier", value = "pro"), + @SimulatorProperty(name = "user.region", value = "eu") +}) +void multipleAppProperties() { /* ... */ } +``` + +The `scope` field controls where it lands: + +| Scope | Where it goes | When applied | +| --- | --- | --- | +| `DISPLAY` (default) | `Display.getInstance().setProperty(name, value)` | After Display init. Use for properties your app reads via `Display.getProperty(...)`. | +| `SYSTEM` | `System.setProperty(name, value)` | Before Display init (class-level only). Use for things the simulator reads at startup — e.g. `java.awt.headless`. Method-level SYSTEM properties are ignored because Display is already up. | + +`@SimulatorProperties` is the container annotation. The JavaSE port compiles at source 1.7 (predates `@Repeatable`), so put multiple `@SimulatorProperty` entries inside `@SimulatorProperties({ ... })` rather than repeating the annotation directly. + +### `@Theme` + +Loads a base theme and installs it via `UIManager.setThemeProps`, then triggers a refresh. Two mutually exclusive inputs: + +**By native theme** — the recommended form for cross-platform look-and-feel coverage. The `NativeTheme` enum mirrors the simulator's *Simulate → Native Theme* menu: + +```java +@Test @Theme(nativeTheme = NativeTheme.IOS_MODERN) void looksRightOnIosModern() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.IOS_FLAT) void looksRightOnIosFlat() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.IPHONE_PRE_FLAT) void looksRightOnClassicIos() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.ANDROID_MATERIAL) void looksRightOnAndroid() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.ANDROID_HOLO_LIGHT) void looksRightOnHoloLight() { /* ... */ } +@Test @Theme(nativeTheme = NativeTheme.ANDROID_LEGACY) void looksRightOnAndroidLegacy(){ /* ... */ } +``` + +Each enum constant carries the resource path (`NativeTheme.IOS_MODERN.resourcePath()` returns `/iOSModernTheme.res`) and the simulator-menu label (`displayName()` returns `"iOS Modern (Liquid Glass)"`). + +**By resource path** — for app themes shipped under `src/main/resources` or `src/test/resources`: + +```java +@Test @Theme("/MyAppTheme.res") void looksRightWithAppTheme() { /* ... */ } +``` + +If both are set, `nativeTheme` wins. If neither is set, the annotation is a no-op. + +### `@DarkMode` + +Toggles dark/light mode via `Display.setDarkMode(Boolean)` and refreshes the active form. + +```java +@Test @DarkMode void mainFormIsLegibleInDark() { /* ... */ } +@Test @DarkMode(enabled = false) void mainFormIsLegibleInLight() { /* ... */ } +``` + +### `@LargerText` + +Sets the accessibility text-scale multiplier — the same knob as the simulator's "Larger Text" submenu. Useful for catching layout regressions at accessibility font sizes. + +```java +@CodenameOneTest +@LargerText // class-level: every test runs at 1.3× (AX2) +class AccessibilityTest { + + @Test void buttonsStillFit() { /* ... */ } + + @Test + @LargerText(scale = 2.0f) // method-level override: AX5 + void buttonsAtExtremeScale() { /* ... */ } +} +``` + +`scale = 1.0f` restores the default size. + +### `@Orientation` + +Forces the simulator into portrait or landscape. Uses `JavaSEPort.setSimulatorPortrait(boolean)` internally, which sets an explicit-portrait flag honored by `Display.isPortrait()`. + +```java +@Test +@Orientation(Orientation.Value.LANDSCAPE) +void formStillFitsInLandscape() { + assertFalse(Display.getInstance().isPortrait()); + // ... layout assertions ... +} +``` + +### `@RTL` + +Flips the look-and-feel into right-to-left mode (Arabic, Hebrew). Calls `UIManager.getInstance().getLookAndFeel().setRTL(...)` and revalidates the active form. + +```java +@Test @RTL void mirrorsCorrectly() { /* ... */ } +@Test @RTL(enabled = false) void restoresLtrLayout() { /* ... */ } +``` + +## Resolution rules + +When the same annotation appears at both the class and the method level, **method wins**. Example: + +```java +@CodenameOneTest +@LargerText(scale = 1.3f) // class default +@DarkMode // class default +class LayoutTest { + + @Test + @LargerText(scale = 2.0f) // overrides class -> 2.0f for this test + void extremeScale() { /* runs at 2.0× scale + dark mode */ } + + @Test + void defaultScale() { /* runs at 1.3× scale + dark mode */ } +} +``` + +Annotations the method doesn't override inherit from the class. Annotations that appear on neither leave Display state alone — the extension never "resets" a knob the caller didn't ask for, so write `@AfterEach` cleanup yourself if a test must leave the simulator pristine for the next class. + +## EDT semantics, in one paragraph + +`@RunOnEdt` dispatches the test body through `CN.callSerially(...)` and uses a latch + `wait/notify` to block the JUnit thread until the EDT-side runnable finishes or `timeoutMillis` expires. Throwables from inside the EDT runnable are captured and rethrown on the JUnit thread so the assertion stack trace lands in the IDE as if the test had been called directly. Without `@RunOnEdt`, the test body runs on the Surefire worker thread — fine for pure model assertions, broken for any UI mutation that has to happen on the EDT. + +## Why the simulator needs a display + +`Display.init(null)` eventually calls `JavaSEPort.init(null)`, which constructs a `javax.swing.JFrame` to host the canvas. JFrame construction in a headless JVM throws `HeadlessException` immediately. So: + +- **Local dev (macOS / Windows / Linux desktop)**: works out of the box. +- **CI with an X server (Xvfb, headful container)**: works — wrap your test command with `xvfb-run mvn test` if needed. +- **CI with `-Djava.awt.headless=true` or no DISPLAY at all**: the `@CodenameOneTest` extension calls `GraphicsEnvironment.isHeadless()` in `@BeforeAll` and aborts the class via `TestAbortedException`. JUnit reports the class as **skipped** rather than failed, and crucially the Display singleton is never partially initialized — so the next test class in the same JVM isn't poisoned. + +That's also why our own `CodenameOneExtensionTest` carries `@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true")` as a belt-and-suspenders. The extension catches auto-detected headless, the annotation catches explicit `-Djava.awt.headless=true`. Pure-logic tests (no UI, no `@CodenameOneTest`) can run on a headless runner without either. + +## Coexisting with `cn1:test` + +Both runners discover their own classes in the same `common/src/test/java` tree: + +- `cn1:test` looks for classes that `implements com.codename1.testing.UnitTest` (the AbstractTest interface). +- Surefire (JUnit Jupiter) looks for `@Test`-annotated methods. + +They don't trip over each other. `mvn install` runs both — Surefire during `test`, then `cn1:test` from the javase module's test profile. If you want only one or the other: + +```bash +mvn -pl javase test # only Surefire / JUnit +mvn -pl javase test -DskipTests # neither +mvn -pl javase verify -Dtest=NoMatchingTest # only cn1:test (skip Surefire by filtering it to nothing) +``` + +## Side-by-side example + +The same "click a button, assert title changed" check, written both ways: + +**AbstractTest / `cn1:test`:** + +```java +public class LoginNavTest extends com.codename1.testing.AbstractTest { + @Override public boolean shouldExecuteOnEDT() { return true; } + + @Override public boolean runTest() throws Exception { + new MyApp().runApp(); + TestUtils.waitForFormTitle("Login"); + TestUtils.setText("usernameField", "alice"); + TestUtils.clickButtonByLabel("Sign In"); + TestUtils.waitForFormTitle("Home"); + return TestUtils.screenshotTest("home-screen"); + } +} +``` + +**JUnit 5 / Surefire:** + +```java +@CodenameOneTest +class LoginNavTest { + + @Test + @RunOnEdt + void signingInLandsOnHome() throws Exception { + new MyApp().runApp(); + TestUtils.waitForFormTitle("Login"); + TestUtils.setText("usernameField", "alice"); + TestUtils.clickButtonByLabel("Sign In"); + TestUtils.waitForFormTitle("Home"); + assertTrue(TestUtils.screenshotTest("home-screen"), "home screenshot regressed"); + } +} +``` + +The UI driving (`TestUtils.*`) is identical — `TestUtils` is independent of the runner. What changes is the lifecycle plumbing: `shouldExecuteOnEDT() + runTest() returns boolean` becomes `@RunOnEdt + @Test void + assertion library`. + +## When NOT to use JUnit + +- Tests that must execute on a real iOS/Android device via `mvn cn1:test -Dtarget=ios`. JUnit Jupiter doesn't exist on ParparVM. Stick with `AbstractTest` for device-runnable tests. +- Codebases where every test already extends `AbstractTest` and you have no migration appetite. Both runners coexist — there is no need to convert. +- Library (cn1lib) test apps inside `tests/` of a cn1lib project. Those drive the simulator differently; treat them like the device case. diff --git a/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md b/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md index 90ca2fe15c..b45c22b84a 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md +++ b/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md @@ -1,6 +1,20 @@ # Testing and Screenshots Reference -Codename One runs tests through its own runner (`cn1:test`), not Surefire. Tests can mutate the UI on the EDT, drive components programmatically, and capture screenshots for regression. This document covers the API and the screenshot comparison algorithm, plus how to use screenshots to evaluate UI you just generated. +This document covers Codename One's `AbstractTest` framework, which runs through `cn1:test`. Standard JUnit 5 tests against the simulator are covered in `references/junit-testing.md` — both frameworks coexist in the same project and you pick per test class. + +When to use `AbstractTest` (this doc): + +- The test must also run on a device via `mvn cn1:test -Dtarget=ios`. JUnit Jupiter is not available on ParparVM, so on-device tests must use `AbstractTest`. +- The test compiles under the strict device subset (no reflection, no `java.nio.file.*`, no `java.net.http.*`). +- You already have a body of `AbstractTest` tests and want to keep adding peers in the same style. + +When to use JUnit instead (`references/junit-testing.md`): + +- Simulator-only tests that want reflection, Mockito, AssertJ, `assertThrows`, parameterized tests, `-Dtest=Foo#bar` filtering, IDE-native test discovery. + +Either way, the `TestUtils` helpers below (`waitForFormTitle`, `clickButtonByLabel`, `screenshotTest`, etc.) are framework-independent — they work the same from both. + +`AbstractTest` tests mutate the UI on the EDT, drive components programmatically, and capture screenshots for regression. This document covers the API and the screenshot comparison algorithm, plus how to use screenshots to evaluate UI you just generated. ## Where tests live