From 6ef3c05df106ba50e03e8ce7d9e51bc5dadb00b8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 24 May 2026 18:56:00 +0300 Subject: [PATCH 1/8] Add JUnit 5 test support for the JavaSE simulator App developers can now write standard @Test methods against the Codename One simulator instead of (or alongside) the legacy AbstractTest / DeviceRunner framework. The new com.codename1.testing.junit package lives in the JavaSE port -- simulator-only, so tests get full JVM reflection and can use Mockito/AssertJ/etc. that ParparVM cannot run on device. Annotations: - @CodenameOneTest -- meta @ExtendWith(CodenameOneExtension.class) - @RunOnEdt -- dispatch the test body (and lifecycle when class-level) through CN.callSerially with a latch so throwables surface on the JUnit thread - @SimulatorProperty (+ @SimulatorProperties container, since the port is source 1.7 and predates @Repeatable) -- SYSTEM scope before Display init, DISPLAY scope after - @Theme, @DarkMode, @LargerText, @Orientation, @RTL -- per-test visual config applied on the EDT in one batch followed by a single theme refresh; method-level overrides class-level JavaSEPort gains two public, non-persisting setters (setSimulatorPortrait, setSimulatorLargerTextScale) so the extension can drive accessibility / orientation without reflection, and an isPortrait() override that honors an explicit-portrait flag (the default canvas-derived inference reads as landscape on any wide host window, which broke the orientation case in tests). JUnit Jupiter moves from test to provided scope in the javase pom so the support classes compile but the JUnit dependency does not leak onto the simulator's runtime classpath for apps that do not opt in. 15 new tests in CodenameOneExtensionTest cover every annotation end-to-end; full javase suite stays green at 63 tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/impl/javase/JavaSEPort.java | 58 +++ .../testing/junit/CodenameOneExtension.java | 434 ++++++++++++++++++ .../testing/junit/CodenameOneTest.java | 63 +++ .../com/codename1/testing/junit/DarkMode.java | 47 ++ .../codename1/testing/junit/LargerText.java | 62 +++ .../codename1/testing/junit/Orientation.java | 56 +++ .../src/com/codename1/testing/junit/RTL.java | 51 ++ .../com/codename1/testing/junit/RunOnEdt.java | 55 +++ .../testing/junit/SimulatorProperties.java | 50 ++ .../testing/junit/SimulatorProperty.java | 87 ++++ .../com/codename1/testing/junit/Theme.java | 59 +++ .../codename1/testing/junit/package-info.java | 37 ++ maven/javase/pom.xml | 7 +- .../junit/CodenameOneExtensionTest.java | 172 +++++++ 14 files changed, 1237 insertions(+), 1 deletion(-) create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneTest.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/DarkMode.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/LargerText.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/Orientation.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/RTL.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/RunOnEdt.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperties.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/SimulatorProperty.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/Theme.java create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/package-info.java create mode 100644 maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java 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..9a1d54b04e --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java @@ -0,0 +1,434 @@ +/* + * 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 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) { + 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( + theme == null ? null : theme.value(), + 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())); + } + + 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/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..309ab84c01 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/testing/junit/Theme.java @@ -0,0 +1,59 @@ +/* + * 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 resource before the test runs — equivalent to + * the simulator's "Native Theme" menu, but scoped to a single test (or to a + * class when placed at the class level). + * + *

