diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index 09538b45f2..6f19f272ee 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -19,6 +19,8 @@ name: Test Android build scripts - '!CodenameOne/src/**/*.md' - 'Ports/Android/**' - '!Ports/Android/**/*.md' + - 'maven/**' + - '!maven/core-unittests/**' - 'tests/**' - '!tests/**/*.md' - '!docs/**' @@ -41,6 +43,8 @@ name: Test Android build scripts - '!CodenameOne/src/**/*.md' - 'Ports/Android/**' - '!Ports/Android/**/*.md' + - 'maven/**' + - '!maven/core-unittests/**' - 'tests/**' - '!tests/**/*.md' - '!docs/**' diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/AutoCompleteTextComponentTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/AutoCompleteTextComponentTest.java new file mode 100644 index 0000000000..1baffb7c52 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/AutoCompleteTextComponentTest.java @@ -0,0 +1,105 @@ +package com.codename1.ui; + +import com.codename1.test.UITestBase; +import com.codename1.ui.list.DefaultListModel; +import com.codename1.ui.list.ListModel; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class AutoCompleteTextComponentTest extends UITestBase { + + private List filtered; + private AutoCompleteTextComponent component; + + @BeforeEach + void createComponent() { + ListModel model = new DefaultListModel("one", "two", "three"); + filtered = new ArrayList(); + component = new AutoCompleteTextComponent(model, new AutoCompleteTextComponent.AutoCompleteFilter() { + @Override + public boolean filter(String text) { + filtered.add(text); + return text != null && text.length() > 0; + } + }); + } + + @Test + void customFilterReceivesText() throws Exception { + AutoCompleteTextField field = component.getAutoCompleteField(); + Method filterMethod = AutoCompleteTextField.class.getDeclaredMethod("filter", String.class); + filterMethod.setAccessible(true); + Boolean result = (Boolean) filterMethod.invoke(field, "hello"); + assertTrue(result.booleanValue()); + assertEquals(Arrays.asList("hello"), filtered); + } + + @Test + void propertyMetadataMatchesComponentContract() { + assertArrayEquals(new String[]{"text", "label", "hint", "multiline", "columns", "rows", "constraint"}, component.getPropertyNames()); + assertArrayEquals(new Class[]{String.class, String.class, String.class, Boolean.class, Integer.class, Integer.class, Integer.class}, component.getPropertyTypes()); + assertArrayEquals(new String[]{"String", "String", "String", "Boolean", "Integer", "Integer", "Integer"}, component.getPropertyTypeNames()); + } + + @Test + void settersUpdateUnderlyingField() { + component.text("value").label("Label").hint("Hint").multiline(true).columns(5).rows(3).constraint(TextArea.EMAILADDR); + AutoCompleteTextField field = component.getAutoCompleteField(); + assertEquals("value", field.getText()); + assertEquals("Label", component.getLabel().getText()); + assertEquals("Hint", field.getHint()); + assertFalse(field.isSingleLineTextArea()); + assertEquals(5, field.getColumns()); + assertEquals(3, field.getRows()); + assertEquals(TextArea.EMAILADDR, field.getConstraint()); + } + + @Test + void setPropertyValueDelegatesToField() { + component.setPropertyValue("text", "abc"); + component.setPropertyValue("hint", "def"); + component.setPropertyValue("multiline", Boolean.TRUE); + component.setPropertyValue("columns", Integer.valueOf(7)); + component.setPropertyValue("rows", Integer.valueOf(4)); + component.setPropertyValue("constraint", Integer.valueOf(TextArea.NUMERIC)); + + AutoCompleteTextField field = component.getAutoCompleteField(); + assertEquals("abc", component.getText()); + assertEquals("def", field.getHint()); + assertFalse(field.isSingleLineTextArea()); + assertEquals(7, field.getColumns()); + assertEquals(4, field.getRows()); + assertEquals(TextArea.NUMERIC, field.getConstraint()); + + assertEquals("abc", component.getPropertyValue("text")); + assertEquals("def", component.getPropertyValue("hint")); + assertEquals(Boolean.TRUE, component.getPropertyValue("multiline")); + assertEquals(Integer.valueOf(7), component.getPropertyValue("columns")); + assertEquals(Integer.valueOf(4), component.getPropertyValue("rows")); + assertEquals(Integer.valueOf(TextArea.NUMERIC), component.getPropertyValue("constraint")); + } + + @Test + void constructUICreatesAnimationLayerWhenFocusAnimationEnabled() throws Exception { + component.onTopMode(true).focusAnimation(true).label("Animated"); + component.constructUI(); + + assertEquals(2, component.getComponentCount()); + assertEquals("Animated", component.getLabel().getText()); + assertFalse(component.getLabel().isVisible()); + assertEquals("Animated", component.getField().getHint()); + + java.lang.reflect.Field animationLayerField = AutoCompleteTextComponent.class.getDeclaredField("animationLayer"); + animationLayerField.setAccessible(true); + Object animationLayer = animationLayerField.get(component); + assertNotNull(animationLayer); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/BrowserComponentTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/BrowserComponentTest.java new file mode 100644 index 0000000000..957f21c68e --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/BrowserComponentTest.java @@ -0,0 +1,318 @@ +package com.codename1.ui; + +import com.codename1.test.UITestBase; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.BrowserNavigationCallback; +import com.codename1.ui.plaf.Style; +import com.codename1.util.AsyncResource; +import com.codename1.util.Callback; +import com.codename1.util.SuccessCallback; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URLEncoder; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BrowserComponentTest extends UITestBase { + + private PeerComponent peer; + + @BeforeEach + void preparePeer() { + peer = mock(PeerComponent.class); + when(peer.getUnselectedStyle()).thenReturn(new Style()); + when(peer.getStyle()).thenReturn(new Style()); + when(peer.toImage()).thenReturn(Image.createImage(1, 1)); + when(implementation.createBrowserComponent(any(BrowserComponent.class))).thenReturn(peer); + } + + @Test + void constructorReplacesPlaceholderWithPeerComponent() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + assertEquals(1, browser.getComponentCount()); + assertSame(peer, browser.getComponentAt(0)); + } + + @Test + void addWebEventListenerFiresOnEvent() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + final AtomicInteger counter = new AtomicInteger(); + browser.addWebEventListener(BrowserComponent.onStart, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + counter.incrementAndGet(); + } + }); + browser.fireWebEvent(BrowserComponent.onStart, new ActionEvent("start")); + assertEquals(1, counter.get()); + } + + @Test + void readyCallbackInvokedWhenStartEventFired() { + BrowserComponent browser = new BrowserComponent(); + final AtomicBoolean invoked = new AtomicBoolean(); + browser.ready(new SuccessCallback() { + @Override + public void onSucess(BrowserComponent value) { + invoked.set(true); + assertSame(browser, value); + } + }); + browser.fireWebEvent(BrowserComponent.onStart, new ActionEvent("start")); + assertTrue(invoked.get()); + } + + @Test + void readyPromiseCompletesAfterStartEvent() { + BrowserComponent browser = new BrowserComponent(); + AsyncResource promise = browser.ready(0); + assertFalse(promise.isDone()); + browser.fireWebEvent(BrowserComponent.onStart, new ActionEvent("start")); + assertTrue(promise.isDone()); + assertSame(browser, promise.get()); + } + + @Test + void executeWithParametersInjectsValues() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + browser.execute("window.call(${0}, ${1});", new Object[]{"value", Integer.valueOf(3)}); + ArgumentCaptor script = ArgumentCaptor.forClass(String.class); + verify(implementation).browserExecute(any(PeerComponent.class), script.capture()); + String js = script.getValue(); + assertTrue(js.contains("\"value\"")); + assertTrue(js.contains("3")); + } + + @Test + void injectParametersSupportsProxiesAndReferences() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + BrowserComponent.JSProxy proxy = browser.createJSProxy("window"); + BrowserComponent.JSRef ref = new BrowserComponent.JSRef("5", "number"); + String expression = BrowserComponent.injectParameters("call(${0}, ${1}, ${2})", "text", proxy, ref); + assertEquals("call(\"text\", window, 5)", expression); + } + + @Test + void fireBrowserNavigationCallbacksReturnsFalseWhenCallbackRejects() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + browser.addBrowserNavigationCallback(new BrowserNavigationCallback() { + @Override + public boolean shouldNavigate(String url) { + return !url.contains("blocked"); + } + }); + assertFalse(browser.fireBrowserNavigationCallbacks("https://example.com/blocked")); + assertTrue(browser.fireBrowserNavigationCallbacks("https://example.com/allowed")); + } + + @Test + void fireBrowserNavigationCallbacksDeliversReturnValuesOnEdt() throws Exception { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + final AtomicReference callbackValue = new AtomicReference(); + SuccessCallback callback = new SuccessCallback() { + @Override + public void onSucess(BrowserComponent.JSRef value) { + callbackValue.set(value); + } + }; + int id = registerReturnValueCallback(browser, callback); + String payload = "{\"callbackId\":" + id + ",\"value\":\"42\",\"type\":\"number\"}"; + String encoded = URLEncoder.encode(payload, "UTF-8"); + boolean result = browser.fireBrowserNavigationCallbacks("https://example.com/!cn1return/" + encoded); + assertFalse(result); + flushSerialCalls(); + BrowserComponent.JSRef value = callbackValue.get(); + assertNotNull(value); + assertEquals("42", value.getValue()); + assertEquals(BrowserComponent.JSType.NUMBER, value.getJSType()); + } + + @Test + void fireBrowserNavigationCallbacksRunsSynchronouslyWhenNotOnEdt() throws Exception { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + browser.setFireCallbacksOnEdt(false); + final AtomicReference callbackValue = new AtomicReference(); + SuccessCallback callback = new SuccessCallback() { + @Override + public void onSucess(BrowserComponent.JSRef value) { + callbackValue.set(value); + } + }; + int id = registerReturnValueCallback(browser, callback); + String payload = "{\"callbackId\":" + id + ",\"value\":\"true\",\"type\":\"boolean\"}"; + String encoded = URLEncoder.encode(payload, "UTF-8"); + boolean result = browser.fireBrowserNavigationCallbacks("https://example.com/!cn1return/" + encoded); + assertFalse(result); + assertNotNull(callbackValue.get()); + assertTrue(callbackValue.get().getBoolean()); + } + + @Test + void captureScreenshotUsesImplementationResult() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + AsyncResource resource = new AsyncResource(); + resource.complete(Image.createImage(5, 5)); + when(implementation.captureBrowserScreenshot(peer)).thenReturn(resource); + AsyncResource result = browser.captureScreenshot(); + assertSame(resource, result); + } + + @Test + void createDataUriEncodesBytes() { + byte[] data = new byte[]{0x01, 0x02, 0x03}; + String uri = BrowserComponent.createDataURI(data, "image/png"); + assertTrue(uri.startsWith("data:image/png;base64,")); + assertTrue(uri.length() > "data:image/png;base64,".length()); + } + + @Test + void setPropertyQueuedUntilPeerReady() { + BrowserComponent browser = new BrowserComponent(); + browser.setProperty("foo", "bar"); + verify(implementation, never()).setBrowserProperty(any(PeerComponent.class), anyString(), any()); + flushSerialCalls(); + verify(implementation).setBrowserProperty(peer, "foo", "bar"); + } + + @Test + void putClientPropertyPropagatesToPeer() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + browser.putClientProperty("HTML5Peer.removeOnDeinitialize", Boolean.TRUE); + verify(peer).putClientProperty("HTML5Peer.removeOnDeinitialize", Boolean.TRUE); + } + + @Test + void setDebugModeTogglesClientProperties() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + browser.setDebugMode(true); + assertTrue(browser.isDebugMode()); + browser.setDebugMode(false); + assertFalse(browser.isDebugMode()); + } + + @Test + void captureScreenshotFallsBackToComponentImage() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + when(implementation.captureBrowserScreenshot(peer)).thenReturn(null); + AsyncResource resource = browser.captureScreenshot(); + assertNotNull(resource.get()); + } + + @Test + void executeWithCallbackBuildsJavascriptWrapper() { + BrowserComponent browser = new BrowserComponent(); + flushSerialCalls(); + doNothing().when(implementation).browserExecute(any(PeerComponent.class), anyString()); + SuccessCallback callback = new Callback() { + @Override + public void onSucess(BrowserComponent.JSRef value) { + } + + @Override + public void onError(Object context, Throwable err, int errorCode, String message) { + } + }; + browser.execute("callback.onSuccess('done')", callback); + ArgumentCaptor script = ArgumentCaptor.forClass(String.class); + verify(implementation).browserExecute(any(PeerComponent.class), script.capture()); + assertTrue(script.getValue().contains("callbackId")); + } + + @Test + void setUrlDelegatesOncePeerReady() { + BrowserComponent browser = new BrowserComponent(); + browser.setURL("https://codenameone.com"); + flushSerialCalls(); + verify(implementation).setBrowserURL(peer, "https://codenameone.com"); + } + + @Test + void getUrlReturnsCachedValueWhenPeerMissing() { + BrowserComponent browser = new BrowserComponent(); + browser.setURL("https://codenameone.com"); + assertEquals("https://codenameone.com", browser.getURL()); + } + + private int registerReturnValueCallback(BrowserComponent browser, SuccessCallback callback) throws Exception { + Method m = BrowserComponent.class.getDeclaredMethod("addReturnValueCallback", SuccessCallback.class); + m.setAccessible(true); + Integer id = (Integer) m.invoke(browser, callback); + return id.intValue(); + } + + private void flushSerialCalls() { + try { + Display display = Display.getInstance(); + Field pendingField = Display.class.getDeclaredField("pendingSerialCalls"); + pendingField.setAccessible(true); + @SuppressWarnings("unchecked") + List pending = (List) pendingField.get(display); + + Field runningField = Display.class.getDeclaredField("runningSerialCallsQueue"); + runningField.setAccessible(true); + @SuppressWarnings("unchecked") + Deque running = (Deque) runningField.get(display); + + if ((pending == null || pending.isEmpty()) && (running == null || running.isEmpty())) { + return; + } + + Deque workQueue = new ArrayDeque(); + if (running != null && !running.isEmpty()) { + workQueue.addAll(running); + running.clear(); + } + if (pending != null && !pending.isEmpty()) { + workQueue.addAll(new ArrayList(pending)); + pending.clear(); + } + + while (!workQueue.isEmpty()) { + Runnable job = workQueue.removeFirst(); + job.run(); + + if (running != null && !running.isEmpty()) { + workQueue.addAll(running); + running.clear(); + } + if (pending != null && !pending.isEmpty()) { + workQueue.addAll(new ArrayList(pending)); + pending.clear(); + } + } + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to drain Display serial calls", e); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/BrowserWindowTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/BrowserWindowTest.java new file mode 100644 index 0000000000..0ee6022887 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/BrowserWindowTest.java @@ -0,0 +1,170 @@ +package com.codename1.ui; + +import com.codename1.test.UITestBase; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.plaf.Style; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BrowserWindowTest extends UITestBase { + + @Test + void nativeWindowDelegatesToImplementation() { + Object nativeWindow = new Object(); + when(implementation.createNativeBrowserWindow(any(String.class))).thenReturn(nativeWindow); + BrowserWindow window = new BrowserWindow("http://start"); + verify(implementation).createNativeBrowserWindow("http://start"); + + ActionListener loadListener = mock(ActionListener.class); + window.addLoadListener(loadListener); + verify(implementation).addNativeBrowserWindowOnLoadListener(nativeWindow, loadListener); + window.removeLoadListener(loadListener); + verify(implementation).removeNativeBrowserWindowOnLoadListener(nativeWindow, loadListener); + + window.setTitle("Docs"); + verify(implementation).nativeBrowserWindowSetTitle(nativeWindow, "Docs"); + window.setSize(320, 480); + verify(implementation).nativeBrowserWindowSetSize(nativeWindow, 320, 480); + + ActionListener closeListener = mock(ActionListener.class); + window.addCloseListener(closeListener); + verify(implementation).nativeBrowserWindowAddCloseListener(nativeWindow, closeListener); + window.removeCloseListener(closeListener); + verify(implementation).nativeBrowserWindowRemoveCloseListener(nativeWindow, closeListener); + + window.show(); + verify(implementation).nativeBrowserWindowShow(nativeWindow); + window.close(); + verify(implementation).nativeBrowserWindowHide(nativeWindow); + verify(implementation).nativeBrowserWindowCleanup(nativeWindow); + } + + @Test + void fallbackModeUsesFormAndBrowserComponent() throws Exception { + PeerComponent peer = mock(PeerComponent.class); + when(peer.getUnselectedStyle()).thenReturn(new Style()); + when(peer.getStyle()).thenReturn(new Style()); + when(peer.toImage()).thenReturn(Image.createImage(1, 1)); + when(implementation.createNativeBrowserWindow(any(String.class))).thenReturn(null); + when(implementation.createBrowserComponent(any(BrowserComponent.class))).thenReturn(peer); + when(implementation.installMessageListener(peer)).thenReturn(true); + + final AtomicInteger backCalls = new AtomicInteger(); + Form previous = new Form() { + @Override + public void showBack() { + backCalls.incrementAndGet(); + } + }; + when(implementation.getCurrentForm()).thenReturn(previous); + doNothing().when(implementation).onShow(any(Form.class)); + + BrowserWindow window = new BrowserWindow("http://start"); + flushSerialCalls(); + + Field webviewField = BrowserWindow.class.getDeclaredField("webview"); + webviewField.setAccessible(true); + BrowserComponent webview = (BrowserComponent) webviewField.get(window); + assertNotNull(webview); + assertEquals("http://start", webview.getURL()); + + final AtomicInteger loadEvents = new AtomicInteger(); + ActionListener listener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + loadEvents.incrementAndGet(); + } + }; + window.addLoadListener(listener); + webview.fireWebEvent("onLoad", new ActionEvent("http://start")); + assertEquals(1, loadEvents.get()); + window.removeLoadListener(listener); + webview.fireWebEvent("onLoad", new ActionEvent("http://start")); + assertEquals(1, loadEvents.get()); + + Field formField = BrowserWindow.class.getDeclaredField("form"); + formField.setAccessible(true); + Form form = (Form) formField.get(window); + assertNotNull(form); + window.setTitle("Browser"); + assertEquals("Browser", form.getTitle()); + + final AtomicInteger closeEvents = new AtomicInteger(); + window.addCloseListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + closeEvents.incrementAndGet(); + } + }); + window.close(); + assertEquals(1, closeEvents.get()); + assertEquals(1, backCalls.get()); + } + + @Test + void evalRequestStoresJavascript() { + BrowserWindow.EvalRequest request = new BrowserWindow.EvalRequest(); + request.setJS("let x = 1;"); + assertEquals("let x = 1;", request.getJS()); + } + + private void flushSerialCalls() { + try { + Display display = Display.getInstance(); + Field pendingField = Display.class.getDeclaredField("pendingSerialCalls"); + pendingField.setAccessible(true); + @SuppressWarnings("unchecked") + List pending = (List) pendingField.get(display); + + Field runningField = Display.class.getDeclaredField("runningSerialCallsQueue"); + runningField.setAccessible(true); + @SuppressWarnings("unchecked") + Deque running = (Deque) runningField.get(display); + + if ((pending == null || pending.isEmpty()) && (running == null || running.isEmpty())) { + return; + } + + Deque workQueue = new ArrayDeque(); + if (running != null && !running.isEmpty()) { + workQueue.addAll(running); + running.clear(); + } + if (pending != null && !pending.isEmpty()) { + workQueue.addAll(new ArrayList(pending)); + pending.clear(); + } + + while (!workQueue.isEmpty()) { + Runnable job = workQueue.removeFirst(); + job.run(); + + if (running != null && !running.isEmpty()) { + workQueue.addAll(running); + running.clear(); + } + if (pending != null && !pending.isEmpty()) { + workQueue.addAll(new ArrayList(pending)); + pending.clear(); + } + } + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to drain Display serial calls", e); + } + } +}