[feed] Minor improvements for Feed Binding (#8824)

Signed-off-by: Christoph Weitkamp <github@christophweitkamp.de>
This commit is contained in:
Christoph Weitkamp 2020-10-21 17:53:46 +02:00 committed by GitHub
parent 44e3f9c90f
commit 333cae9e72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 91 additions and 87 deletions

View File

@ -4,9 +4,7 @@ This binding allows you to integrate feeds in the openHAB environment.
The Feed binding downloads the content, tracks for changes, and displays information like feed author, feed title and description, number of entries, last update date. The Feed binding downloads the content, tracks for changes, and displays information like feed author, feed title and description, number of entries, last update date.
It can be used in combination with openHAB rules to trigger events on feed change. It can be used in combination with openHAB rules to trigger events on feed change.
It uses the [ROME library](https://rometools.github.io/rome/index.html) for parsing It uses the [ROME library](https://rometools.github.io/rome/index.html) for parsing and supports a wide range of popular feed formats - RSS 2.00, RSS 1.00, RSS 0.94, RSS 0.93, RSS 0.92, RSS 0.91 UserLand, RSS 0.91 Netscape, RSS 0.90, Atom 1.0, Atom 0.3.
and supports a wide range of popular feed formats - RSS 2.00, RSS 1.00, RSS 0.94, RSS 0.93, RSS 0.92, RSS 0.91 UserLand,
RSS 0.91 Netscape, RSS 0.90, Atom 1.0, Atom 0.3.
## Supported Things ## Supported Things
@ -24,11 +22,11 @@ No binding configuration required.
Required configuration: Required configuration:
- **URL** - the URL of the feed (e.g <http://example.com/path/file>). The binding uses this URL to download data - **URL** - the URL of the feed (e.g <http://example.com/path/file>). The binding uses this URL to download data.
Optional configuration: Optional configuration:
- **refresh** - a refresh interval defines after how many minutes the binding will check, if new content is available. Default value is 20 minutes - **refresh** - a refresh interval defines after how many minutes the binding will check, if new content is available. Default value is 20 minutes.
## Channels ## Channels
@ -39,18 +37,18 @@ The binding supports following channels
| latest-title | String | Contains the title of the last feed entry. | | latest-title | String | Contains the title of the last feed entry. |
| latest-description | String | Contains the description of last feed entry. | | latest-description | String | Contains the description of last feed entry. |
| latest-date | DateTime | Contains the published date of the last feed entry. | | latest-date | DateTime | Contains the published date of the last feed entry. |
| author | String | The name of the feed author, if author is present | | author | String | The name of the feed author, if author is present. |
| title | String | The title of the feed | | title | String | The title of the feed. |
| description | String | Description of the feed | | description | String | Description of the feed. |
| last-update | DateTime | The last update date of the feed | | last-update | DateTime | The last update date of the feed. |
| number-of-entries | Number | Number of entries in the feed | | number-of-entries | Number | Number of entries in the feed. |
## Example ## Example
Things: Things:
```java ```java
feed:feed:bbc [ URL="http://feeds.bbci.co.uk/news/video_and_audio/news_front_page/rss.xml?edition=uk"] feed:feed:bbc [ URL="http://feeds.bbci.co.uk/news/video_and_audio/news_front_page/rss.xml?edition=uk"]
feed:feed:techCrunch [ URL="http://feeds.feedburner.com/TechCrunch/", refresh=60] feed:feed:techCrunch [ URL="http://feeds.feedburner.com/TechCrunch/", refresh=60]
``` ```

View File

@ -12,8 +12,6 @@
*/ */
package org.openhab.binding.feed.internal; package org.openhab.binding.feed.internal;
import java.math.BigDecimal;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingTypeUID;
@ -86,11 +84,11 @@ public class FeedBindingConstants {
/** /**
* The default auto refresh time in minutes. * The default auto refresh time in minutes.
*/ */
public static final BigDecimal DEFAULT_REFRESH_TIME = new BigDecimal(20); public static final long DEFAULT_REFRESH_TIME = 20;
/** /**
* The minimum refresh time in milliseconds. Any REFRESH command send to a Thing, before this time has expired, will * The minimum refresh time in milliseconds. Any REFRESH command send to a Thing, before this time has expired, will
* not trigger an attempt to dowload new data form the server. * not trigger an attempt to download new data from the server.
**/ **/
public static final int MINIMUM_REFRESH_TIME = 3000; public static final int MINIMUM_REFRESH_TIME = 3000;
} }

View File

@ -14,9 +14,10 @@ package org.openhab.binding.feed.internal;
import static org.openhab.binding.feed.internal.FeedBindingConstants.FEED_THING_TYPE_UID; import static org.openhab.binding.feed.internal.FeedBindingConstants.FEED_THING_TYPE_UID;
import java.util.Collections;
import java.util.Set; import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.feed.internal.handler.FeedHandler; import org.openhab.binding.feed.internal.handler.FeedHandler;
import org.openhab.core.thing.Thing; import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingTypeUID;
@ -32,9 +33,10 @@ import org.osgi.service.component.annotations.Component;
* @author Svilen Valkanov - Initial contribution * @author Svilen Valkanov - Initial contribution
*/ */
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.feed") @Component(service = ThingHandlerFactory.class, configurationPid = "binding.feed")
@NonNullByDefault
public class FeedHandlerFactory extends BaseThingHandlerFactory { public class FeedHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(FEED_THING_TYPE_UID); private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(FEED_THING_TYPE_UID);
@Override @Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) { public boolean supportsThingType(ThingTypeUID thingTypeUID) {
@ -42,7 +44,7 @@ public class FeedHandlerFactory extends BaseThingHandlerFactory {
} }
@Override @Override
protected ThingHandler createHandler(Thing thing) { protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID(); ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(FEED_THING_TYPE_UID)) { if (thingTypeUID.equals(FEED_THING_TYPE_UID)) {

View File

@ -29,11 +29,12 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPInputStream; import java.util.zip.GZIPInputStream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.Configuration; import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing; import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatus;
@ -57,73 +58,80 @@ import com.rometools.rome.io.SyndFeedInput;
* *
* @author Svilen Valkanov - Initial contribution * @author Svilen Valkanov - Initial contribution
*/ */
@NonNullByDefault
public class FeedHandler extends BaseThingHandler { public class FeedHandler extends BaseThingHandler {
private Logger logger = LoggerFactory.getLogger(FeedHandler.class); private final Logger logger = LoggerFactory.getLogger(FeedHandler.class);
private String urlString; private @Nullable URL url;
private BigDecimal refreshTime; private long refreshTime;
private ScheduledFuture<?> refreshTask; private @Nullable ScheduledFuture<?> refreshTask;
private SyndFeed currentFeedState; private @Nullable SyndFeed currentFeedState;
private long lastRefreshTime; private long lastRefreshTime;
public FeedHandler(Thing thing) { public FeedHandler(Thing thing) {
super(thing); super(thing);
currentFeedState = null;
} }
@Override @Override
public void initialize() { public void initialize() {
checkConfiguration(); if (checkConfiguration()) {
updateStatus(ThingStatus.UNKNOWN); updateStatus(ThingStatus.UNKNOWN);
startAutomaticRefresh(); startAutomaticRefresh();
}
} }
/** /**
* This method checks if the provided configuration is valid. * This method checks if the provided configuration is valid.
* When invalid parameter is found, default value is assigned. * When invalid parameter is found, default value is assigned.
*/ */
private void checkConfiguration() { private boolean checkConfiguration() {
logger.debug("Start reading Feed Thing configuration."); logger.debug("Start reading Feed Thing configuration.");
Configuration configuration = getConfig(); Configuration configuration = getConfig();
// It is not necessary to check if the URL is valid, this will be done in fetchFeedData() method // It is not necessary to check if the URL is valid, this will be done in fetchFeedData() method
urlString = (String) configuration.get(URL); String urlString = (String) configuration.get(URL);
try { try {
refreshTime = (BigDecimal) configuration.get(REFRESH_TIME); url = new URL(urlString);
if (refreshTime.intValue() <= 0) { } catch (MalformedURLException e) {
logger.warn("Url '{}' is not valid: ", urlString, e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
return false;
}
BigDecimal localRefreshTime = null;
try {
localRefreshTime = (BigDecimal) configuration.get(REFRESH_TIME);
if (localRefreshTime.intValue() <= 0) {
throw new IllegalArgumentException("Refresh time must be positive number!"); throw new IllegalArgumentException("Refresh time must be positive number!");
} }
refreshTime = localRefreshTime.longValue();
} catch (Exception e) { } catch (Exception e) {
logger.warn("Refresh time [{}] is not valid. Falling back to default value: {}. {}", refreshTime, logger.warn("Refresh time [{}] is not valid. Falling back to default value: {}. {}", localRefreshTime,
DEFAULT_REFRESH_TIME, e.getMessage()); DEFAULT_REFRESH_TIME, e.getMessage());
refreshTime = DEFAULT_REFRESH_TIME; refreshTime = DEFAULT_REFRESH_TIME;
} }
return true;
} }
private void startAutomaticRefresh() { private void startAutomaticRefresh() {
refreshTask = scheduler.scheduleWithFixedDelay(this::refreshFeedState, 0, refreshTime.intValue(), refreshTask = scheduler.scheduleWithFixedDelay(this::refreshFeedState, 0, refreshTime, TimeUnit.MINUTES);
TimeUnit.MINUTES); logger.debug("Start automatic refresh at {} minutes!", refreshTime);
logger.debug("Start automatic refresh at {} minutes", refreshTime.intValue());
} }
private void refreshFeedState() { private void refreshFeedState() {
SyndFeed feed = fetchFeedData(urlString); SyndFeed feed = fetchFeedData();
boolean feedUpdated = updateFeedIfChanged(feed); boolean feedUpdated = updateFeedIfChanged(feed);
if (feedUpdated) { if (feedUpdated) {
List<Channel> channels = getThing().getChannels(); getThing().getChannels().forEach(channel -> publishChannelIfLinked(channel.getUID()));
for (Channel channel : channels) {
publishChannelIfLinked(channel.getUID());
}
} }
} }
private void publishChannelIfLinked(ChannelUID channelUID) { private void publishChannelIfLinked(ChannelUID channelUID) {
String channelID = channelUID.getId(); String channelID = channelUID.getId();
if (currentFeedState == null) { SyndFeed feedState = currentFeedState;
if (feedState == null) {
// This will happen if the binding could not download data from the server // This will happen if the binding could not download data from the server
logger.trace("Cannot update channel with ID {}; no data has been downloaded from the server!", channelID); logger.trace("Cannot update channel with ID {}; no data has been downloaded from the server!", channelID);
return; return;
@ -135,7 +143,7 @@ public class FeedHandler extends BaseThingHandler {
} }
State state = null; State state = null;
SyndEntry latestEntry = getLatestEntry(currentFeedState); SyndEntry latestEntry = getLatestEntry(feedState);
switch (channelID) { switch (channelID) {
case CHANNEL_LATEST_TITLE: case CHANNEL_LATEST_TITLE:
@ -166,19 +174,19 @@ public class FeedHandler extends BaseThingHandler {
} }
break; break;
case CHANNEL_AUTHOR: case CHANNEL_AUTHOR:
String author = currentFeedState.getAuthor(); String author = feedState.getAuthor();
state = new StringType(getValueSafely(author)); state = new StringType(getValueSafely(author));
break; break;
case CHANNEL_DESCRIPTION: case CHANNEL_DESCRIPTION:
String channelDescription = currentFeedState.getDescription(); String channelDescription = feedState.getDescription();
state = new StringType(getValueSafely(channelDescription)); state = new StringType(getValueSafely(channelDescription));
break; break;
case CHANNEL_TITLE: case CHANNEL_TITLE:
String channelTitle = currentFeedState.getTitle(); String channelTitle = feedState.getTitle();
state = new StringType(getValueSafely(channelTitle)); state = new StringType(getValueSafely(channelTitle));
break; break;
case CHANNEL_NUMBER_OF_ENTRIES: case CHANNEL_NUMBER_OF_ENTRIES:
int numberOfEntries = currentFeedState.getEntries().size(); int numberOfEntries = feedState.getEntries().size();
state = new DecimalType(numberOfEntries); state = new DecimalType(numberOfEntries);
break; break;
default: default:
@ -200,7 +208,7 @@ public class FeedHandler extends BaseThingHandler {
* @return <code>true</code> if new content is available on the server since the last update or <code>false</code> * @return <code>true</code> if new content is available on the server since the last update or <code>false</code>
* otherwise * otherwise
*/ */
private synchronized boolean updateFeedIfChanged(SyndFeed newFeedState) { private synchronized boolean updateFeedIfChanged(@Nullable SyndFeed newFeedState) {
// SyndFeed class has implementation of equals () // SyndFeed class has implementation of equals ()
if (newFeedState != null && !newFeedState.equals(currentFeedState)) { if (newFeedState != null && !newFeedState.equals(currentFeedState)) {
currentFeedState = newFeedState; currentFeedState = newFeedState;
@ -218,16 +226,18 @@ public class FeedHandler extends BaseThingHandler {
* {@link ThingStatusDetail#CONFIGURATION_ERROR} or * {@link ThingStatusDetail#CONFIGURATION_ERROR} or
* {@link ThingStatusDetail#COMMUNICATION_ERROR} and adequate message. * {@link ThingStatusDetail#COMMUNICATION_ERROR} and adequate message.
* *
* @param urlString URL of the Feed
* @return {@link SyndFeed} instance with the feed data, if the connection attempt was successful and * @return {@link SyndFeed} instance with the feed data, if the connection attempt was successful and
* <code>null</code> otherwise * <code>null</code> otherwise
*/ */
private SyndFeed fetchFeedData(String urlString) { private @Nullable SyndFeed fetchFeedData() {
SyndFeed feed = null; URL localUrl = url;
try { if (localUrl == null) {
URL url = new URL(urlString); logger.trace("Url '{}' is not valid: ", localUrl);
return null;
}
URLConnection connection = url.openConnection(); try {
URLConnection connection = localUrl.openConnection();
connection.setRequestProperty("Accept-Encoding", "gzip"); connection.setRequestProperty("Accept-Encoding", "gzip");
BufferedReader in = null; BufferedReader in = null;
@ -238,50 +248,45 @@ public class FeedHandler extends BaseThingHandler {
} }
SyndFeedInput input = new SyndFeedInput(); SyndFeedInput input = new SyndFeedInput();
feed = input.build(in); SyndFeed feed = input.build(in);
in.close(); in.close();
if (this.thing.getStatus() != ThingStatus.ONLINE) { if (this.thing.getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.ONLINE); updateStatus(ThingStatus.ONLINE);
} }
} catch (MalformedURLException e) {
logger.warn("Url '{}' is not valid: ", urlString, e); return feed;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
return null;
} catch (IOException e) { } catch (IOException e) {
logger.warn("Error accessing feed: {}", urlString, e); logger.warn("Error accessing feed: {}", localUrl, e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
return null; return null;
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
logger.warn("Feed URL is null ", e); logger.warn("Feed URL is null: {} ", localUrl, e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
return null; return null;
} catch (FeedException e) { } catch (FeedException e) {
logger.warn("Feed content is not valid: {} ", urlString, e); logger.warn("Feed content is not valid: {} ", localUrl, e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
return null; return null;
} }
return feed;
} }
/** /**
* Returns the most recent entry or null, if no entries are found. * Returns the most recent entry or null, if no entries are found.
*/ */
private SyndEntry getLatestEntry(SyndFeed feed) { private @Nullable SyndEntry getLatestEntry(SyndFeed feed) {
List<SyndEntry> allEntries = feed.getEntries(); List<SyndEntry> allEntries = feed.getEntries();
SyndEntry lastEntry = null;
if (!allEntries.isEmpty()) { if (!allEntries.isEmpty()) {
/* /*
* The entries are stored in the SyndFeed object in the following order - * The entries are stored in the SyndFeed object in the following order -
* the newest entry has index 0. The order is determined from the time the entry was posted, not the * the newest entry has index 0. The order is determined from the time the entry was posted, not the
* published time of the entry. * published time of the entry.
*/ */
lastEntry = allEntries.get(0); return allEntries.get(0);
} else { } else {
logger.debug("No entries found"); logger.debug("No entries found");
} }
return lastEntry; return null;
} }
@Override @Override
@ -289,7 +294,7 @@ public class FeedHandler extends BaseThingHandler {
if (command instanceof RefreshType) { if (command instanceof RefreshType) {
// safeguard for multiple REFRESH commands for different channels in a row // safeguard for multiple REFRESH commands for different channels in a row
if (isMinimumRefreshTimeExceeded()) { if (isMinimumRefreshTimeExceeded()) {
SyndFeed feed = fetchFeedData(urlString); SyndFeed feed = fetchFeedData();
updateFeedIfChanged(feed); updateFeedIfChanged(feed);
} }
publishChannelIfLinked(channelUID); publishChannelIfLinked(channelUID);
@ -317,7 +322,7 @@ public class FeedHandler extends BaseThingHandler {
return true; return true;
} }
public String getValueSafely(String value) { public String getValueSafely(@Nullable String value) {
return value == null ? new String() : value; return value == null ? "" : value;
} }
} }

View File

@ -6,8 +6,9 @@
<!-- DEFINITIONS of terms used in the binding: Feed is a XML document used for providing users with frequently updated content. <!-- DEFINITIONS of terms used in the binding: Feed is a XML document used for providing users with frequently updated content.
The most popular feed formats are RSS and Atom. Entry is a single element in the Feed, that contains reference (link), The most popular feed formats are RSS and Atom. Entry is a single element in the Feed, that contains reference (link),
short description and other information like images, comments and etc. Entry in this binding is abstraction for RSS item short
element and Atom entry element. --> description and other information like images, comments and etc. Entry in this binding is abstraction for RSS item element
and Atom entry element. -->
<!-- Feed Thing Type --> <!-- Feed Thing Type -->
<thing-type id="feed"> <thing-type id="feed">
@ -28,18 +29,16 @@
<config-description> <config-description>
<parameter name="URL" type="text" required="true"> <parameter name="URL" type="text" required="true">
<label>Feed URL</label> <label>Feed URL</label>
<description>The URL of the feed</description> <description>The URL of the feed.</description>
</parameter> </parameter>
<!--After the refresh time interval expires, the bindings checks for updates in the Feed, and if the information is not <!-- After the refresh time interval expires, the bindings checks for updates in the Feed, and if the information is not
up to date, updates the feed content stored in the channel --> up to date, updates the feed content stored in the channel -->
<parameter name="refresh" type="integer"> <parameter name="refresh" type="integer">
<label>Refresh Time Interval</label> <label>Refresh Time Interval</label>
<description>Refresh time interval in minutes.</description> <description>Refresh time interval in minutes.</description>
<default>20</default> <default>20</default>
</parameter> </parameter>
</config-description> </config-description>
</thing-type> </thing-type>
@ -67,35 +66,35 @@
<channel-type id="author" advanced="true"> <channel-type id="author" advanced="true">
<item-type>String</item-type> <item-type>String</item-type>
<label>Author</label> <label>Author</label>
<description>The name of the feed author, if author is present</description> <description>The name of the feed author, if author is present.</description>
<state readOnly="true" pattern="%s"/> <state readOnly="true" pattern="%s"/>
</channel-type> </channel-type>
<channel-type id="title" advanced="true"> <channel-type id="title" advanced="true">
<item-type>String</item-type> <item-type>String</item-type>
<label>Title</label> <label>Title</label>
<description>The title of the feed</description> <description>The title of the feed.</description>
<state readOnly="true" pattern="%s"/> <state readOnly="true" pattern="%s"/>
</channel-type> </channel-type>
<channel-type id="description" advanced="true"> <channel-type id="description" advanced="true">
<item-type>String</item-type> <item-type>String</item-type>
<label>Description</label> <label>Description</label>
<description>Description of the feed</description> <description>Description of the feed.</description>
<state readOnly="true" pattern="%s"/> <state readOnly="true" pattern="%s"/>
</channel-type> </channel-type>
<channel-type id="last-update" advanced="true"> <channel-type id="last-update" advanced="true">
<item-type>DateTime</item-type> <item-type>DateTime</item-type>
<label>Last Update</label> <label>Last Update</label>
<description>The last update date of the feed</description> <description>The last update date of the feed.</description>
<state readOnly="true" pattern="%tc %n"/> <state readOnly="true" pattern="%tc %n"/>
</channel-type> </channel-type>
<channel-type id="number-of-entries" advanced="true"> <channel-type id="number-of-entries" advanced="true">
<item-type>Number</item-type> <item-type>Number</item-type>
<label>Number of Entries</label> <label>Number of Entries</label>
<description>Number of entries in the feed</description> <description>Number of entries in the feed.</description>
<state readOnly="true" pattern="%d"/> <state readOnly="true" pattern="%d"/>
</channel-type> </channel-type>

View File

@ -93,7 +93,7 @@ public class FeedHandlerTest extends JavaOSGiTest {
/** /**
* It is updated from mocked {@link StateChangeListener#stateUpdated() } * It is updated from mocked {@link StateChangeListener#stateUpdated() }
*/ */
private StringType currentItemState = null; private StringType currentItemState;
// Required services for the test // Required services for the test
private ManagedThingProvider managedThingProvider; private ManagedThingProvider managedThingProvider;

View File

@ -12,11 +12,13 @@
*/ */
package org.openhab.binding.feed.test; package org.openhab.binding.feed.test;
import org.eclipse.jdt.annotation.NonNullByDefault;
/** /**
* This interface is used to mark tests that take too much time * This interface is used to mark tests that take too much time
* *
* @author Svilen Valkanov * @author Svilen Valkanov - Initial contribution
*/ */
@NonNullByDefault
public interface SlowTests { public interface SlowTests {
} }