The {@link #value()} is the classpath name of a {@code .res} file + * containing a theme. The native themes bundled into the JavaSE simulator + * jar can be used directly: + * + *

+ * @Test @Theme("/iOSModernTheme.res")   void looksRightOnIos()       { ... }
+ * @Test @Theme("/AndroidMaterialTheme.res") void looksRightOnAndroid() { ... }
+ * @Test @Theme("/iPhoneTheme.res")     void looksRightOnLegacyIos() { ... }
+ * 
+ * + *

Custom app themes work too — ship the {@code .res} file under + * {@code src/main/resources} or {@code src/test/resources} and reference it + * with a leading slash. The theme is loaded via + * {@code Resources.open(value)} 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 /}). + */ + String value(); +} 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/maven/javase/pom.xml b/maven/javase/pom.xml index 2585df0123..cc835b2d5d 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -101,10 +101,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..14c242aace --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java @@ -0,0 +1,172 @@ +/* + * 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 creates a {@code JFrame} during init); + * on a true headless CI runner these tests would throw + * {@code HeadlessException}, so the class is disabled when + * {@code java.awt.headless=true} is set explicitly. + */ +@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 themeLoaded() { + // Verifies the annotation 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"); + } +} From dcfdc2353f0ff7522a8883be65cef80ed4265541 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 24 May 2026 19:54:14 +0300 Subject: [PATCH 2/8] Auto-activate local-dev-javase profile when cn1.binaries is set setup-workspace.sh runs two mvn invocations: the first passes -Dcodename1.platform=javase (which activates local-dev-javase and pulls jcef.jar + jfxrt.jar onto the simulator's compile classpath), the second doesn't. As long as nothing forced javase to recompile in that second pass, master got away with it -- target/classes from the first pass already had CEF / JavaFX .class files. The JUnit-support work in this branch adds new source files under Ports/JavaSE/src/com/codename1/ testing/junit/ which is enough to flip maven-compiler-plugin's incremental detection into a full rebuild, and the rebuild then fails because jcef.jar is no longer visible. Switch the activation to mirror the maven/android compile-android profile -- fire whenever cn1.binaries is a real directory and the property is set. The script passes -Dcn1.binaries to both invocations, so the profile now activates in both passes and the recompile (when it does happen) has everything it needs. Co-Authored-By: Claude Opus 4.7 (1M context) --- maven/javase/pom.xml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index cc835b2d5d..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 From 035be169db60b8fbd325593bf96366e415efdcf5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 24 May 2026 20:03:30 +0300 Subject: [PATCH 3/8] Exclude com/codename1/testing/junit/** from JavaSE Ant build scripts/run-javase-simulator-integration-tests.sh (and the top-level ant test-javase target used by PR CI) build the JavaSE port through the NetBeans-style Ports/JavaSE/build.xml. That Ant build has its own javac.classpath -- it does not see Maven scopes -- and JUnit Jupiter is not on it. The new com/codename1/testing/junit support classes are meant to ship via the codenameone-javase Maven artifact, where junit-jupiter is a "provided" dep. They are not needed for the simulator's screenshot integration tests, so just skip them on the Ant side. Co-Authored-By: Claude Opus 4.7 (1M context) --- Ports/JavaSE/nbproject/project.properties | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From 96a734ac89c7355d9959c4df2d4724ae692c23b2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 02:21:38 +0300 Subject: [PATCH 4/8] Abort CodenameOneExtension cleanly on headless runners The PR CI's build-test matrix runs Surefire inside a Linux container with no X11 display. AWT's GraphicsEnvironment auto-detects headless mode but never sets the java.awt.headless system property, so the @DisabledIfSystemProperty guard on CodenameOneExtensionTest didn't fire. The extension then tried Display.init(null), which goes through JavaSEPort.init -> new JFrame() and threw HeadlessException inside @BeforeAll. JUnit marked the class as errored, and worse the Display singleton was left half-initialized, hanging the next test class (SimulatorHookLoaderTest) until the CI 6-hour timeout fired. Fix: have CodenameOneExtension.beforeAll check GraphicsEnvironment.isHeadless() first and throw TestAbortedException with an explanatory message. JUnit reports that as "skipped" instead of "failed", and crucially the JVM never enters Display.init so the shared singleton stays clean for any later tests. Verified locally: mvn test -DargLine="-Djava.awt.headless=true" reports "Tests run: 15, Skipped: 15" instead of erroring. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../testing/junit/CodenameOneExtension.java | 15 +++++++++++++++ .../testing/junit/CodenameOneExtensionTest.java | 11 +++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java index 9a1d54b04e..27ca4c1952 100644 --- a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java +++ b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java @@ -34,7 +34,9 @@ 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; @@ -75,6 +77,19 @@ public class CodenameOneExtension @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), 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 index 14c242aace..983b8f291a 100644 --- a/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java +++ b/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java @@ -36,10 +36,13 @@ /** * Sanity tests for {@link CodenameOneExtension}. The Codename One simulator - * needs a real AWT environment (it creates a {@code JFrame} during init); - * on a true headless CI runner these tests would throw - * {@code HeadlessException}, so the class is disabled when - * {@code java.awt.headless=true} is set explicitly. + * 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") From 07397762f070403bbc7e5d0b3fa3bd25e5f038fc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 04:50:17 +0300 Subject: [PATCH 5/8] Document JUnit testing path: skill, dev guide, archetype wiring End-user-facing changes to make the new com.codename1.testing.junit API usable out of the box: * maven/cn1app-archetype/.../common/pom.xml: add codenameone-javase and junit-jupiter at test scope so common can compile JUnit tests that live in common/src/test/java. Surefire stays skipped here to avoid running each JUnit test twice (javase/pom.xml mounts the same sources via testSourceDirectory). * maven/cn1app-archetype/.../javase/pom.xml: add junit-jupiter at test scope. Surefire runs from this module and now actually has the JUnit Jupiter engine on its classpath. The codenameone-javase artifact's "provided" scope for junit-jupiter still keeps it out of user app classpaths that do not opt in. * scripts/initializr/.../skill/references/junit-testing.md: new skill reference covering the JUnit 5 path end-to-end -- when to use it vs AbstractTest, dependency setup, every annotation with a worked example, EDT dispatch semantics, the headless behavior and why CodenameOneExtensionTest is gated for it, coexistence with cn1:test, side-by-side example. * scripts/initializr/.../skill/SKILL.md: index the new reference and expand the Testing section so the choice between AbstractTest and @CodenameOneTest is the first thing a contributor sees. * scripts/initializr/.../skill/references/testing-and-screenshots.md: add a "Two ways to write tests" preamble that points at the new junit-testing.md so users find the JUnit option from either entry. * docs/developer-guide/Testing-with-JUnit.adoc: long-form chapter for the manual covering the same material as the skill reference but in AsciiDoc, with a comparison table, dependency snippets, annotation reference, headless explanation, and side-by-side worked example. Wired into developer-guide.asciidoc next to the performance chapter so it sits near the existing screenshot testing material. Verified by mvn install of the archetype, archetype:generate of a new app from it, and javac-compiling a sample @CodenameOneTest against the locally-installed codenameone-javase 8.0-SNAPSHOT + junit-jupiter-api 5.9.3 artifacts. JavaSE port suite remains at 63 passing tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/developer-guide/Testing-with-JUnit.adoc | 334 ++++++++++++++++++ docs/developer-guide/developer-guide.asciidoc | 2 + .../archetype-resources/common/pom.xml | 34 ++ .../archetype-resources/javase/pom.xml | 17 + .../common/src/main/resources/skill/SKILL.md | 31 +- .../skill/references/junit-testing.md | 296 ++++++++++++++++ .../references/testing-and-screenshots.md | 16 +- 7 files changed, 722 insertions(+), 8 deletions(-) create mode 100644 docs/developer-guide/Testing-with-JUnit.adoc create mode 100644 scripts/initializr/common/src/main/resources/skill/references/junit-testing.md diff --git a/docs/developer-guide/Testing-with-JUnit.adoc b/docs/developer-guide/Testing-with-JUnit.adoc new file mode 100644 index 0000000000..e57470f21b --- /dev/null +++ b/docs/developer-guide/Testing-with-JUnit.adoc @@ -0,0 +1,334 @@ +== Testing with JUnit 5 + +Codename One has historically shipped its own test framework — `com.codename1.testing.AbstractTest` driven by the `cn1:test` Maven goal. That framework is still fully supported and remains the only way to run tests on a physical iOS or Android device. Starting with Codename One 8, 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. + +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 vs. 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`). Legacy code that already extends `AbstractTest`. 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 doesn't exist 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 freely. `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 (releases ≥ 8.0) generates a `common/pom.xml` and `javase/pom.xml` that already pull in `junit-jupiter` and `codenameone-javase` at test scope. If you are upgrading an older project, 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 partially initialized — so subsequent test classes in the same JVM are not 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 -- e.g. `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 `.res` resource and installs its first theme through `UIManager.setThemeProps`, then triggers a refresh. + +[source,java] +---- +@Test @Theme("/iOSModernTheme.res") void rendersUnderIos() { /* ... */ } +@Test @Theme("/AndroidMaterialTheme.res") void rendersUnderAndroid() { /* ... */ } +@Test @Theme("/iPhoneTheme.res") void rendersUnderLegacyIos() { /* ... */ } +---- + +The simulator jar bundles `iOSModernTheme.res`, `AndroidMaterialTheme.res`, `iPhoneTheme.res`, `iOS7Theme.res`, `androidTheme.res`, and `android_holo_light.res`. App themes work too — ship the `.res` under `src/main/resources` or `src/test/resources` and reference it with a leading slash. + +=== `@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 auto-aborts via `TestAbortedException` from `@BeforeAll`. JUnit reports the class as *skipped*, not *failed*, and crucially the Display singleton is never partially initialized -- so subsequent test classes in the same JVM are not poisoned. + +This is why our own internal `CodenameOneExtensionTest` (in the Codename One sources) carries `@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true")`. The extension catches auto-detected headless (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 legacy 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/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/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 753a13ccad..775100446d 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` (legacy runner) 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 legacy 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..2229ec1e9e --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md @@ -0,0 +1,296 @@ +# 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`), legacy code that already extends `AbstractTest`, or 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 | Tests that run only in the simulator JVM. 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 (releases ≥ 8.0) generates a `common/pom.xml` and `javase/pom.xml` that already pull in `junit-jupiter` and `codenameone-javase` at test scope. If you are upgrading an older project, 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 `.res` resource and installs its first theme via `UIManager.setThemeProps`, then triggers a refresh. The native themes bundled into the simulator jar can be used directly: + +```java +@Test @Theme("/iOSModernTheme.res") void looksRightOnIos() { /* ... */ } +@Test @Theme("/AndroidMaterialTheme.res") void looksRightOnAndroid() { /* ... */ } +@Test @Theme("/iPhoneTheme.res") void looksRightOnLegacyIos() { /* ... */ } +``` + +For app themes, ship the `.res` under `src/main/resources` or `src/test/resources` and reference it with a leading slash. + +### `@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..f06c8186b4 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 **legacy `AbstractTest` framework**, which runs through `cn1:test`. If you want to write standard JUnit 5 tests that integrate with Surefire and your IDE's green-bar runner, read `references/junit-testing.md` instead — both styles coexist in the same project and you pick per test class. + +When to stay on `AbstractTest` (this doc): + +- The test must also run on a device via `mvn cn1:test -Dtarget=ios`. JUnit Jupiter doesn't exist on ParparVM. +- The test compiles under the strict device subset (no reflection, no `java.nio.file.*`, no `java.net.http.*`). +- You're maintaining tests that already extend `AbstractTest`. + +When to switch to JUnit (`references/junit-testing.md`): + +- The test runs only in the simulator JVM and you 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 From 0404128eeb7f8c09e9f6fd40a3e1fb06ff4ba4f3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 07:52:35 +0300 Subject: [PATCH 6/8] Fix Vale prose-lint hits in Testing-with-JUnit.adoc The dev-guide CI runs Vale with the Microsoft style and fails the build on any error-level alert. The new chapter tripped four errors (Contractions, Foreign 'e.g.', two hyphenated 'auto-...') and five warnings (HeadingPunctuation, Adverbs, We). Reworded: * "vs." -> "versus" in the section heading. * Dropped the "freely" / "partially" / "auto-" adverbs and rewrote the sentences without them. * "e.g." -> "for example". * "our own internal" -> "the framework's internal" to avoid the first-person plural. Vale now reports 0 errors, 0 warnings, 0 suggestions for this file. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/developer-guide/Testing-with-JUnit.adoc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/developer-guide/Testing-with-JUnit.adoc b/docs/developer-guide/Testing-with-JUnit.adoc index e57470f21b..1ca5d0d202 100644 --- a/docs/developer-guide/Testing-with-JUnit.adoc +++ b/docs/developer-guide/Testing-with-JUnit.adoc @@ -4,7 +4,7 @@ Codename One has historically shipped its own test framework — `com.codename1. 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 vs. AbstractTest +=== When to use JUnit versus AbstractTest [cols="1,3,3", options="header"] |=== @@ -19,7 +19,7 @@ Both styles coexist in the same project under `common/src/test/java`. You pick p |Anything that must also run on a device — JUnit Jupiter doesn't exist 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 freely. `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. +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 @@ -97,7 +97,7 @@ mvn -pl javase test -Dtest=GreetingFormTest#formShowsExpectedTitle # one metho 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 partially initialized — so subsequent test classes in the same JVM are not poisoned. This makes JUnit-style Codename One tests safe to run on a headless CI runner; they simply skip. +. 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. @@ -157,7 +157,7 @@ The `scope` field selects where the value lands: |`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 since Display is already up by the time `@BeforeEach` runs. +|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. @@ -263,9 +263,9 @@ Without `@RunOnEdt`, the test body runs on the Surefire worker thread — fine f * *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 auto-aborts via `TestAbortedException` from `@BeforeAll`. JUnit reports the class as *skipped*, not *failed*, and crucially the Display singleton is never partially initialized -- so subsequent test classes in the same JVM are not poisoned. +* *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 our own internal `CodenameOneExtensionTest` (in the Codename One sources) carries `@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true")`. The extension catches auto-detected headless (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. +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. From 12ccd941d84ef659e26e0f6726d022a6447c1d53 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 10:07:55 +0300 Subject: [PATCH 7/8] Add JUnit-chapter terms to LanguageTool accept list The dev-guide aggregate quality gate fails when LANGUAGETOOL_COUNT != 0 (not just when LANGUAGETOOL_STATUS != 0). The Testing-with-JUnit chapter introduced four spelling-rule matches that LanguageTool's English dictionary doesn't carry: * rethrown -- past tense of "rethrow" (already accepted) and "rethrows" (already accepted). Adding the third form for completeness. * Throwable / Throwables -- java.lang.Throwable and its plural, used when describing the EDT dispatch semantics. * javase -- the lowercase Maven module name. * Xvfb -- X virtual framebuffer, named in the headless-runner section. These are all legitimate technical terms used throughout the chapter, so they belong in the accept list rather than reworded. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/developer-guide/languagetool-accept.txt | 5 +++++ 1 file changed, 5 insertions(+) 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 From 3586ca344f14b83112bdefd67efb67f32f2ba7b8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 17:20:30 +0300 Subject: [PATCH 8/8] Add NativeTheme enum to @Theme; reframe AbstractTest as a peer The @Theme annotation now accepts a native-theme alias in addition to a resource path. The new NativeTheme enum mirrors the simulator's Simulate > Native Theme menu (iOS Modern / iOS Flat / iPhone Pre-Flat / Android Material / Android Holo Light / Android Legacy) and carries both the .res resource path and the human-readable label that menu displays. The extension prefers the enum form when both are set; a non-empty value() is used only when nativeTheme is left at NONE. @Test @Theme(nativeTheme = NativeTheme.IOS_MODERN) ... @Test @Theme(nativeTheme = NativeTheme.ANDROID_MATERIAL) ... @Test @Theme("/MyAppTheme.res") ... Docs and skill files updated to lead with the enum form, since it is the recommended path for cross-platform look-and-feel coverage; the resource-path form remains for app themes shipped under src/main/resources. Same commit removes the "legacy" framing from the AbstractTest / cn1:test path across all docs (dev-guide chapter, junit-testing.md skill, testing-and-screenshots.md skill, SKILL.md), and drops the version-anchored language ("Starting with Codename One 8", "releases >= 8.0") that I had guessed wrong. AbstractTest is the only framework that runs on a device via ParparVM and isn't going anywhere -- the two frameworks are peers and the docs now describe them by their tradeoffs (device vs. simulator-only, device subset vs. full JVM) rather than by recency. 64 javase tests pass (was 63 + a new NativeTheme test that asserts the enum's resourcePath() and displayName() carry the right values). Vale prose-lint clean on the updated chapter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../testing/junit/CodenameOneExtension.java | 24 ++++- .../codename1/testing/junit/NativeTheme.java | 89 +++++++++++++++++++ .../com/codename1/testing/junit/Theme.java | 48 ++++++---- docs/developer-guide/Testing-with-JUnit.adoc | 34 ++++--- .../junit/CodenameOneExtensionTest.java | 27 ++++-- .../common/src/main/resources/skill/SKILL.md | 4 +- .../skill/references/junit-testing.md | 29 ++++-- .../references/testing-and-screenshots.md | 12 +-- 8 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java index 27ca4c1952..1185ab0b52 100644 --- a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java +++ b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java @@ -414,13 +414,35 @@ static ResolvedVisualConfig resolve(Class testClass, Method method) { Orientation o = pickOrientation(testClass, method); RTL rtl = pickRtl(testClass, method); return new ResolvedVisualConfig( - theme == null ? null : theme.value(), + 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); 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/Theme.java b/Ports/JavaSE/src/com/codename1/testing/junit/Theme.java index 309ab84c01..5b3ec75a4b 100644 --- a/Ports/JavaSE/src/com/codename1/testing/junit/Theme.java +++ b/Ports/JavaSE/src/com/codename1/testing/junit/Theme.java @@ -28,24 +28,34 @@ import java.lang.annotation.Target; /** - * Applies a base theme resource before the test runs — equivalent to - * the simulator's "Native Theme" menu, but scoped to a single test (or to a + * 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). * - *

The {@link #value()} is the classpath name of a {@code .res} file - * containing a theme. The native themes bundled into the JavaSE simulator - * jar can be used directly: + *

Pick the theme one of two ways: * - *

- * @Test @Theme("/iOSModernTheme.res")   void looksRightOnIos()       { ... }
- * @Test @Theme("/AndroidMaterialTheme.res") void looksRightOnAndroid() { ... }
- * @Test @Theme("/iPhoneTheme.res")     void looksRightOnLegacyIos() { ... }
- * 
+ * * - *

Custom app themes work too — ship the {@code .res} file under - * {@code src/main/resources} or {@code src/test/resources} and reference it - * with a leading slash. The theme is loaded via - * {@code Resources.open(value)} and the first theme inside the resource is + *

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. */ @@ -54,6 +64,14 @@ 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(); + 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/docs/developer-guide/Testing-with-JUnit.adoc b/docs/developer-guide/Testing-with-JUnit.adoc index 1ca5d0d202..633922ede3 100644 --- a/docs/developer-guide/Testing-with-JUnit.adoc +++ b/docs/developer-guide/Testing-with-JUnit.adoc @@ -1,6 +1,6 @@ == Testing with JUnit 5 -Codename One has historically shipped its own test framework — `com.codename1.testing.AbstractTest` driven by the `cn1:test` Maven goal. That framework is still fully supported and remains the only way to run tests on a physical iOS or Android device. Starting with Codename One 8, 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. +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. @@ -11,19 +11,19 @@ Both styles coexist in the same project under `common/src/test/java`. You pick p |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`). Legacy code that already extends `AbstractTest`. Tests that must compile under the strict Codename One device subset (no reflection, no `java.net.http.*`, no `java.nio.file.*`). +|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 doesn't exist on ParparVM. +|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 (releases ≥ 8.0) generates a `common/pom.xml` and `javase/pom.xml` that already pull in `junit-jupiter` and `codenameone-javase` at test scope. If you are upgrading an older project, add these dependency blocks yourself: +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'] ---- @@ -164,16 +164,30 @@ The JavaSE port compiles at source 1.7, which predates `@Repeatable`. To set mor === `@Theme` -Loads a `.res` resource and installs its first theme through `UIManager.setThemeProps`, then triggers a refresh. +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("/iOSModernTheme.res") void rendersUnderIos() { /* ... */ } -@Test @Theme("/AndroidMaterialTheme.res") void rendersUnderAndroid() { /* ... */ } -@Test @Theme("/iPhoneTheme.res") void rendersUnderLegacyIos() { /* ... */ } +@Test @Theme("/MyAppTheme.res") void rendersAppTheme() { /* ... */ } ---- -The simulator jar bundles `iOSModernTheme.res`, `AndroidMaterialTheme.res`, `iPhoneTheme.res`, `iOS7Theme.res`, `androidTheme.res`, and `android_holo_light.res`. App themes work too — ship the `.res` under `src/main/resources` or `src/test/resources` and reference it with a leading slash. +If both `nativeTheme` and `value` are set, `nativeTheme` wins. If neither is set, the annotation is a no-op. === `@DarkMode` @@ -330,5 +344,5 @@ The UI driving (`TestUtils.*`) is identical -- `TestUtils` is independent of the === Cross-reference -* For the legacy framework's full API surface (`TestUtils` helpers, `screenshotTest` tolerance algorithm, baseline management), see <> in the performance chapter. +* 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/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java b/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java index 983b8f291a..c14a71377a 100644 --- a/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java +++ b/maven/javase/src/test/java/com/codename1/testing/junit/CodenameOneExtensionTest.java @@ -164,12 +164,29 @@ public void rtlExplicitlyDisabled() { @Test @Theme("/iOSModernTheme.res") - public void themeLoaded() { - // Verifies the annotation 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. + 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 775100446d..d6f730b088 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -226,13 +226,13 @@ class GreetingFormTest { } ``` -Run with `mvn -pl common cn1:test` (legacy runner) or `mvn test` (both runners). The cn1app archetype already wires up Surefire + JUnit Jupiter in the generated POMs. +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 legacy runner needs you to skip with `-DskipTests` or run under `xvfb-run`. +> 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 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 index 2229ec1e9e..2c07947cac 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md +++ b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md @@ -4,8 +4,8 @@ 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`), legacy code that already extends `AbstractTest`, or 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 | Tests that run only in the simulator JVM. 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. | +| `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). @@ -13,7 +13,7 @@ See `references/testing-and-screenshots.md` for the AbstractTest path, including ## Project setup -The cn1app archetype (releases ≥ 8.0) generates a `common/pom.xml` and `javase/pom.xml` that already pull in `junit-jupiter` and `codenameone-javase` at test scope. If you are upgrading an older project, add these two blocks yourself: +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 @@ -137,15 +137,28 @@ The `scope` field controls where it lands: ### `@Theme` -Loads a `.res` resource and installs its first theme via `UIManager.setThemeProps`, then triggers a refresh. The native themes bundled into the simulator jar can be used directly: +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("/iOSModernTheme.res") void looksRightOnIos() { /* ... */ } -@Test @Theme("/AndroidMaterialTheme.res") void looksRightOnAndroid() { /* ... */ } -@Test @Theme("/iPhoneTheme.res") void looksRightOnLegacyIos() { /* ... */ } +@Test @Theme("/MyAppTheme.res") void looksRightWithAppTheme() { /* ... */ } ``` -For app themes, ship the `.res` under `src/main/resources` or `src/test/resources` and reference it with a leading slash. +If both are set, `nativeTheme` wins. If neither is set, the annotation is a no-op. ### `@DarkMode` 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 f06c8186b4..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,16 +1,16 @@ # Testing and Screenshots Reference -This document covers Codename One's **legacy `AbstractTest` framework**, which runs through `cn1:test`. If you want to write standard JUnit 5 tests that integrate with Surefire and your IDE's green-bar runner, read `references/junit-testing.md` instead — both styles coexist in the same project and you pick per test class. +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 stay on `AbstractTest` (this doc): +When to use `AbstractTest` (this doc): -- The test must also run on a device via `mvn cn1:test -Dtarget=ios`. JUnit Jupiter doesn't exist on ParparVM. +- 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're maintaining tests that already extend `AbstractTest`. +- You already have a body of `AbstractTest` tests and want to keep adding peers in the same style. -When to switch to JUnit (`references/junit-testing.md`): +When to use JUnit instead (`references/junit-testing.md`): -- The test runs only in the simulator JVM and you want reflection, Mockito, AssertJ, `assertThrows`, parameterized tests, `-Dtest=Foo#bar` filtering, IDE-native test discovery. +- 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.