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);
+ }
+ }
+}