diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index cb1fd62212..15fffffa4f 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -51,7 +51,7 @@ jobs: mkdir -p "$HOME/.codenameone" touch "$HOME/.codenameone/guibuilder.jar" cp maven/CodeNameOneBuildClient.jar "$HOME/.codenameone/CodeNameOneBuildClient.jar" - xvfb-run -a mvn -B -ntp -Dgenerate-gui-sources-done=true -pl common -am -f docs/demos/pom.xml install + xvfb-run -a mvn -B -ntp -Dgenerate-gui-sources-done=true -am -f docs/demos/pom.xml install - name: Install ImageMagick for screenshot comparison if: github.event_name != 'pull_request' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true' diff --git a/docs/demos/common/src/test/java/com/codenameone/developerguide/animations/AnimationDemosScreenshotTest.java b/docs/demos/common/src/test/java/com/codenameone/developerguide/animations/AnimationDemosScreenshotTest.java index 0bda882b98..aa484b1c38 100644 --- a/docs/demos/common/src/test/java/com/codenameone/developerguide/animations/AnimationDemosScreenshotTest.java +++ b/docs/demos/common/src/test/java/com/codenameone/developerguide/animations/AnimationDemosScreenshotTest.java @@ -4,17 +4,28 @@ import com.codename1.io.Util; import com.codename1.testing.AbstractTest; import com.codename1.testing.TestUtils; +import com.codename1.ui.AnimationManager; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; import com.codename1.ui.Display; import com.codename1.ui.Form; import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.CN; +import com.codename1.ui.animations.Motion; import com.codename1.ui.util.ImageIO; import com.codenameone.developerguide.Demo; +import com.codenameone.developerguide.DemoBrowserForm; import com.codenameone.developerguide.DemoRegistry; import java.io.IOException; import java.io.OutputStream; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -23,9 +34,14 @@ * that external tooling can compare them against the developer guide imagery. */ public class AnimationDemosScreenshotTest extends AbstractTest { - private static final String HOST_TITLE = "Demo Test Host"; + private static final String HOST_TITLE = "Developer Guide Demos"; private static final long FORM_TIMEOUT_MS = 10000L; private static final String STORAGE_PREFIX = "developer-guide.animations."; + private static final int FRAMES_PER_ANIMATION = 6; + private static final long ANIMATION_CAPTURE_TIMEOUT_MS = 2000L; + private static final long ANIMATION_SETTLE_TIMEOUT_MS = 1500L; + private static final int ANIMATION_FRAME_DELAY_MS = 180; + private static final String FRAME_MANIFEST_SUFFIX = "-frames.manifest"; private static final Map SCREENSHOT_NAME_OVERRIDES = createScreenshotNameOverrides(); private static final Set OVERRIDE_FILE_NAMES = new HashSet<>(SCREENSHOT_NAME_OVERRIDES.values()); @@ -36,24 +52,44 @@ public class AnimationDemosScreenshotTest extends AbstractTest { public boolean runTest() throws Exception { clearPreviousScreenshots(); - Form host = new Form(HOST_TITLE); - host.show(); - TestUtils.waitForFormTitle(HOST_TITLE, FORM_TIMEOUT_MS); + boolean previousSlowMotion = Motion.isSlowMotion(); + Motion.setSlowMotion(false); + try { + Form host = new DemoBrowserForm(); + host.show(); + TestUtils.waitForFormTitle(HOST_TITLE, FORM_TIMEOUT_MS); - for (Demo demo : DemoRegistry.getDemos()) { - Form previous = Display.getInstance().getCurrent(); - demo.show(host); - Form demoForm = waitForFormChange(previous); - waitForFormReady(demoForm); + for (Demo demo : DemoRegistry.getDemos()) { + Motion.setSlowMotion(false); + demo.show(host); + Form demoForm = waitForDemoForm(host); + waitForFormReady(demoForm); - Image screenshot = capture(demoForm); - saveScreenshot(storageKeyFor(demo.getTitle()), screenshot); + waitForAnimationsToFinish(demoForm); - host.show(); - waitForHost(host); - } + triggerAnimationIfNeeded(demo, demoForm); + Form activeForm = ensureCurrentFormReady(demoForm); - return true; + Motion.setSlowMotion(true); + try { + if (waitForAnimationStart(activeForm, ANIMATION_CAPTURE_TIMEOUT_MS)) { + captureAnimationFrames(demo, activeForm); + finalizeAnimations(activeForm); + } else { + Image screenshot = capture(activeForm); + saveScreenshot(storageKeyFor(demo.getTitle()), screenshot); + } + } finally { + Motion.setSlowMotion(false); + } + + returnToHost(activeForm, host); + } + + return true; + } finally { + Motion.setSlowMotion(previousSlowMotion); + } } private void clearPreviousScreenshots() { @@ -100,16 +136,31 @@ private Image capture(Form form) { return screenshot; } - private Form waitForFormChange(Form previous) { + private Form waitForDemoForm(Form host) { long deadline = System.currentTimeMillis() + FORM_TIMEOUT_MS; - while (Display.getInstance().getCurrent() == previous) { + while (System.currentTimeMillis() <= deadline) { + Form form = currentForm(); + if (form != null && form != host && !HOST_TITLE.equals(formTitle(form))) { + return form; + } + animateCurrentForm(); TestUtils.waitFor(50); - if (System.currentTimeMillis() > deadline) { - fail("Timed out waiting for demo form to appear."); - break; + } + fail("Timed out waiting for demo form to appear."); + return host; + } + + private String formTitle(Form form) { + if (form == null) { + return null; + } + if (form.getToolbar() != null) { + Component titleComponent = form.getToolbar().getTitleComponent(); + if (titleComponent instanceof Label) { + return ((Label) titleComponent).getText(); } } - return Display.getInstance().getCurrent(); + return form.getTitle(); } private void waitForFormReady(Form form) { @@ -123,15 +174,270 @@ private void waitForFormReady(Form form) { } private void waitForHost(Form host) { + TestUtils.waitForFormTitle(HOST_TITLE, FORM_TIMEOUT_MS); + TestUtils.waitFor(200); + } + + private void returnToHost(Form demoForm, Form host) { + if (host == null) { + return; + } + long deadline = System.currentTimeMillis() + FORM_TIMEOUT_MS; - while (Display.getInstance().getCurrent() != host) { + boolean restoreSlowMotion = Motion.isSlowMotion(); + if (restoreSlowMotion) { + Motion.setSlowMotion(false); + } + + try { + if (demoForm != null && demoForm != currentForm()) { + unwindForm(demoForm, host); + } + + while (System.currentTimeMillis() <= deadline) { + Dialog activeDialog = activeDialog(); + if (activeDialog != null) { + activeDialog.dispose(); + animateCurrentForm(); + TestUtils.waitFor(120); + continue; + } + + Form active = currentForm(); + if (active == host) { + break; + } + + if (active == null) { + host.show(); + } else { + unwindForm(active, host); + } + + animateCurrentForm(); + TestUtils.waitFor(120); + } + + if (currentForm() != host) { + host.show(); + } + + waitForHost(host); + } finally { + if (restoreSlowMotion) { + Motion.setSlowMotion(true); + } + } + } + + private void unwindForm(Form form, Form host) { + if (form == null) { + return; + } + + if (form instanceof Dialog) { + ((Dialog) form).dispose(); + return; + } + + if (form == host) { + return; + } + + form.showBack(); + } + + private Form currentForm() { + Component current = Display.getInstance().getCurrent(); + if (current instanceof Form) { + return (Form) current; + } + Form cnForm = CN.getCurrentForm(); + if (cnForm instanceof Dialog) { + return null; + } + return cnForm; + } + + private Dialog activeDialog() { + Component current = Display.getInstance().getCurrent(); + return (current instanceof Dialog) ? (Dialog) current : null; + } + + private void animateCurrentForm() { + Form current = currentForm(); + if (current != null) { + current.animate(); + return; + } + + Dialog dialog = activeDialog(); + if (dialog != null) { + dialog.animate(); + } + } + + private void triggerAnimationIfNeeded(Demo demo, Form form) { + if (demo == null || form == null) { + return; + } + + if (demo instanceof LayoutAnimationsDemo) { + clickButton(form, "Fall"); + } else if (demo instanceof UnlayoutAnimationsDemo) { + clickButton(form, "Fall"); + } else if (demo instanceof HiddenComponentDemo) { + clickButton(form, "Hide It"); + } else if (demo instanceof AnimationSynchronicityDemo) { + clickButton(form, "Run Sequence"); + } else if (demo instanceof ReplaceTransitionDemo) { + clickButton(form, "Replace Pending"); + } else if (demo instanceof SlideTransitionsDemo) { + clickButton(form, "Show"); + } else if (demo instanceof BubbleTransitionDemo) { + clickButton(form, "+"); + } else if (demo instanceof SwipeBackSupportDemo) { + clickButton(form, "Open Destination"); + } + } + + private void clickButton(Component root, String text) { + Button button = findButton(root, text); + if (button != null) { + button.pressed(); + button.released(); + TestUtils.waitFor(200); + } + } + + private Button findButton(Component component, String text) { + if (component instanceof Button) { + Button button = (Button) component; + if (text.equals(button.getText())) { + return button; + } + } + + if (component instanceof Form) { + return findButton(((Form) component).getContentPane(), text); + } + + if (component instanceof Container) { + Container container = (Container) component; + int childCount = container.getComponentCount(); + for (int i = 0; i < childCount; i++) { + Button match = findButton(container.getComponentAt(i), text); + if (match != null) { + return match; + } + } + } + + return null; + } + + private Form ensureCurrentFormReady(Form fallback) { + Component current = Display.getInstance().getCurrent(); + if (current instanceof Form) { + Form form = (Form) current; + waitForFormReady(form); + return form; + } + return fallback; + } + + private void waitForAnimationsToFinish(Form form) { + if (form == null) { + return; + } + long deadline = System.currentTimeMillis() + ANIMATION_SETTLE_TIMEOUT_MS; + while (System.currentTimeMillis() <= deadline) { + AnimationManager manager = form.getAnimationManager(); + if (manager == null || !manager.isAnimating()) { + break; + } + form.animate(); TestUtils.waitFor(50); - if (System.currentTimeMillis() > deadline) { - fail("Timed out waiting to return to host form."); + } + } + + private void captureAnimationFrames(Demo demo, Form form) throws IOException { + String sanitized = sanitizeFileName(demo.getTitle()); + String baseKey = storageKeyFor(demo.getTitle()); + boolean baseSaved = false; + List frameKeys = new ArrayList<>(FRAMES_PER_ANIMATION); + Image finalFrameImage = null; + + for (int frameIndex = 0; frameIndex < FRAMES_PER_ANIMATION; frameIndex++) { + if (!isAnimating(form) && frameIndex == 0) { + break; + } + + Image frameImage = capture(form); + if (!baseSaved) { + saveScreenshot(baseKey, frameImage); + baseSaved = true; + } + + String frameKey = stageStorageKeyFor(sanitized, frameIndex); + saveScreenshot(frameKey, frameImage); + frameKeys.add(frameKey); + finalFrameImage = frameImage; + + if (frameIndex >= FRAMES_PER_ANIMATION - 1) { + break; + } + + if (!advanceAnimation(form)) { + finalFrameImage = capture(form); break; } } - TestUtils.waitFor(200); + + if (!baseSaved) { + Image screenshot = capture(form); + saveScreenshot(baseKey, screenshot); + finalFrameImage = screenshot; + } + + if (finalFrameImage == null) { + finalFrameImage = capture(form); + } + + while (frameKeys.size() < FRAMES_PER_ANIMATION) { + String frameKey = stageStorageKeyFor(sanitized, frameKeys.size()); + saveScreenshot(frameKey, finalFrameImage); + frameKeys.add(frameKey); + } + + if (!frameKeys.isEmpty()) { + recordFrameManifest(sanitized, frameKeys); + } + } + + private String stageStorageKeyFor(String sanitizedTitle, int frame) { + return STORAGE_PREFIX + sanitizedTitle + "-frame-" + (frame + 1) + ".png"; + } + + private void recordFrameManifest(String sanitizedTitle, List frameKeys) { + if (sanitizedTitle == null || frameKeys == null || frameKeys.isEmpty()) { + return; + } + + String manifestKey = STORAGE_PREFIX + sanitizedTitle + FRAME_MANIFEST_SUFFIX; + storage.deleteStorageFile(manifestKey); + + Map manifest = new HashMap<>(); + manifest.put("frames", new ArrayList<>(frameKeys)); + + List comparableFrames = new ArrayList<>(2); + comparableFrames.add(Integer.valueOf(0)); + if (frameKeys.size() > 1) { + comparableFrames.add(Integer.valueOf(frameKeys.size() - 1)); + } + manifest.put("compareFrames", comparableFrames); + + storage.writeObject(manifestKey, manifest); } private static Map createScreenshotNameOverrides() { @@ -148,6 +454,51 @@ private String sanitizeFileName(String value) { return sanitized.isEmpty() ? "demo-screenshot" : sanitized; } + private void finalizeAnimations(Form form) { + if (form == null) { + return; + } + form.animate(); + waitForAnimationsToFinish(form); + } + + private boolean waitForAnimationStart(Form form, long timeoutMs) { + if (form == null) { + return false; + } + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() <= deadline) { + if (isAnimating(form)) { + return true; + } + form.animate(); + TestUtils.waitFor(50); + } + return isAnimating(form); + } + + private boolean isAnimating(Form form) { + if (form == null) { + return false; + } + AnimationManager manager = form.getAnimationManager(); + return manager != null && manager.isAnimating(); + } + + private boolean advanceAnimation(Form form) { + if (form == null) { + return false; + } + long deadline = System.currentTimeMillis() + ANIMATION_FRAME_DELAY_MS; + boolean animating = isAnimating(form); + while (System.currentTimeMillis() <= deadline && animating) { + form.animate(); + TestUtils.waitFor(20); + animating = isAnimating(form); + } + return animating; + } + @Override public boolean shouldExecuteOnEDT() { return true;