/* * Copyright (c) 2022, 2024, Oracle 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. * * 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 Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ import java.awt.AWTException; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.Font; import java.awt.GraphicsConfiguration; import java.awt.GraphicsDevice; import java.awt.GraphicsEnvironment; import java.awt.Image; import java.awt.Insets; import java.awt.Point; import java.awt.Rectangle; import java.awt.Robot; import java.awt.Toolkit; import java.awt.Window; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.awt.event.WindowListener; import java.awt.image.RenderedImage; import java.io.File; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import javax.imageio.ImageIO; import javax.swing.Box; import javax.swing.JButton; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JEditorPane; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.JTextArea; import javax.swing.Timer; import javax.swing.text.JTextComponent; import javax.swing.text.html.HTMLEditorKit; import javax.swing.text.html.StyleSheet; import static java.util.Collections.unmodifiableList; import static javax.swing.BorderFactory.createEmptyBorder; import static javax.swing.SwingUtilities.invokeAndWait; import static javax.swing.SwingUtilities.isEventDispatchThread; /** * Provides a framework for manual tests to display test instructions and * Pass/Fail buttons. *

* Instructions for the user can be either plain text or HTML as supported * by Swing. If the instructions start with {@code }, the * instructions are displayed as HTML. *

* A simple test would look like this: *

{@code
 * public class SampleManualTest {
 *     private static final String INSTRUCTIONS =
 *             "Click Pass, or click Fail if the test failed.";
 *
 *     public static void main(String[] args) throws Exception {
 *         PassFailJFrame.builder()
 *                       .instructions(INSTRUCTIONS)
 *                       .testUI(() -> createTestUI())
 *                       .build()
 *                       .awaitAndCheck();
 *     }
 *
 *     private static Window createTestUI() {
 *         JFrame testUI = new JFrame("Test UI");
 *         testUI.setSize(250, 150);
 *         return testUI;
 *     }
 * }
 * }
*

* The above example uses the {@link Builder Builder} to set the parameters of * the instruction frame. It is the recommended way. *

* The framework will create instruction UI, it will call * the provided {@code createTestUI} on the Event Dispatch Thread (EDT), * and it will automatically position the test UI and make it visible. *

* The {@code Builder.testUI} methods accept interfaces which create one window * or a list of windows if the test needs multiple windows, * or directly a single window, an array of windows or a list of windows. *

* For simple test UI, use {@code Builder.splitUI}, or explicitly * {@code Builder.splitUIRight} or {@code Builder.splitUIBottom} with * a {@code PanelCreator}. The framework will call the provided * {@code createUIPanel} to create the component with test UI and * will place it as the right or bottom component in a split pane * along with instruction UI. *

* Alternatively, use one of the {@code PassFailJFrame} constructors to * create an object, then create secondary test UI, register it * with {@code PassFailJFrame}, position it and make it visible. * The following sample demonstrates it: *

{@code
 * public class SampleOldManualTest {
 *     private static final String INSTRUCTIONS =
 *             "Click Pass, or click Fail if the test failed.";
 *
 *     public static void main(String[] args) throws Exception {
 *         PassFailJFrame passFail = new PassFailJFrame(INSTRUCTIONS);
 *
 *         SwingUtilities.invokeAndWait(() -> createTestUI());
 *
 *         passFail.awaitAndCheck();
 *     }
 *
 *     private static void createTestUI() {
 *         JFrame testUI = new JFrame("Test UI");
 *         testUI.setSize(250, 150);
 *         PassFailJFrame.addTestWindow(testUI);
 *         PassFailJFrame.positionTestWindow(testUI, PassFailJFrame.Position.HORIZONTAL);
 *         testUI.setVisible(true);
 *     }
 * }
 * }
*

* Use methods of the {@code Builder} class or constructors of the * {@code PassFailJFrame} class to control other parameters: *

*/ public final class PassFailJFrame { private static final String TITLE = "Test Instruction Frame"; private static final long TEST_TIMEOUT = 5; private static final int ROWS = 10; private static final int COLUMNS = 40; /** * A gap between windows. */ public static final int WINDOW_GAP = 8; /** * Prefix for the user-provided failure reason. */ private static final String FAILURE_REASON = "Failure Reason:\n"; /** * The failure reason message when the user didn't provide one. */ private static final String EMPTY_REASON = "(no reason provided)"; /** * List of windows or frames managed by the {@code PassFailJFrame} * framework. These windows are automatically disposed of when the * test is finished. *

* Note: access to this field has to be synchronized by * {@code PassFailJFrame.class}. */ private static final List windowList = new ArrayList<>(); private static final CountDownLatch latch = new CountDownLatch(1); private static TimeoutHandlerPanel timeoutHandlerPanel; /** * The description of why the test fails. *

* Note: do not use this field directly, * use the {@link #setFailureReason(String) setFailureReason} and * {@link #getFailureReason() getFailureReason} methods to modify and * to read its value. */ private static String failureReason; private static final AtomicInteger imgCounter = new AtomicInteger(0); private static JFrame frame; private static Robot robot; private static JTextArea logArea; public enum Position {HORIZONTAL, VERTICAL, TOP_LEFT_CORNER} /** * Constructs a frame which displays test instructions and * the Pass / Fail buttons with the given instructions, and * the default timeout of {@value #TEST_TIMEOUT} minutes, * the default title of {@value #TITLE} and * the default values of {@value #ROWS} and {@value #COLUMNS} * for rows and columns. *

* See {@link #PassFailJFrame(String,String,long,int,int,boolean)} for * more details. * * @param instructions the instructions for the tester * * @throws InterruptedException if the current thread is interrupted * while waiting for EDT to finish creating UI components * @throws InvocationTargetException if an exception is thrown while * creating UI components on EDT */ public PassFailJFrame(String instructions) throws InterruptedException, InvocationTargetException { this(instructions, TEST_TIMEOUT); } /** * Constructs a frame which displays test instructions and * the Pass / Fail buttons * with the given instructions and timeout as well as * the default title of {@value #TITLE} * and the default values of {@value #ROWS} and {@value #COLUMNS} * for rows and columns. *

* See {@link #PassFailJFrame(String,String,long,int,int,boolean)} for * more details. * * @param instructions the instructions for the tester * @param testTimeOut the test timeout in minutes * * @throws InterruptedException if the current thread is interrupted * while waiting for EDT to finish creating UI components * @throws InvocationTargetException if an exception is thrown while * creating UI components on EDT */ public PassFailJFrame(String instructions, long testTimeOut) throws InterruptedException, InvocationTargetException { this(TITLE, instructions, testTimeOut); } /** * Constructs a frame which displays test instructions and * the Pass / Fail buttons * with the given title, instructions and timeout as well as * the default values of {@value #ROWS} and {@value #COLUMNS} * for rows and columns. * The screenshot feature is not enabled, if you use this constructor. *

* See {@link #PassFailJFrame(String,String,long,int,int,boolean)} for * more details. * * @param title the title of the instruction frame * @param instructions the instructions for the tester * @param testTimeOut the test timeout in minutes * * @throws InterruptedException if the current thread is interrupted * while waiting for EDT to finish creating UI components * @throws InvocationTargetException if an exception is thrown while * creating UI components on EDT */ public PassFailJFrame(String title, String instructions, long testTimeOut) throws InterruptedException, InvocationTargetException { this(title, instructions, testTimeOut, ROWS, COLUMNS); } /** * Constructs a frame which displays test instructions and * the Pass / Fail buttons * with the given title, instructions, timeout, number of rows and columns. * The screenshot feature is not enabled, if you use this constructor. *

* See {@link #PassFailJFrame(String,String,long,int,int,boolean)} for * more details. * * @param title the title of the instruction frame * @param instructions the instructions for the tester * @param testTimeOut the test timeout in minutes * @param rows the number of rows for the text component * which displays test instructions * @param columns the number of columns for the text component * which displays test instructions * * @throws InterruptedException if the current thread is interrupted * while waiting for EDT to finish creating UI components * @throws InvocationTargetException if an exception is thrown while * creating UI components on EDT */ public PassFailJFrame(String title, String instructions, long testTimeOut, int rows, int columns) throws InterruptedException, InvocationTargetException { this(title, instructions, testTimeOut, rows, columns, false); } /** * Constructs a frame which displays test instructions and * the Pass / Fail buttons * as well as supporting UI components with the given title, instructions, * timeout, number of rows and columns, * and screen capture functionality. * All the UI components are created on the EDT, so it is safe to call * the constructor on the main thread. *

* After you create a test UI window, register the window using * {@link #addTestWindow(Window) addTestWindow} for disposal, and * position it close to the instruction frame using * {@link #positionTestWindow(Window, Position) positionTestWindow}. * As the last step, make your test UI window visible. *

* Call the {@link #awaitAndCheck() awaitAndCheck} method on the instance * of {@code PassFailJFrame} when you set up the testing environment. *

* If the tester clicks the Fail button, a dialog prompting for * a description of the problem is displayed, and then an exception * is thrown which fails the test. * If the tester clicks the Pass button, the test completes * successfully. * If the timeout occurs or the instruction frame is closed, * the test fails. *

* The {@code rows} and {@code columns} parameters control * the size of a text component which displays the instructions. * The preferred size of the instructions is calculated by * creating {@code new JTextArea(rows, columns)}. *

* If you enable screenshots by setting the {@code screenCapture} * parameter to {@code true}, a Screenshot button is added. * Clicking the Screenshot button takes screenshots of * all the monitors or all the windows registered with * {@code PassFailJFrame}. * * @param title the title of the instruction frame * @param instructions the instructions for the tester * @param testTimeOut the test timeout in minutes * @param rows the number of rows for the text component * which displays test instructions * @param columns the number of columns for the text component * which displays test instructions * @param screenCapture if set to {@code true}, enables screen capture * functionality * * @throws InterruptedException if the current thread is interrupted * while waiting for EDT to finish creating UI components * @throws InvocationTargetException if an exception is thrown while * creating UI components on EDT * * @see JTextArea#JTextArea(int,int) JTextArea(int rows, int columns) * @see Builder Builder */ public PassFailJFrame(String title, String instructions, long testTimeOut, int rows, int columns, boolean screenCapture) throws InterruptedException, InvocationTargetException { invokeOnEDT(() -> createUI(title, instructions, testTimeOut, rows, columns, screenCapture)); } /** * Configures {@code PassFailJFrame} using the builder. * It creates test UI specified using {@code testUI} or {@code splitUI} * methods on EDT. * @param builder the builder with the parameters * @throws InterruptedException if the current thread is interrupted while * waiting for EDT to complete a task * @throws InvocationTargetException if an exception is thrown while * running a task on EDT */ private PassFailJFrame(final Builder builder) throws InterruptedException, InvocationTargetException { invokeOnEDT(() -> createUI(builder)); if (!builder.splitUI && builder.panelCreator != null) { JComponent content = builder.panelCreator.createUIPanel(); String title = content.getName(); if (title == null) { title = "Test UI"; } JDialog dialog = new JDialog(frame, title, false); dialog.addWindowListener(windowClosingHandler); dialog.add(content, BorderLayout.CENTER); dialog.pack(); addTestWindow(dialog); positionTestWindow(dialog, builder.position); } if (builder.windowListCreator != null) { invokeOnEDT(() -> builder.testWindows = builder.windowListCreator.createTestUI()); if (builder.testWindows == null) { throw new IllegalStateException("Window list creator returned null list"); } } if (builder.testWindows != null) { if (builder.testWindows.isEmpty()) { throw new IllegalStateException("Window list is empty"); } addTestWindow(builder.testWindows); builder.testWindows .forEach(w -> w.addWindowListener(windowClosingHandler)); if (builder.positionWindows != null) { positionInstructionFrame(builder.position); invokeOnEDT(() -> builder.positionWindows .positionTestWindows(unmodifiableList(builder.testWindows), builder.instructionUIHandler)); } else { Window window = builder.testWindows.get(0); positionTestWindow(window, builder.position); } } showAllWindows(); } /** * Performs an operation on EDT. If called on EDT, invokes {@code run} * directly, otherwise wraps into {@code invokeAndWait}. * * @param doRun an operation to run on EDT * @throws InterruptedException if we're interrupted while waiting for * the event dispatching thread to finish executing * {@code doRun.run()} * @throws InvocationTargetException if an exception is thrown while * running {@code doRun} * @see javax.swing.SwingUtilities#invokeAndWait(Runnable) */ private static void invokeOnEDT(Runnable doRun) throws InterruptedException, InvocationTargetException { if (isEventDispatchThread()) { doRun.run(); } else { invokeAndWait(doRun); } } /** * Does the same as {@link #invokeOnEDT(Runnable)}, but does not throw * any checked exceptions. * * @param doRun an operation to run on EDT */ private static void invokeOnEDTUncheckedException(Runnable doRun) { try { invokeOnEDT(doRun); } catch (InterruptedException | InvocationTargetException e) { throw new RuntimeException(e); } } private static void createUI(String title, String instructions, long testTimeOut, int rows, int columns, boolean enableScreenCapture) { frame = new JFrame(title); frame.setLayout(new BorderLayout()); frame.addWindowListener(windowClosingHandler); frame.add(createInstructionUIPanel(instructions, testTimeOut, rows, columns, enableScreenCapture, false, 0), BorderLayout.CENTER); frame.pack(); frame.setLocationRelativeTo(null); addTestWindow(frame); } private static void createUI(Builder builder) { frame = new JFrame(builder.title); frame.setLayout(new BorderLayout()); frame.addWindowListener(windowClosingHandler); JComponent instructionUI = createInstructionUIPanel(builder.instructions, builder.testTimeOut, builder.rows, builder.columns, builder.screenCapture, builder.addLogArea, builder.logAreaRows); if (builder.splitUI) { JSplitPane splitPane = new JSplitPane( builder.splitUIOrientation, instructionUI, builder.panelCreator.createUIPanel()); frame.add(splitPane, BorderLayout.CENTER); } else { frame.add(instructionUI, BorderLayout.CENTER); } frame.pack(); frame.setLocationRelativeTo(null); addTestWindow(frame); } private static JComponent createInstructionUIPanel(String instructions, long testTimeOut, int rows, int columns, boolean enableScreenCapture, boolean addLogArea, int logAreaRows) { JPanel main = new JPanel(new BorderLayout()); timeoutHandlerPanel = new TimeoutHandlerPanel(testTimeOut); main.add(timeoutHandlerPanel, BorderLayout.NORTH); JTextComponent text = instructions.startsWith("") ? configureHTML(instructions, rows, columns) : configurePlainText(instructions, rows, columns); text.setEditable(false); main.add(new JScrollPane(text), BorderLayout.CENTER); JButton btnPass = new JButton("Pass"); btnPass.addActionListener((e) -> { latch.countDown(); timeoutHandlerPanel.stop(); }); JButton btnFail = new JButton("Fail"); btnFail.addActionListener((e) -> { requestFailureReason(); timeoutHandlerPanel.stop(); }); JPanel buttonsPanel = new JPanel(); buttonsPanel.add(btnPass); buttonsPanel.add(btnFail); if (enableScreenCapture) { buttonsPanel.add(createCapturePanel()); } if (addLogArea) { logArea = new JTextArea(logAreaRows, columns); logArea.setEditable(false); Box buttonsLogPanel = Box.createVerticalBox(); buttonsLogPanel.add(buttonsPanel); buttonsLogPanel.add(new JScrollPane(logArea)); main.add(buttonsLogPanel, BorderLayout.SOUTH); } else { main.add(buttonsPanel, BorderLayout.SOUTH); } main.setMinimumSize(main.getPreferredSize()); return main; } private static JTextComponent configurePlainText(String instructions, int rows, int columns) { JTextArea text = new JTextArea(instructions, rows, columns); text.setLineWrap(true); text.setWrapStyleWord(true); text.setBorder(createEmptyBorder(4, 4, 4, 4)); return text; } private static JTextComponent configureHTML(String instructions, int rows, int columns) { JEditorPane text = new JEditorPane("text/html", instructions); text.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE); // Set preferred size as if it were JTextArea text.setPreferredSize(new JTextArea(rows, columns).getPreferredSize()); HTMLEditorKit kit = (HTMLEditorKit) text.getEditorKit(); StyleSheet styles = kit.getStyleSheet(); // Reduce the default margins styles.addRule("ol, ul { margin-left-ltr: 20; margin-left-rtl: 20 }"); // Make the size of code blocks the same as other text styles.addRule("code { font-size: inherit }"); return text; } /** * Creates a test UI window. */ @FunctionalInterface public interface WindowCreator { /** * Creates a window for test UI. * This method is called by the framework on the EDT. * @return a test UI window */ Window createTestUI(); } /** * Creates a list of test UI windows. */ @FunctionalInterface public interface WindowListCreator { /** * Creates one or more windows for test UI. * This method is called by the framework on the EDT. * @return a list of test UI windows */ List createTestUI(); } /** * Creates a component (panel) with test UI * to be hosted in a split pane or a frame. */ @FunctionalInterface public interface PanelCreator { /** * Creates a component which hosts test UI. This component * is placed into a split pane or into a frame to display the UI. *

* This method is called by the framework on the EDT. * @return a component (panel) with test UI */ JComponent createUIPanel(); } /** * Positions test UI windows. */ @FunctionalInterface public interface PositionWindows { /** * Positions test UI windows. * This method is called by the framework on the EDT after * the instruction UI frame was positioned on the screen. *

* The list of the test windows contains the windows * that were passed to the framework via the * {@link Builder#testUI(Window...) testUI(Window...)} method or * that were created with {@code WindowCreator} * or {@code WindowListCreator} which were passed via * {@link Builder#testUI(WindowCreator) testUI(WindowCreator)} or * {@link Builder#testUI(WindowListCreator) testUI(WindowListCreator)} * correspondingly. * * @param testWindows the list of test windows * @param instructionUI information about the instruction frame */ void positionTestWindows(List testWindows, InstructionUI instructionUI); } /** * Provides information about the instruction frame. */ public interface InstructionUI { /** * {@return the location of the instruction frame} */ Point getLocation(); /** * {@return the size of the instruction frame} */ Dimension getSize(); /** * {@return the bounds of the instruction frame} */ Rectangle getBounds(); /** * Allows to change the location of the instruction frame. * * @param location the new location of the instruction frame */ void setLocation(Point location); /** * Allows to change the location of the instruction frame. * * @param x the x coordinate of the new location * @param y the y coordinate of the new location */ void setLocation(int x, int y); /** * Returns the specified position that was used to set * the initial location of the instruction frame. * * @return the specified position * * @see Position */ Position getPosition(); } private static final class TimeoutHandlerPanel extends JPanel implements ActionListener { private static final String PAUSE_BUTTON_LABEL = "Pause"; private static final String RESUME_BUTTON_LABEL = "Resume"; private long endTime; private long pauseTimeLeft; private final Timer timer; private final JLabel label; private final JButton button; public TimeoutHandlerPanel(final long testTimeOut) { endTime = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(testTimeOut); label = new JLabel("", JLabel.CENTER); button = new JButton(PAUSE_BUTTON_LABEL); button.setFocusPainted(false); button.setFont(new Font(Font.DIALOG, Font.BOLD, 10)); button.addActionListener(e -> pauseToggle()); setLayout(new BorderLayout()); add(label, BorderLayout.CENTER); add(button, BorderLayout.EAST); timer = new Timer(1000, this); timer.start(); updateTime(testTimeOut); } @Override public void actionPerformed(ActionEvent e) { long leftTime = endTime - System.currentTimeMillis(); if (leftTime < 0) { timer.stop(); setFailureReason(FAILURE_REASON + "Timeout - User did not perform testing."); latch.countDown(); } updateTime(leftTime); } private void updateTime(final long leftTime) { if (leftTime < 0) { label.setText("Test timeout: 00:00:00"); return; } long hours = leftTime / 3_600_000; long minutes = (leftTime - hours * 3_600_000) / 60_000; long seconds = (leftTime - hours * 3_600_000 - minutes * 60_000) / 1_000; label.setText(String.format(Locale.ENGLISH, "Test timeout: %02d:%02d:%02d", hours, minutes, seconds)); } private void pauseToggle() { if (timer.isRunning()) { pauseTimeLeft = endTime - System.currentTimeMillis(); timer.stop(); label.setEnabled(false); button.setText(RESUME_BUTTON_LABEL); } else { endTime = System.currentTimeMillis() + pauseTimeLeft; updateTime(pauseTimeLeft); timer.start(); label.setEnabled(true); button.setText(PAUSE_BUTTON_LABEL); } } public void stop() { timer.stop(); } } private static final class WindowClosingHandler extends WindowAdapter { @Override public void windowClosing(WindowEvent e) { setFailureReason(FAILURE_REASON + "User closed a window"); latch.countDown(); } } private static final WindowListener windowClosingHandler = new WindowClosingHandler(); private static JComponent createCapturePanel() { JComboBox screenShortType = new JComboBox<>(CaptureType.values()); JButton capture = new JButton("ScreenShot"); capture.addActionListener((e) -> captureScreen((CaptureType) screenShortType.getSelectedItem())); JPanel panel = new JPanel(); panel.add(screenShortType); panel.add(capture); return panel; } private enum CaptureType { FULL_SCREEN("Capture Full Screen"), WINDOWS("Capture Individual Frame"); private final String type; CaptureType(String type) { this.type = type; } @Override public String toString() { return type; } } private static Robot createRobot() { if (robot == null) { try { robot = new Robot(); } catch (AWTException e) { String errorMsg = "Failed to create an instance of Robot."; JOptionPane.showMessageDialog(frame, errorMsg, "Failed", JOptionPane.ERROR_MESSAGE); forceFail(errorMsg + e.getMessage()); } } return robot; } private static void captureScreen(Rectangle bounds) { Robot robot = createRobot(); List imageList = robot.createMultiResolutionScreenCapture(bounds) .getResolutionVariants(); Image image = imageList.get(imageList.size() - 1); File file = new File("CaptureScreen_" + imgCounter.incrementAndGet() + ".png"); try { ImageIO.write((RenderedImage) image, "png", file); } catch (IOException e) { throw new RuntimeException(e); } } private static void captureScreen(CaptureType type) { switch (type) { case FULL_SCREEN: Arrays.stream(GraphicsEnvironment.getLocalGraphicsEnvironment() .getScreenDevices()) .map(GraphicsDevice::getDefaultConfiguration) .map(GraphicsConfiguration::getBounds) .forEach(PassFailJFrame::captureScreen); break; case WINDOWS: synchronized (PassFailJFrame.class) { windowList.stream() .filter(Window::isShowing) .map(Window::getBounds) .forEach(PassFailJFrame::captureScreen); } break; default: throw new IllegalStateException("Unexpected value of capture type"); } JOptionPane.showMessageDialog(frame, "Screen Captured Successfully", "Screen Capture", JOptionPane.INFORMATION_MESSAGE); } /** * Sets the failure reason which describes why the test fails. * This method ensures the {@code failureReason} field does not change * after it's set to a non-{@code null} value. * @param reason the description of why the test fails * @throws IllegalArgumentException if the {@code reason} parameter * is {@code null} */ private static synchronized void setFailureReason(final String reason) { if (reason == null) { throw new IllegalArgumentException("The failure reason must not be null"); } if (failureReason == null) { failureReason = reason; } } /** * {@return the description of why the test fails} */ private static synchronized String getFailureReason() { return failureReason; } /** * Wait for the user decision i,e user selects pass or fail button. * If user does not select pass or fail button then the test waits for * the specified timeoutMinutes period and the test gets timeout. * Note: This method should be called from main() thread * * @throws InterruptedException exception thrown when thread is * interrupted * @throws InvocationTargetException if an exception is thrown while * disposing of frames on EDT */ public void awaitAndCheck() throws InterruptedException, InvocationTargetException { if (isEventDispatchThread()) { throw new IllegalStateException("awaitAndCheck() should not be called on EDT"); } latch.await(); invokeAndWait(PassFailJFrame::disposeWindows); String failure = getFailureReason(); if (failure != null) { throw new RuntimeException(failure); } System.out.println("Test passed!"); } /** * Requests the description of the test failure reason from the tester. */ private static void requestFailureReason() { final JDialog dialog = new JDialog(frame, "Test Failure ", true); dialog.setTitle("Failure reason"); JPanel jPanel = new JPanel(new BorderLayout()); JTextArea jTextArea = new JTextArea(5, 20); JButton okButton = new JButton("OK"); okButton.addActionListener((ae) -> { String text = jTextArea.getText(); setFailureReason(FAILURE_REASON + (!text.isEmpty() ? text : EMPTY_REASON)); dialog.setVisible(false); }); jPanel.add(new JScrollPane(jTextArea), BorderLayout.CENTER); JPanel okayBtnPanel = new JPanel(); okayBtnPanel.add(okButton); jPanel.add(okayBtnPanel, BorderLayout.SOUTH); dialog.add(jPanel); dialog.setLocationRelativeTo(frame); dialog.pack(); dialog.setVisible(true); // Ensure the test fails even if the dialog is closed // without clicking the OK button setFailureReason(FAILURE_REASON + EMPTY_REASON); dialog.dispose(); latch.countDown(); } /** * Disposes of all the windows. It disposes of the test instruction frame * and all other windows added via {@link #addTestWindow(Window)}. */ private static synchronized void disposeWindows() { windowList.forEach(Window::dispose); } private static void positionInstructionFrame(final Position position) { Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize(); // Get the screen insets to position the frame by taking into // account the location of taskbar or menu bar on screen. GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment() .getDefaultScreenDevice() .getDefaultConfiguration(); Insets screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(gc); switch (position) { case HORIZONTAL: int newX = (((screenSize.width + WINDOW_GAP) / 2) - frame.getWidth()); frame.setLocation((newX + screenInsets.left), (frame.getY() + screenInsets.top)); break; case VERTICAL: int newY = (((screenSize.height + WINDOW_GAP) / 2) - frame.getHeight()); frame.setLocation((frame.getX() + screenInsets.left), (newY + screenInsets.top)); break; case TOP_LEFT_CORNER: frame.setLocation(screenInsets.left, screenInsets.top); break; } syncLocationToWindowManager(); } /** * Approximately positions the instruction frame relative to the test * window as specified by the {@code position} parameter. If {@code testWindow} * is {@code null}, only the instruction frame is positioned according to * {@code position} parameter. *

This method should be called before making the test window visible * to avoid flickering.

* * @param testWindow test window that the test created. * May be {@code null}. * * @param position position must be one of: * */ public static void positionTestWindow(Window testWindow, Position position) { positionInstructionFrame(position); if (testWindow != null) { switch (position) { case HORIZONTAL: case TOP_LEFT_CORNER: testWindow.setLocation((frame.getX() + frame.getWidth() + WINDOW_GAP), frame.getY()); break; case VERTICAL: testWindow.setLocation(frame.getX(), (frame.getY() + frame.getHeight() + WINDOW_GAP)); break; } } // make instruction frame visible after updating // frame & window positions frame.setVisible(true); } /** * Ensures the frame location is updated by the window manager * if it adjusts the frame location after {@code setLocation}. * * @see #positionTestWindow */ private static void syncLocationToWindowManager() { Toolkit.getDefaultToolkit().sync(); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } /** * Returns the current position and size of the test instruction frame. * This method can be used in scenarios when custom positioning of * multiple test windows w.r.t test instruction frame is necessary, * at test-case level and the desired configuration is not available * as a {@code Position} option. * * @return Rectangle bounds of test instruction frame * @see #positionTestWindow * * @throws InterruptedException exception thrown when thread is * interrupted * @throws InvocationTargetException if an exception is thrown while * obtaining frame bounds on EDT */ public static Rectangle getInstructionFrameBounds() throws InterruptedException, InvocationTargetException { final Rectangle[] bounds = {null}; invokeOnEDT(() -> bounds[0] = frame != null ? frame.getBounds() : null); return bounds[0]; } /** * Add the testWindow to the windowList so that test instruction frame * and testWindow and any other windows used in this test is disposed * via disposeWindows(). * * @param testWindow testWindow that needs to be disposed */ public static synchronized void addTestWindow(Window testWindow) { windowList.add(testWindow); } /** * Adds a collection of test windows to the windowList to be disposed of * when the test completes. * * @param testWindows the collection of test windows to be disposed of */ public static synchronized void addTestWindow(Collection testWindows) { windowList.addAll(testWindows); } /** * Displays all the windows in {@code windowList}. * * @throws InterruptedException if the thread is interrupted while * waiting for the event dispatch thread to finish running * the {@link #showUI() showUI} * @throws InvocationTargetException if an exception is thrown while * the event dispatch thread executes {@code showUI} */ private static void showAllWindows() throws InterruptedException, InvocationTargetException { invokeOnEDT(PassFailJFrame::showUI); } /** * Displays all the windows in {@code windowList}; it has to be called on * the EDT — use {@link #showAllWindows() showAllWindows} to ensure it. */ private static synchronized void showUI() { windowList.forEach(w -> w.setVisible(true)); } /** * Forcibly pass the test. *

The sample usage: *


     *      PrinterJob pj = PrinterJob.getPrinterJob();
     *      if (pj == null || pj.getPrintService() == null) {
     *          System.out.println(""Printer not configured or available.");
     *          PassFailJFrame.forcePass();
     *      }
     * 
*/ public static void forcePass() { latch.countDown(); } /** * Forcibly fail the test. */ public static void forceFail() { forceFail("forceFail called"); } /** * Forcibly fail the test and provide a reason. * * @param reason the reason why the test is failed */ public static void forceFail(String reason) { setFailureReason(FAILURE_REASON + reason); latch.countDown(); } /** * Adds a {@code message} to the log area, if enabled by * {@link Builder#logArea() logArea()} or * {@link Builder#logArea(int) logArea(int)}. * * @param message the message to log */ public static void log(String message) { System.out.println("PassFailJFrame: " + message); invokeOnEDTUncheckedException(() -> logArea.append(message + "\n")); } /** * Clears the log area, if enabled by * {@link Builder#logArea() logArea()} or * {@link Builder#logArea(int) logArea(int)}. */ public static void logClear() { System.out.println("\nPassFailJFrame: log cleared\n"); invokeOnEDTUncheckedException(() -> logArea.setText("")); } /** * Replaces the log area content with provided {@code text}, if enabled by * {@link Builder#logArea() logArea()} or * {@link Builder#logArea(int) logArea(int)}. * * @param text new text for the log area */ public static void logSet(String text) { System.out.println("\nPassFailJFrame: log set to:\n" + text + "\n"); invokeOnEDTUncheckedException(() -> logArea.setText(text)); } public static final class Builder { private String title; private String instructions; private long testTimeOut; private int rows; private int columns; private boolean screenCapture; private boolean addLogArea; private int logAreaRows = 10; private List testWindows; private WindowListCreator windowListCreator; private PanelCreator panelCreator; private boolean splitUI; private int splitUIOrientation; private PositionWindows positionWindows; private InstructionUI instructionUIHandler; private Position position; /** * A private constructor for the builder, * it should not be created directly. * Use {@code PassFailJFrame.builder()} method instead. */ private Builder() { } public Builder title(String title) { this.title = title; return this; } public Builder instructions(String instructions) { this.instructions = instructions; return this; } public Builder testTimeOut(long testTimeOut) { this.testTimeOut = testTimeOut; return this; } /** * Sets the number of rows for displaying the instruction text. * The default value is the number of lines in the text plus 1: * {@code ((int) instructions.lines().count() + 1)}. * * @param rows the number of rows for instruction text * @return this builder */ public Builder rows(int rows) { this.rows = rows; return this; } private int getDefaultRows() { return (int) instructions.lines().count() + 1; } /** * Adds a certain number of rows for displaying the instruction text. * * @param rowsAdd the number of rows to add to the number of rows * @return this builder * @see #rows */ public Builder rowsAdd(int rowsAdd) { if (rows == 0) { rows = getDefaultRows(); } rows += rowsAdd; return this; } /** * Sets the number of columns for displaying the instruction text. * * @param columns the number of columns for instruction text * @return this builder */ public Builder columns(int columns) { this.columns = columns; return this; } public Builder screenCapture() { this.screenCapture = true; return this; } /** * Adds a log area below the "Pass", "Fail" buttons. *

* The log area can be controlled by {@link #log(String)}, * {@link #logClear()} and {@link #logSet(String)}. * * @return this builder */ public Builder logArea() { this.addLogArea = true; return this; } /** * Adds a log area below the "Pass", "Fail" buttons. *

* The log area can be controlled by {@link #log(String)}, * {@link #logClear()} and {@link #logSet(String)}. *

* The number of columns is taken from the number of * columns in the instructional JTextArea. * * @param rows of the log area * @return this builder */ public Builder logArea(int rows) { this.addLogArea = true; this.logAreaRows = rows; return this; } /** * Adds a {@code WindowCreator} which the framework will use * to create the test UI window. * * @param windowCreator a {@code WindowCreator} * to create the test UI window * @return this builder * @throws IllegalArgumentException if {@code windowCreator} is {@code null} * @throws IllegalStateException if a window creator * or a list of test windows is already set */ public Builder testUI(WindowCreator windowCreator) { if (windowCreator == null) { throw new IllegalArgumentException("The window creator can't be null"); } checkWindowsLists(); this.windowListCreator = () -> List.of(windowCreator.createTestUI()); return this; } /** * Adds an implementation of {@link PositionWindows PositionWindows} * which the framework will use to position multiple test UI windows. * * @param positionWindows an implementation of {@code PositionWindows} * to position multiple test UI windows * @return this builder * @throws IllegalArgumentException if the {@code positionWindows} * parameter is {@code null} * @throws IllegalStateException if the {@code positionWindows} field * is already set */ public Builder positionTestUI(PositionWindows positionWindows) { if (positionWindows == null) { throw new IllegalArgumentException("positionWindows parameter can't be null"); } if (this.positionWindows != null) { throw new IllegalStateException("PositionWindows is already set"); } this.positionWindows = positionWindows; return this; } /** * Positions the test UI windows in a row to the right of * the instruction frame. The top of the windows is aligned to * that of the instruction frame. * * @return this builder */ public Builder positionTestUIRightRow() { return position(Position.HORIZONTAL) .positionTestUI(WindowLayouts::rightOneRow); } /** * Positions the test UI windows in a column to the right of * the instruction frame. The top of the first window is aligned to * that of the instruction frame. * * @return this builder */ public Builder positionTestUIRightColumn() { return position(Position.HORIZONTAL) .positionTestUI(WindowLayouts::rightOneColumn); } /** * Positions the test UI windows in a column to the right of * the instruction frame centering the stack of the windows. * * @return this builder */ public Builder positionTestUIRightColumnCentered() { return position(Position.HORIZONTAL) .positionTestUI(WindowLayouts::rightOneColumnCentered); } /** * Positions the test UI windows in a row to the bottom of * the instruction frame. The left of the first window is aligned to * that of the instruction frame. * * @return this builder */ public Builder positionTestUIBottomRow() { return position(Position.VERTICAL) .positionTestUI(WindowLayouts::bottomOneRow); } /** * Positions the test UI windows in a row to the bottom of * the instruction frame centering the row of the windows. * * @return this builder */ public Builder positionTestUIBottomRowCentered() { return position(Position.VERTICAL) .positionTestUI(WindowLayouts::bottomOneRowCentered); } /** * Positions the test UI windows in a column to the bottom of * the instruction frame. The left of the first window is aligned to * that of the instruction frame. * * @return this builder */ public Builder positionTestUIBottomColumn() { return position(Position.VERTICAL) .positionTestUI(WindowLayouts::bottomOneColumn); } /** * Adds a {@code WindowListCreator} which the framework will use * to create a list of test UI windows. * * @param windowListCreator a {@code WindowListCreator} * to create test UI windows * @return this builder * @throws IllegalArgumentException if {@code windowListCreator} is {@code null} * @throws IllegalStateException if a window creator * or a list of test windows is already set */ public Builder testUI(WindowListCreator windowListCreator) { if (windowListCreator == null) { throw new IllegalArgumentException("The window list creator can't be null"); } checkWindowsLists(); this.windowListCreator = windowListCreator; return this; } /** * Adds an already created test UI window. * The window is positioned and shown automatically. * * @param window a test UI window * @return this builder */ public Builder testUI(Window window) { return testUI(List.of(window)); } /** * Adds an array of already created test UI windows. * * @param windows an array of test UI windows * @return this builder */ public Builder testUI(Window... windows) { return testUI(List.of(windows)); } /** * Adds a list of already created test UI windows. * * @param windows a list of test UI windows * @return this builder * @throws IllegalArgumentException if {@code windows} is {@code null} * or the list contains {@code null} * @throws IllegalStateException if a window creator * or a list of test windows is already set */ public Builder testUI(List windows) { if (windows == null) { throw new IllegalArgumentException("The list of windows can't be null"); } if (windows.stream() .anyMatch(Objects::isNull)) { throw new IllegalArgumentException("The list of windows can't contain null"); } checkWindowsLists(); this.testWindows = windows; return this; } /** * Verifies the state of window list and window creator. * * @throws IllegalStateException if a windows list creator * or a list of test windows is already set */ private void checkWindowsLists() { if (windowListCreator != null) { throw new IllegalStateException("Window list creator is already set"); } if (testWindows != null) { throw new IllegalStateException("The list of test windows is already set"); } } /** * Adds a {@code PanelCreator} which the framework will use * to create a component and place it into a dialog. * * @param panelCreator a {@code PanelCreator} to create a component * with test UI * @return this builder * @throws IllegalStateException if split UI was enabled using * a {@code splitUI} method */ public Builder testUI(PanelCreator panelCreator) { if (splitUI) { throw new IllegalStateException("Can't combine splitUI and " + "testUI with panelCreator"); } this.panelCreator = panelCreator; return this; } /** * Adds a {@code PanelCreator} which the framework will use * to create a component with test UI and display it in a split pane. *

* By default, horizontal orientation is used, * and test UI is displayed to the right of the instruction UI. * * @param panelCreator a {@code PanelCreator} to create a component * with test UI * @return this builder * * @throws IllegalStateException if a {@code PanelCreator} is * already set * @throws IllegalArgumentException if {panelCreator} is {@code null} */ public Builder splitUI(PanelCreator panelCreator) { return splitUIRight(panelCreator); } /** * Adds a {@code PanelCreator} which the framework will use * to create a component with test UI and display it * to the right of instruction UI. * * @param panelCreator a {@code PanelCreator} to create a component * with test UI * @return this builder * * @throws IllegalStateException if a {@code PanelCreator} is * already set * @throws IllegalArgumentException if {panelCreator} is {@code null} */ public Builder splitUIRight(PanelCreator panelCreator) { return splitUI(panelCreator, JSplitPane.HORIZONTAL_SPLIT); } /** * Adds a {@code PanelCreator} which the framework will use * to create a component with test UI and display it * in the bottom of instruction UI. * * @param panelCreator a {@code PanelCreator} to create a component * with test UI * @return this builder * * @throws IllegalStateException if a {@code PanelCreator} is * already set * @throws IllegalArgumentException if {panelCreator} is {@code null} */ public Builder splitUIBottom(PanelCreator panelCreator) { return splitUI(panelCreator, JSplitPane.VERTICAL_SPLIT); } /** * Enables split UI and stores the orientation of the split pane. * * @param panelCreator a {@code PanelCreator} to create a component * with test UI * @param splitUIOrientation orientation of the split pane * @return this builder * * @throws IllegalStateException if a {@code PanelCreator} is * already set * @throws IllegalArgumentException if {panelCreator} is {@code null} */ private Builder splitUI(PanelCreator panelCreator, int splitUIOrientation) { if (panelCreator == null) { throw new IllegalArgumentException("A PanelCreator cannot be null"); } if (this.panelCreator != null) { throw new IllegalStateException("A PanelCreator is already set"); } splitUI = true; this.splitUIOrientation = splitUIOrientation; this.panelCreator = panelCreator; return this; } public Builder position(Position position) { this.position = position; return this; } public PassFailJFrame build() throws InterruptedException, InvocationTargetException { validate(); return new PassFailJFrame(this); } private void validate() { if (title == null) { title = TITLE; } if (instructions == null || instructions.isEmpty()) { throw new IllegalStateException("Please provide the test " + "instructions for this manual test"); } if (testTimeOut == 0L) { testTimeOut = TEST_TIMEOUT; } if (rows == 0) { rows = getDefaultRows(); } if (columns == 0) { columns = COLUMNS; } if (position == null && (testWindows != null || windowListCreator != null || (!splitUI && panelCreator != null))) { position = Position.HORIZONTAL; } if (positionWindows != null) { if (testWindows == null && windowListCreator == null) { throw new IllegalStateException("To position windows, " + "provide a list of windows to the builder"); } instructionUIHandler = new InstructionUIHandler(); } } private final class InstructionUIHandler implements InstructionUI { @Override public Point getLocation() { return frame.getLocation(); } @Override public Dimension getSize() { return frame.getSize(); } @Override public Rectangle getBounds() { return frame.getBounds(); } @Override public void setLocation(Point location) { setLocation(location.x, location.y); } @Override public void setLocation(int x, int y) { frame.setLocation(x, y); } @Override public Position getPosition() { return position; } } } /** * Creates a builder for configuring {@code PassFailJFrame}. * * @return the builder for configuring {@code PassFailJFrame} */ public static Builder builder() { return new Builder(); } }