diff --git a/pom.xml b/pom.xml index dde8433..033aa0f 100644 --- a/pom.xml +++ b/pom.xml @@ -134,6 +134,12 @@ 4.13.1 test + + org.mockito + mockito-core + 5.18.0 + test + com.flowingcode.vaadin.addons.demo commons-demo diff --git a/src/main/java/com/flowingcode/vaadin/addons/rssitems/RssItems.java b/src/main/java/com/flowingcode/vaadin/addons/rssitems/RssItems.java index cd695f1..3bd2205 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/rssitems/RssItems.java +++ b/src/main/java/com/flowingcode/vaadin/addons/rssitems/RssItems.java @@ -34,8 +34,10 @@ import com.vaadin.flow.component.HasSize; import com.vaadin.flow.component.HasStyle; import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.dependency.NpmPackage; +import com.vaadin.flow.internal.StateTree.ExecutionRegistration; /** * Simple RSS Reader component based on https://github.com/TherapyChat/rss-items @@ -54,7 +56,9 @@ public class RssItems extends Component implements HasSize, HasStyle { private boolean extractImageFromDescription; - private static final String ERROR_RSS = "\r\n" + + private ExecutionRegistration pendingRefreshRegistration; + + public static final String ERROR_RSS = "\r\n" + " \r\n" + " \r\n" + " Error Retrieving RSS\r\n" + @@ -63,7 +67,7 @@ public class RssItems extends Component implements HasSize, HasStyle { " \r\n" + " "; - private static final String IMAGE_METHOD = " $0._getItemImageScr = function (item) {\r\n" + + public static final String IMAGE_METHOD = " $0._getItemImageScr = function (item) {\r\n" + " var element = document.createElement('div');\r\n" + " element.innerHTML = item.%%ATTRIBUTE_NAME%%;\r\n" + " var image = element.querySelector('img') || {};\r\n" + @@ -72,11 +76,11 @@ public class RssItems extends Component implements HasSize, HasStyle { ""; - private static final int DEFAULT_MAX = Integer.MAX_VALUE; + public static final int DEFAULT_MAX = Integer.MAX_VALUE; - private static final int DEFAULT_MAX_TITLE_LENGTH = 50; + public static final int DEFAULT_MAX_TITLE_LENGTH = 50; - private static final int DEFAULT_MAX_EXCERPT_LENGTH = 100; + public static final int DEFAULT_MAX_EXCERPT_LENGTH = 100; /** * @param url rss feed url @@ -102,7 +106,6 @@ public RssItems(String url, int max, int maxTitleLength, int maxExcerptLength, b this.setMaxTitleLength(maxTitleLength); addClassName("x-scope"); addClassName("rss-items-0"); - refreshUrl(); } /** @@ -112,7 +115,49 @@ public RssItems(String url) { this(url,DEFAULT_MAX, DEFAULT_MAX_TITLE_LENGTH, DEFAULT_MAX_EXCERPT_LENGTH, false); } + /** + * Constructor for testing purposes. + */ + protected RssItems() { + } + + private void scheduleRefresh() { + UI ui = UI.getCurrent(); + if (ui == null) { + // If no UI is available (e.g., background thread or testing without proper UI setup), + // consider an immediate refresh or log a warning. + // For now, let's do an immediate refresh as a fallback, + // though this might not be ideal in all detached scenarios. + // This matches the original behavior more closely if UI isn't available. + refreshUrl(); + return; + } + + // If there's a pending registration, remove it. + if (pendingRefreshRegistration != null) { + try { + pendingRefreshRegistration.remove(); + } catch (Exception e) { + // Log or handle potential exceptions if .remove() fails, though typically it shouldn't. + // For example, if the registration is already inactive. + System.err.println("Error removing pending refresh registration: " + e.getMessage()); + } + pendingRefreshRegistration = null; + } + + // Schedule refreshUrl to be called before the client response. + // The lambda uiParam -> refreshUrl() is used because the consumer takes the UI as a parameter. + pendingRefreshRegistration = ui.beforeClientResponse(this, uiParam -> refreshUrl()); + } + private void refreshUrl() { + if (pendingRefreshRegistration != null) { + // If refreshUrl is called directly while a refresh was scheduled, + // the scheduled one is now effectively preempted or redundant. + // We nullify the registration to reflect this. + // Note: We don't call .remove() here as this method IS the execution (or a direct call). + pendingRefreshRegistration = null; + } try { String rss = obtainRss(url); invokeXmlToItems(rss); @@ -126,7 +171,7 @@ private void invokeXmlToItems(String rss) { this.getElement().executeJs("this.xmlToItems($0)", rss); } - private String obtainRss(String url) throws ClientProtocolException, IOException { + protected String obtainRss(String url) throws ClientProtocolException, IOException { HttpClient client = HttpClientBuilder.create().build(); HttpGet request = new HttpGet(URI.create(url)); request.addHeader("Content-Type", "application/xml"); @@ -155,7 +200,7 @@ public void setAuto(boolean auto) { */ public void setMaxTitleLength(int length) { this.getElement().setProperty("maxTitleLength", length); - refreshUrl(); + scheduleRefresh(); } /** @@ -164,7 +209,7 @@ public void setMaxTitleLength(int length) { */ public void setMaxExcerptLength(int length) { this.getElement().setProperty("maxExcerptLength", length); - refreshUrl(); + scheduleRefresh(); } /** @@ -173,12 +218,12 @@ public void setMaxExcerptLength(int length) { */ public void setMax(int max) { this.getElement().setProperty("max", max); - refreshUrl(); + scheduleRefresh(); } public void setExtractImageFromDescription(boolean extractImageFromDescription) { this.extractImageFromDescription = extractImageFromDescription; - refreshUrl(); + scheduleRefresh(); } /** @@ -188,6 +233,23 @@ public void setExtractImageFromDescription(boolean extractImageFromDescription) public void setUrl(String url) { this.getElement().setProperty("url", url); this.url = url; + scheduleRefresh(); + } + + /** + * Refreshes the RSS feed. + */ + public void refresh() { + if (pendingRefreshRegistration != null) { + try { + pendingRefreshRegistration.remove(); + } catch (Exception e) { + // Log or handle potential exceptions. + System.err.println("Error removing pending refresh registration during manual refresh: " + e.getMessage()); + } + pendingRefreshRegistration = null; + } + refreshUrl(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/rssitems/RssItemsTest.java b/src/test/java/com/flowingcode/vaadin/addons/rssitems/RssItemsTest.java new file mode 100644 index 0000000..743c4d5 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/rssitems/RssItemsTest.java @@ -0,0 +1,251 @@ +package com.flowingcode.vaadin.addons.rssitems; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.ArgumentMatchers.any; + +import java.io.IOException; +import java.util.Optional; +import org.apache.http.client.ClientProtocolException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.internal.StateTree; + +public class RssItemsTest { + + private RssItems rssItemsSpy; + private static final String INITIAL_URL = "http://example.com/rss"; + private static final String ANOTHER_URL = "http://another.example.com/rss"; + private static final String CONSTRUCTOR_TEST_URL = "http://constructortest.com/rss"; + private static final String DUMMY_RSS_XML = "Test"; + + @Mock + private Element elementMock; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private UI uiMock; + + @Captor + private ArgumentCaptor> consumerCaptor; + + @Mock + private StateTree.ExecutionRegistration mockExecutionRegistration; + + private MockedStatic staticUiMock; + + @Before + public void setUp() throws ClientProtocolException, IOException { + MockitoAnnotations.openMocks(this); + + RssItems realRssItems = new RssItems(); + rssItemsSpy = spy(realRssItems); + + when(rssItemsSpy.getElement()).thenReturn(elementMock); + when(elementMock.getUI()).thenReturn(Optional.of(uiMock)); + + when(elementMock.setProperty(anyString(), anyString())).thenReturn(elementMock); + when(elementMock.setProperty(anyString(), anyBoolean())).thenReturn(elementMock); + when(elementMock.setProperty(anyString(), anyInt())).thenReturn(elementMock); + + doNothing().when(rssItemsSpy).invokeXmlToItems(anyString()); + doReturn(DUMMY_RSS_XML).when(rssItemsSpy).obtainRss(anyString()); + + // Stub beforeClientResponse for the main uiMock. + // This will be used by the rssItemsSpy instance managed by most tests. + // Note: if a test needs to verify .remove() on a specific registration, + // it might need to set up its own uiMock.beforeClientResponse interaction. + when(uiMock.beforeClientResponse(any(RssItems.class), consumerCaptor.capture())) + .thenReturn(mockExecutionRegistration); + } + + private void setupStaticUiMock() { + if (staticUiMock == null || staticUiMock.isClosed()) { + staticUiMock = Mockito.mockStatic(UI.class); + } + staticUiMock.when(UI::getCurrent).thenReturn(uiMock); + } + + @After + public void tearDown() { + if (staticUiMock != null && !staticUiMock.isClosed()) { + staticUiMock.close(); + } + } + + private void runLastScheduledRefresh(ArgumentCaptor> captor) { + if (!captor.getAllValues().isEmpty()) { + captor.getValue().accept(uiMock); + } + } + + private void prepareForTestWithInitialUrl(String url) throws ClientProtocolException, IOException { + setupStaticUiMock(); + // Set an initial URL for the spy to ensure obtainRss is called with a predictable URL if refresh happens + rssItemsSpy.setUrl(url); // This will call scheduleRefresh, consumerCaptor will get it. + runLastScheduledRefresh(consumerCaptor); // Run this initial refresh. + + Mockito.clearInvocations(rssItemsSpy, elementMock, uiMock, mockExecutionRegistration); + + // Re-stub beforeClientResponse for the actual test part, ensuring the captor is fresh for the setter + when(uiMock.beforeClientResponse(any(RssItems.class), consumerCaptor.capture())).thenReturn(mockExecutionRegistration); + // Re-stub obtainRss for the specific URL expected in the test + doReturn(DUMMY_RSS_XML).when(rssItemsSpy).obtainRss(url); + } + + @Test + public void testSetMaxTitleLengthSchedulesRefreshButDoesNotRunImmediately() throws ClientProtocolException, IOException { + prepareForTestWithInitialUrl(INITIAL_URL); + rssItemsSpy.setMaxTitleLength(100); + verify(rssItemsSpy, never()).obtainRss(anyString()); + runLastScheduledRefresh(consumerCaptor); + verify(rssItemsSpy, times(1)).obtainRss(INITIAL_URL); + } + + @Test + public void testSetMaxExcerptLengthSchedulesRefreshButDoesNotRunImmediately() throws ClientProtocolException, IOException { + prepareForTestWithInitialUrl(INITIAL_URL); + rssItemsSpy.setMaxExcerptLength(200); + verify(rssItemsSpy, never()).obtainRss(anyString()); + runLastScheduledRefresh(consumerCaptor); + verify(rssItemsSpy, times(1)).obtainRss(INITIAL_URL); + } + + @Test + public void testSetMaxSchedulesRefreshButDoesNotRunImmediately() throws ClientProtocolException, IOException { + prepareForTestWithInitialUrl(INITIAL_URL); + rssItemsSpy.setMax(10); + verify(rssItemsSpy, never()).obtainRss(anyString()); + runLastScheduledRefresh(consumerCaptor); + verify(rssItemsSpy, times(1)).obtainRss(INITIAL_URL); + } + + @Test + public void testSetExtractImageFromDescriptionSchedulesRefreshButDoesNotRunImmediately() throws ClientProtocolException, IOException { + prepareForTestWithInitialUrl(INITIAL_URL); + rssItemsSpy.setExtractImageFromDescription(true); + verify(rssItemsSpy, never()).obtainRss(anyString()); + runLastScheduledRefresh(consumerCaptor); + verify(rssItemsSpy, times(1)).obtainRss(INITIAL_URL); + } + + @Test + public void testMultipleSettersScheduleOnlyOneRefresh() throws ClientProtocolException, IOException { + prepareForTestWithInitialUrl(INITIAL_URL); + + rssItemsSpy.setMax(10); + verify(uiMock, times(1)).beforeClientResponse(any(RssItems.class), consumerCaptor.capture()); + + rssItemsSpy.setMaxTitleLength(100); + verify(uiMock, times(2)).beforeClientResponse(any(RssItems.class), consumerCaptor.capture()); + // The first registration (from setMax) should have been removed by the second call (setMaxTitleLength) + verify(mockExecutionRegistration, times(1)).remove(); + + verify(rssItemsSpy, never()).obtainRss(anyString()); + runLastScheduledRefresh(consumerCaptor); + verify(rssItemsSpy, times(1)).obtainRss(INITIAL_URL); + } + + @Test + public void testSetUrlSchedulesRefresh() throws ClientProtocolException, IOException { + setupStaticUiMock(); + Mockito.clearInvocations(rssItemsSpy, uiMock, mockExecutionRegistration); + when(uiMock.beforeClientResponse(any(RssItems.class), consumerCaptor.capture())).thenReturn(mockExecutionRegistration); + doReturn(DUMMY_RSS_XML).when(rssItemsSpy).obtainRss(ANOTHER_URL); + + rssItemsSpy.setUrl(ANOTHER_URL); + verify(rssItemsSpy, never()).obtainRss(ANOTHER_URL); + runLastScheduledRefresh(consumerCaptor); + verify(rssItemsSpy, times(1)).obtainRss(ANOTHER_URL); + } + + @Test + public void testPublicRefreshCancelsScheduledAndRefreshesImmediately() throws ClientProtocolException, IOException { + setupStaticUiMock(); + rssItemsSpy.setUrl(INITIAL_URL); + runLastScheduledRefresh(consumerCaptor); // Run refresh from initial setUrl + Mockito.clearInvocations(rssItemsSpy, uiMock, mockExecutionRegistration); + + ExecutionRegistration localSpecificMockRegistration = mock(StateTree.ExecutionRegistration.class); + when(uiMock.beforeClientResponse(any(RssItems.class), consumerCaptor.capture())) + .thenReturn(localSpecificMockRegistration); // Use a specific registration for this test + doReturn(DUMMY_RSS_XML).when(rssItemsSpy).obtainRss(INITIAL_URL); + + rssItemsSpy.setMax(10); // Schedules a refresh, consumer captured by class-level captor + verify(uiMock, times(1)).beforeClientResponse(any(RssItems.class), any()); + verify(rssItemsSpy, never()).obtainRss(anyString()); + + rssItemsSpy.refresh(); // Public, immediate refresh + + verify(rssItemsSpy, times(1)).obtainRss(INITIAL_URL); + verify(localSpecificMockRegistration, times(1)).remove(); + + Mockito.clearInvocations(rssItemsSpy); + runLastScheduledRefresh(consumerCaptor); + verify(rssItemsSpy, never()).obtainRss(anyString()); + } + + @Test + public void testConstructorWithUrlSchedulesRefresh() throws ClientProtocolException, IOException { + try (MockedStatic staticUiForCtor = Mockito.mockStatic(UI.class)) { + UI localConstructorUiMock = mock(UI.class, Answers.RETURNS_DEEP_STUBS); + staticUiForCtor.when(UI::getCurrent).thenReturn(localConstructorUiMock); + + ArgumentCaptor> ctorConsumerCaptor = ArgumentCaptor.forClass(SerializableConsumer.class); + StateTree.ExecutionRegistration ctorMockRegistration = mock(StateTree.ExecutionRegistration.class); + when(localConstructorUiMock.beforeClientResponse(any(RssItems.class), ctorConsumerCaptor.capture())) + .thenReturn(ctorMockRegistration); + + RssItems items = new RssItems(); + RssItems constructorTestSpy = spy(items); + + Element localElementMock = mock(Element.class); + when(constructorTestSpy.getElement()).thenReturn(localElementMock); + when(localElementMock.getUI()).thenReturn(Optional.of(localConstructorUiMock)); + + when(localElementMock.setProperty(anyString(), anyString())).thenReturn(localElementMock); + when(localElementMock.setProperty(anyString(), anyBoolean())).thenReturn(localElementMock); + when(localElementMock.setProperty(anyString(), anyInt())).thenReturn(localElementMock); + doNothing().when(constructorTestSpy).invokeXmlToItems(anyString()); + doReturn(DUMMY_RSS_XML).when(constructorTestSpy).obtainRss(CONSTRUCTOR_TEST_URL); + + // Simulate the sequence of calls made by RssItems(CONSTRUCTOR_TEST_URL) + // This constructor calls this(url, DEFAULT_MAX, ...) which calls setters. + // Each of these (setUrl, setMax, etc.) calls scheduleRefresh. + // The *last* call to scheduleRefresh is the one that persists. + constructorTestSpy.setUrl(CONSTRUCTOR_TEST_URL); + constructorTestSpy.setAuto(true); + constructorTestSpy.setMax(RssItems.DEFAULT_MAX); + constructorTestSpy.setMaxExcerptLength(RssItems.DEFAULT_MAX_EXCERPT_LENGTH); + constructorTestSpy.setMaxTitleLength(RssItems.DEFAULT_MAX_TITLE_LENGTH); + + verify(constructorTestSpy, never()).obtainRss(anyString()); + + if (!ctorConsumerCaptor.getAllValues().isEmpty()) { + ctorConsumerCaptor.getValue().accept(localConstructorUiMock); + } + + verify(constructorTestSpy, times(1)).obtainRss(CONSTRUCTOR_TEST_URL); + } + } +}