diff --git a/pom.xml b/pom.xml index 69688d2..e0f2201 100644 --- a/pom.xml +++ b/pom.xml @@ -109,13 +109,8 @@ **/AppCommandLineRunner* - **/ExamplesCommandLineRunner* - **/ProducerConsumerExampleRunner* - **/Consumer* **/JavaPatternsAndConstructsApplication* **/MainWindow* - **/HyperlinkMouseListener* - **/ObservableAbstract* diff --git a/src/main/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListener.java b/src/main/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListener.java index 4c2bcdc..fc93566 100644 --- a/src/main/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListener.java +++ b/src/main/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListener.java @@ -17,23 +17,34 @@ public class HyperlinkMouseListener implements MouseListener { - private Logger log = LoggerFactory.getLogger(HyperlinkMouseListener.class); + /** Opens a link in the user's browser. Replaceable in tests, where there is no desktop. */ + @FunctionalInterface + interface LinkOpener { + void open(URI uri) throws IOException; + } - ApplicationProperties props; + private static final Logger log = LoggerFactory.getLogger(HyperlinkMouseListener.class); + private final ApplicationProperties props; + private final LinkOpener linkOpener; private String lastText; public HyperlinkMouseListener(ApplicationProperties props) { + this(props, uri -> Desktop.getDesktop().browse(uri)); + } + + HyperlinkMouseListener(ApplicationProperties props, LinkOpener linkOpener) { this.props = props; + this.linkOpener = linkOpener; } @Override public void mouseClicked(MouseEvent e) { log.debug("Hyperlink text: " + lastText); try { - Desktop.getDesktop().browse(new URI(lastText)); - } catch (IOException | URISyntaxException e1) { - log.error("Error opening link", e); + linkOpener.open(new URI(lastText)); + } catch (IOException | URISyntaxException | RuntimeException ex) { + log.error("Error opening link", ex); } } @@ -53,11 +64,12 @@ public void mouseExited(MouseEvent e) { } @Override - public void mousePressed(MouseEvent arg0) { + public void mousePressed(MouseEvent e) { + // Nothing to do: the link opens on click. } @Override - public void mouseReleased(MouseEvent arg0) { + public void mouseReleased(MouseEvent e) { + // Nothing to do: the link opens on click. } - } diff --git a/src/main/java/com/penapereira/example/constructs/producerconsumer/Consumer.java b/src/main/java/com/penapereira/example/constructs/producerconsumer/Consumer.java index 78e9c91..4e110d0 100644 --- a/src/main/java/com/penapereira/example/constructs/producerconsumer/Consumer.java +++ b/src/main/java/com/penapereira/example/constructs/producerconsumer/Consumer.java @@ -9,24 +9,27 @@ public class Consumer implements Runnable { private static final Logger log = LoggerFactory.getLogger(Consumer.class); - private BlockingQueue queue; - - private int myId; + private final BlockingQueue queue; + private final int myId; public Consumer(BlockingQueue blockingQueue, int myId) { this.queue = blockingQueue; this.myId = myId; } + /** + * Consumes integers until the thread is interrupted, which is how the + * example runner asks the consumers to stop once the producer is done. + */ @Override public void run() { - while (true) { + while (!Thread.currentThread().isInterrupted()) { try { var consumed = queue.take(); log.trace(String.format(" %d: Consumed [%2d]", myId, consumed)); } catch (InterruptedException finish) { + Thread.currentThread().interrupt(); } } } - } diff --git a/src/main/java/com/penapereira/example/constructs/producerconsumer/ProducerConsumerExampleRunner.java b/src/main/java/com/penapereira/example/constructs/producerconsumer/ProducerConsumerExampleRunner.java index 72e2185..76f6c3f 100644 --- a/src/main/java/com/penapereira/example/constructs/producerconsumer/ProducerConsumerExampleRunner.java +++ b/src/main/java/com/penapereira/example/constructs/producerconsumer/ProducerConsumerExampleRunner.java @@ -18,20 +18,26 @@ public class ProducerConsumerExampleRunner implements ExampleRunnerInterface { private static final Logger log = LoggerFactory.getLogger(ProducerConsumerExampleRunner.class); @Override - public void runExample() throws InterruptedException { + public void runExample() throws Exception { log.trace("Executing Producer/Consumer implementation:"); - - BlockingQueue blockingQueue = new LinkedBlockingDeque(3); + BlockingQueue blockingQueue = new LinkedBlockingDeque<>(3); ExecutorService executor = Executors.newFixedThreadPool(3); - - Consumer consumer1 = new Consumer(blockingQueue, 1); - Consumer consumer2 = new Consumer(blockingQueue, 2); - Producer producer = new Producer(blockingQueue); - - executor.execute(consumer1); - executor.execute(consumer2); - executor.execute(producer); - - executor.awaitTermination(1, TimeUnit.SECONDS); + try { + executor.execute(new Consumer(blockingQueue, 1)); + executor.execute(new Consumer(blockingQueue, 2)); + // Wait until the producer has put every item in the queue... + executor.submit(new Producer(blockingQueue)).get(); + // ...and until the consumers have taken all of them. + while (!blockingQueue.isEmpty()) { + Thread.sleep(10); + } + } finally { + // Consumers block forever waiting for more work; interrupt them so + // the pool threads do not leak after the example finishes. + executor.shutdownNow(); + if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { + log.warn("Consumers did not stop in time"); + } + } } } diff --git a/src/test/java/com/penapereira/example/constructs/app/ExamplesCommandLineRunnerTests.java b/src/test/java/com/penapereira/example/constructs/app/ExamplesCommandLineRunnerTests.java new file mode 100644 index 0000000..ed71d4d --- /dev/null +++ b/src/test/java/com/penapereira/example/constructs/app/ExamplesCommandLineRunnerTests.java @@ -0,0 +1,103 @@ +package com.penapereira.example.constructs.app; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.context.support.GenericApplicationContext; + +import com.penapereira.example.constructs.app.properties.ApplicationProperties; +import com.penapereira.example.constructs.app.properties.Messages; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +class ExamplesCommandLineRunnerTests { + + private final Logger runnerLog = (Logger) LoggerFactory.getLogger(ExamplesCommandLineRunner.class); + private final Level originalLevel = runnerLog.getLevel(); + private final ListAppender logged = new ListAppender<>(); + + private final AtomicInteger executions = new AtomicInteger(); + private GenericApplicationContext ctx; + private ApplicationProperties props; + private Messages msg; + + @BeforeEach + void setUp() { + logged.start(); + runnerLog.addAppender(logged); + + ctx = new GenericApplicationContext(); + ctx.registerBean("fakeExampleRunner", ExampleRunnerInterface.class, () -> executions::incrementAndGet); + ctx.refresh(); + + props = new ApplicationProperties(); + msg = new Messages(); + msg.setExamplesFound("Examples"); + msg.setSeparator("---"); + msg.setEnableTraceToSeeExamplesDetails("enable trace"); + msg.setEnableDebugToSeeExamplesList("enable debug"); + } + + @AfterEach + void tearDown() { + runnerLog.detachAppender(logged); + runnerLog.setLevel(originalLevel); + ctx.close(); + } + + private ExamplesCommandLineRunner runner() { + return new ExamplesCommandLineRunner(ctx, msg, props); + } + + private boolean logged(String text) { + return logged.list.stream().anyMatch(e -> e.getFormattedMessage().contains(text)); + } + + @Test + void doesNothingWhenDisabled() throws Exception { + props.setEnableCommandLineRunner(false); + + runner().run(); + + assertEquals(0, executions.get()); + assertTrue(logged.list.isEmpty()); + } + + @Test + void listsAndExecutesExamplesWhenTraceIsEnabled() throws Exception { + props.setEnableCommandLineRunner(true); + runnerLog.setLevel(Level.TRACE); + + runner().run(); + + assertEquals(1, executions.get()); + assertTrue(logged("Examples (ExampleRunnerInterface):")); + assertTrue(logged(" 1 : fake")); + assertTrue(logged("---")); + assertFalse(logged("enable trace")); + assertFalse(logged("enable debug")); + } + + @Test + void tellsHowToSeeDetailsWhenLogLevelIsTooHigh() throws Exception { + props.setEnableCommandLineRunner(true); + runnerLog.setLevel(Level.INFO); + + runner().run(); + + assertEquals(1, executions.get()); + assertTrue(logged("enable debug")); + assertTrue(logged("enable trace")); + assertFalse(logged(" 1 : fake")); + } +} diff --git a/src/test/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListenerTests.java b/src/test/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListenerTests.java index 150228c..995bd9c 100644 --- a/src/test/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListenerTests.java +++ b/src/test/java/com/penapereira/example/constructs/app/ui/HyperlinkMouseListenerTests.java @@ -4,6 +4,9 @@ import java.awt.Color; import java.awt.event.MouseEvent; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.atomic.AtomicReference; import javax.swing.JLabel; @@ -30,4 +33,52 @@ void hyperlinkChangesColorAndText() { assertEquals("http://example.com", label.getText()); assertEquals(Color.decode(props.getLinkColor()), label.getForeground()); } + + private static ApplicationProperties props() { + ApplicationProperties props = new ApplicationProperties(); + props.setLinkColor("#000000"); + props.setLinkColorHover("#ffffff"); + return props; + } + + private static MouseEvent eventOn(JLabel label, int id) { + return new MouseEvent(label, id, 0, 0, 0, 0, 1, false); + } + + @Test + void clickOpensTheLinkShownWhenTheMouseEntered() { + AtomicReference opened = new AtomicReference<>(); + HyperlinkMouseListener listener = new HyperlinkMouseListener(props(), opened::set); + JLabel label = new JLabel("http://example.com"); + + listener.mouseEntered(eventOn(label, MouseEvent.MOUSE_ENTERED)); + listener.mousePressed(eventOn(label, MouseEvent.MOUSE_PRESSED)); + listener.mouseReleased(eventOn(label, MouseEvent.MOUSE_RELEASED)); + listener.mouseClicked(eventOn(label, MouseEvent.MOUSE_CLICKED)); + + assertEquals(URI.create("http://example.com"), opened.get()); + } + + @Test + void clickOnMalformedLinkIsLoggedNotThrown() { + AtomicReference opened = new AtomicReference<>(); + HyperlinkMouseListener listener = new HyperlinkMouseListener(props(), opened::set); + JLabel label = new JLabel("http://exa mple.com"); + + listener.mouseEntered(eventOn(label, MouseEvent.MOUSE_ENTERED)); + assertDoesNotThrow(() -> listener.mouseClicked(eventOn(label, MouseEvent.MOUSE_CLICKED))); + + assertNull(opened.get()); + } + + @Test + void failureToOpenTheBrowserIsLoggedNotThrown() { + HyperlinkMouseListener listener = new HyperlinkMouseListener(props(), uri -> { + throw new IOException("no browser"); + }); + JLabel label = new JLabel("http://example.com"); + + listener.mouseEntered(eventOn(label, MouseEvent.MOUSE_ENTERED)); + assertDoesNotThrow(() -> listener.mouseClicked(eventOn(label, MouseEvent.MOUSE_CLICKED))); + } } diff --git a/src/test/java/com/penapereira/example/constructs/observer/ObserverTests.java b/src/test/java/com/penapereira/example/constructs/observer/ObserverTests.java index b095c25..e8f6277 100644 --- a/src/test/java/com/penapereira/example/constructs/observer/ObserverTests.java +++ b/src/test/java/com/penapereira/example/constructs/observer/ObserverTests.java @@ -31,4 +31,16 @@ void listenerCanBeRemoved() { subject.doSomethingWith(1); // should not throw assertEquals(0, subject.getSupport().getPropertyChangeListeners().length); } + + @Test + void allListenersCanBeRemovedAtOnce() { + Observable subject = new Observable(); + subject.addPropertyChangeListener(new Observer()); + subject.addPropertyChangeListener(event -> {}); + assertEquals(2, subject.getSupport().getPropertyChangeListeners().length); + + subject.removeAllListeners(); + + assertEquals(0, subject.getSupport().getPropertyChangeListeners().length); + } } diff --git a/src/test/java/com/penapereira/example/constructs/producerconsumer/ConsumerTests.java b/src/test/java/com/penapereira/example/constructs/producerconsumer/ConsumerTests.java new file mode 100644 index 0000000..6f8a99c --- /dev/null +++ b/src/test/java/com/penapereira/example/constructs/producerconsumer/ConsumerTests.java @@ -0,0 +1,50 @@ +package com.penapereira.example.constructs.producerconsumer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; + +import org.junit.jupiter.api.Test; + +class ConsumerTests { + + private static void waitUntilBlockedOnQueue(Thread t) { + while (t.getState() != Thread.State.WAITING) { + Thread.onSpinWait(); + } + } + + @Test + void consumesEverythingAndStopsWhenInterrupted() throws InterruptedException { + BlockingQueue queue = new LinkedBlockingDeque<>(); + queue.put(1); + queue.put(2); + Thread t = new Thread(new Consumer(queue, 1)); + + t.start(); + waitUntilBlockedOnQueue(t); + assertTrue(queue.isEmpty()); + + t.interrupt(); + t.join(5_000); + assertFalse(t.isAlive()); + } + + @Test + void stopsImmediatelyWhenInterruptedBeforeStarting() throws InterruptedException { + BlockingQueue queue = new LinkedBlockingDeque<>(); + queue.put(1); + Thread t = new Thread(() -> { + Thread.currentThread().interrupt(); + new Consumer(queue, 2).run(); + }); + + t.start(); + t.join(5_000); + + assertFalse(t.isAlive()); + assertFalse(queue.isEmpty()); + } +} diff --git a/src/test/java/com/penapereira/example/constructs/producerconsumer/ProducerConsumerExampleRunnerTests.java b/src/test/java/com/penapereira/example/constructs/producerconsumer/ProducerConsumerExampleRunnerTests.java new file mode 100644 index 0000000..4c3dcd3 --- /dev/null +++ b/src/test/java/com/penapereira/example/constructs/producerconsumer/ProducerConsumerExampleRunnerTests.java @@ -0,0 +1,31 @@ +package com.penapereira.example.constructs.producerconsumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +class ProducerConsumerExampleRunnerTests { + + private static long consumerThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(t -> t.isAlive() && t.getName().startsWith("pool-")) + .count(); + } + + @Test + void runsToCompletionAndStopsItsThreads() { + long before = consumerThreads(); + + assertTimeoutPreemptively(Duration.ofSeconds(10), () -> new ProducerConsumerExampleRunner().runExample()); + + // Worker threads may still be exiting right after the pool reports termination. + long deadline = System.currentTimeMillis() + 5_000; + while (consumerThreads() != before && System.currentTimeMillis() < deadline) { + Thread.onSpinWait(); + } + assertEquals(before, consumerThreads()); + } +}