Merge branch 'mmw/JAL-4199-web-services-testing' into development/Release_2_12_Branch
authorMateusz Warowny <mmzwarowny@dundee.ac.uk>
Tue, 18 Jul 2023 14:45:55 +0000 (16:45 +0200)
committerMateusz Warowny <mmzwarowny@dundee.ac.uk>
Tue, 18 Jul 2023 14:45:55 +0000 (16:45 +0200)
16 files changed:
j11lib/slivka-client.jar
src/jalview/ws/slivkaws/SlivkaAnnotationServiceInstance.java
src/jalview/ws/slivkaws/SlivkaMsaServiceInstance.java
src/jalview/ws/slivkaws/SlivkaWSDiscoverer.java
src/jalview/ws/slivkaws/SlivkaWSInstance.java
src/jalview/ws2/actions/AbstractPollableTask.java
src/jalview/ws2/actions/BaseJob.java
src/jalview/ws2/api/JobStatus.java
src/jalview/ws2/client/slivka/SlivkaWSClient.java
src/jalview/ws2/client/slivka/SlivkaWSDiscoverer.java
test/jalview/ws2/actions/alignment/AlignmentActionTest.java [new file with mode: 0644]
test/jalview/ws2/client/slivka/SlivkaWSDiscovererTest.java
test/jalview/ws2/client/slivka/default.jvprops [new file with mode: 0644]
utils/jalviewjs/libjs/slivka-client-site.zip
utils/testnglibs/hamcrest-2.2-sources.jar [new file with mode: 0644]
utils/testnglibs/hamcrest-2.2.jar [new file with mode: 0644]

index 49ab4fc..11b2f93 100644 (file)
Binary files a/j11lib/slivka-client.jar and b/j11lib/slivka-client.jar differ
index 999951a..5c98cbe 100644 (file)
@@ -65,8 +65,7 @@ public class SlivkaAnnotationServiceInstance extends SlivkaWSInstance implements
     RemoteFile featFile = null;
     try
     {
-      var slivkaJob = client.getJob(jobId.getJobId());
-      Collection<RemoteFile> files = slivkaJob.getResults();
+      Collection<RemoteFile> files = client.fetchFilesList(jobId.getJobId());
       for (RemoteFile f : files)
       {
         if (f.getMediaType().equals("application/jalview-annotations"))
index 374d2eb..337073e 100644 (file)
@@ -40,8 +40,7 @@ public class SlivkaMsaServiceInstance extends SlivkaWSInstance implements Multip
     Collection<RemoteFile> files;
     try
     {
-      var slivkaJob = client.getJob(jobId.getJobId());
-      files = slivkaJob.getResults();
+      files = client.fetchFilesList(jobId.getJobId());
       for (RemoteFile f : files)
       {
         if (f.getMediaType().equals("application/clustal"))
index d21d5d1..104560a 100644 (file)
@@ -94,7 +94,7 @@ public class SlivkaWSDiscoverer implements WSDiscovererI
 
     for (String url : getServiceUrls())
     {
-      SlivkaClient client = new SlivkaClient(url);
+      SlivkaClient client = SlivkaClient.newInstance(url);
 
       List<SlivkaService> services;
       try
@@ -220,7 +220,7 @@ public class SlivkaWSDiscoverer implements WSDiscovererI
   {
     try
     {
-      List<?> services = new SlivkaClient(url).getServices();
+      List<?> services = SlivkaClient.newInstance(url).getServices();
       return services.isEmpty() ? STATUS_NO_SERVICES : STATUS_OK;
     } catch (IOException | org.json.JSONException e)
     {
index 613c702..d3701ac 100644 (file)
@@ -30,7 +30,7 @@ import javajs.http.ClientProtocolException;
 
 import java.util.Collection;
 import uk.ac.dundee.compbio.slivkaclient.Job;
-import uk.ac.dundee.compbio.slivkaclient.JobRequest;
+import uk.ac.dundee.compbio.slivkaclient.RequestValues;
 import uk.ac.dundee.compbio.slivkaclient.Parameter;
 import uk.ac.dundee.compbio.slivkaclient.RemoteFile;
 import uk.ac.dundee.compbio.slivkaclient.SlivkaClient;
@@ -75,7 +75,7 @@ public abstract class SlivkaWSInstance extends ServiceWithParameters
           WsParamSetI preset, List<ArgumentI> args) throws Throwable
   {
     var parameters = service.getParameters();
-    var request = new JobRequest();
+    var request = new RequestValues();
     for (Parameter param : parameters)
     {
       if (param instanceof Parameter.FileParameter)
@@ -126,8 +126,8 @@ public abstract class SlivkaWSInstance extends ServiceWithParameters
         }
       }
     }
-    var job = service.submitJob(request);
-    return new JobId(service.getName(), service.getName(), job.getId());
+    var jobId = client.submitJob(service, request);
+    return new JobId(service.getName(), service.getName(), jobId);
   }
 
   @Override
@@ -135,8 +135,7 @@ public abstract class SlivkaWSInstance extends ServiceWithParameters
   {
     try
     {
-      var slivkaJob = client.getJob(job.getJobId());
-      job.setState(stateMap.get(slivkaJob.getStatus()));
+      job.setState(stateMap.get(client.fetchJobStatus(job.getJobId())));
     } catch (IOException e)
     {
       throw new IOError(e);
@@ -146,8 +145,7 @@ public abstract class SlivkaWSInstance extends ServiceWithParameters
   @Override
   public final boolean updateJobProgress(WsJob job) throws IOException
   {      
-    var slivkaJob = client.getJob(job.getJobId());
-    Collection<RemoteFile> files = slivkaJob.getResults();
+    Collection<RemoteFile> files = client.fetchFilesList(job.getJobId());
     RemoteFile logFile=null;
     for (RemoteFile f : files)
     {
@@ -161,7 +159,7 @@ public abstract class SlivkaWSInstance extends ServiceWithParameters
     if (logFile!=null)
     {
       ByteArrayOutputStream output = new ByteArrayOutputStream();
-      logFile.writeTo(output);
+      client.writeFileTo(logFile, output);
       if (output.size() > job.getNextChunk())
       {
         newContent = true;
@@ -185,7 +183,7 @@ public abstract class SlivkaWSInstance extends ServiceWithParameters
       if (errLogFile!=null)
       {
         ByteArrayOutputStream output = new ByteArrayOutputStream();
-        errLogFile.writeTo(output);
+        client.writeFileTo(errLogFile, output);
         if (output.size() > 0)
         {
           newContent = true;
index e692c68..b61711c 100644 (file)
@@ -49,7 +49,7 @@ public abstract class AbstractPollableTask<T extends BaseJob, R> implements Task
 
   private final TaskEventSupport<R> eventHandler;
 
-  protected JobStatus taskStatus = null;
+  protected JobStatus taskStatus = JobStatus.CREATED;
 
   private Future<?> future = null;
 
index 945c7b0..8376d20 100644 (file)
@@ -31,7 +31,7 @@ public abstract class BaseJob implements JobI
 
   protected final List<SequenceI> inputSeqs;
 
-  protected JobStatus status = null;
+  protected JobStatus status = JobStatus.CREATED;
 
   protected String log = "";
 
index 3341a69..8957343 100644 (file)
@@ -2,6 +2,8 @@ package jalview.ws2.api;
 
 public enum JobStatus
 {
+  /** Initial status before the job is started. */
+  CREATED,
   /** Job has invalid inputs and cannot be started. */
   INVALID,
   /** Job is created and ready for submission. */
@@ -39,6 +41,7 @@ public enum JobStatus
     case CANCELLED:
     case SERVER_ERROR:
       return true;
+    case CREATED:
     case READY:
     case SUBMITTED:
     case QUEUED:
@@ -60,6 +63,7 @@ public enum JobStatus
       JobStatus.UNKNOWN, // unknown prevents successful completion but not
                          // running or failure
       JobStatus.READY,
+      JobStatus.CREATED,
       JobStatus.SUBMITTED,
       JobStatus.QUEUED,
       JobStatus.RUNNING,
index bef502b..7dcdae1 100644 (file)
@@ -46,10 +46,10 @@ public class SlivkaWSClient implements WebServiceClientI
 
   final SlivkaClient client;
 
-  SlivkaWSClient(SlivkaService service)
+  SlivkaWSClient(SlivkaClient client, SlivkaService service)
   {
     this.service = service;
-    this.client = service.getClient();
+    this.client = client;
   }
 
   @Override
@@ -72,7 +72,7 @@ public class SlivkaWSClient implements WebServiceClientI
   public WebServiceJobHandle submit(List<SequenceI> sequences,
       List<ArgumentI> args, Credentials credentials) throws IOException
   {
-    var request = new uk.ac.dundee.compbio.slivkaclient.JobRequest();
+    var request = new uk.ac.dundee.compbio.slivkaclient.RequestValues();
     for (Parameter param : service.getParameters())
     {
       // TODO: restrict input sequences parameter name to "sequences"
@@ -131,8 +131,8 @@ public class SlivkaWSClient implements WebServiceClientI
         }
       }
     }
-    var job = service.submitJob(request);
-    return createJobHandle(job.getId());
+    var jobId = client.submitJob(service, request);
+    return createJobHandle(jobId);
   }
 
   protected WebServiceJobHandle createJobHandle(String jobId)
@@ -145,8 +145,7 @@ public class SlivkaWSClient implements WebServiceClientI
   @Override
   public JobStatus getStatus(WebServiceJobHandle job) throws IOException
   {
-    var slivkaJob = client.getJob(job.getJobId());
-    return statusMap.getOrDefault(slivkaJob.getStatus(), JobStatus.UNKNOWN);
+    return statusMap.getOrDefault(client.fetchJobStatus(job.getJobId()), JobStatus.UNKNOWN);
   }
 
   protected static final EnumMap<Job.Status, JobStatus> statusMap = new EnumMap<>(Job.Status.class);
@@ -168,13 +167,12 @@ public class SlivkaWSClient implements WebServiceClientI
   @Override
   public String getLog(WebServiceJobHandle job) throws IOException
   {
-    var slivkaJob = client.getJob(job.getJobId());
-    for (var f : slivkaJob.getResults())
+    for (var f : client.fetchFilesList(job.getJobId()))
     {
       if (f.getLabel().equals("log"))
       {
         ByteArrayOutputStream stream = new ByteArrayOutputStream();
-        f.writeTo(stream);
+        client.writeFileTo(f, stream);
         return stream.toString("UTF-8");
       }
     }
@@ -184,13 +182,12 @@ public class SlivkaWSClient implements WebServiceClientI
   @Override
   public String getErrorLog(WebServiceJobHandle job) throws IOException
   {
-    var slivkaJob = client.getJob(job.getJobId());
-    for (var f : slivkaJob.getResults())
+    for (var f : client.fetchFilesList(job.getJobId()))
     {
       if (f.getLabel().equals("error-log"))
       {
         ByteArrayOutputStream stream = new ByteArrayOutputStream();
-        f.writeTo(stream);
+        client.writeFileTo(f, stream);
         return stream.toString("UTF-8");
       }
     }
@@ -210,16 +207,15 @@ class SlivkaAlignmentWSClient extends SlivkaWSClient
     implements AlignmentWebServiceClientI
 {
 
-  SlivkaAlignmentWSClient(SlivkaService service)
+  SlivkaAlignmentWSClient(SlivkaClient client, SlivkaService service)
   {
-    super(service);
+    super(client, service);
   }
 
   @Override
   public AlignmentI getAlignment(WebServiceJobHandle job) throws IOException
   {
-    var slivkaJob = client.getJob(job.getJobId());
-    for (var f : slivkaJob.getResults())
+    for (var f : client.fetchFilesList(job.getJobId()))
     {
       // TODO: restrict result file label to "alignment"
       FileFormat format;
@@ -245,9 +241,9 @@ class SlivkaAlignmentWSClient extends SlivkaWSClient
 class SlivkaAnnotationWSClient extends SlivkaWSClient
     implements AnnotationWebServiceClientI
 {
-  SlivkaAnnotationWSClient(SlivkaService service)
+  SlivkaAnnotationWSClient(SlivkaClient client, SlivkaService service)
   {
-    super(service);
+    super(client, service);
   }
 
   @Override
@@ -255,10 +251,9 @@ class SlivkaAnnotationWSClient extends SlivkaWSClient
       List<SequenceI> sequences, Map<String, FeatureColourI> colours,
       Map<String, FeatureMatcherSetI> filters) throws IOException
   {
-    var slivkaJob = client.getJob(job.getJobId());
     var aln = new Alignment(sequences.toArray(new SequenceI[sequences.size()]));
     boolean featPresent = false, annotPresent = false;
-    for (var f : slivkaJob.getResults())
+    for (var f : client.fetchFilesList(job.getJobId()))
     {
       // TODO: restrict file label to "annotations" or "features"
       var match = mediaTypePattern.matcher(f.getMediaType());
index 70e1c94..51e27ad 100644 (file)
@@ -2,10 +2,12 @@ package jalview.ws2.client.slivka;
 
 import java.io.IOException;
 import java.net.MalformedURLException;
+import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.Function;
 
 import jalview.bin.Cache;
 import jalview.bin.Console;
@@ -36,15 +38,18 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
   private static SlivkaWSDiscoverer instance = null;
 
   private static ParamManager paramManager = null;
+  
+  private final Function<URI, SlivkaClient> clientFactory;
 
-  private SlivkaWSDiscoverer()
+  SlivkaWSDiscoverer(Function<URI, SlivkaClient> clientFactory)
   {
+    this.clientFactory = clientFactory;
   }
 
   public static SlivkaWSDiscoverer getInstance()
   {
     if (instance == null)
-      instance = new SlivkaWSDiscoverer();
+      instance = new SlivkaWSDiscoverer(SlivkaClient::newInstance);
     return instance;
   }
 
@@ -58,8 +63,12 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
   {
     try
     {
-      List<?> services = new SlivkaClient(url.toString()).getServices();
+      List<?> services = clientFactory.apply(url.toURI()).getServices();
       return services.isEmpty() ? STATUS_NO_SERVICES : STATUS_OK;
+    } catch (URISyntaxException e)
+    {
+      Console.error("invalid URL " + url, e);
+      return STATUS_INVALID;
     } catch (IOException e)
     {
       Console.error("slivka could not retrieve services from " + url, e);
@@ -86,7 +95,7 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
     SlivkaClient slivkaClient;
     try
     {
-      slivkaClient = new SlivkaClient(url.toURI());
+      slivkaClient = clientFactory.apply(url.toURI());
     } catch (URISyntaxException e)
     {
       throw new MalformedURLException(e.getMessage());
@@ -97,14 +106,14 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
       if (serviceClass == SERVICE_CLASS_MSA)
       {
         var wsb = WebService.<AlignmentAction> newBuilder();
-        initServiceBuilder(slivkaService, wsb);
+        initServiceBuilder(slivkaClient, slivkaService, wsb);
         wsb.category("Alignment");
         wsb.interactive(false);
         wsb.actionClass(AlignmentAction.class);
         var msaService = wsb.build();
 
         boolean canRealign = msaService.getName().contains("lustal");
-        var client = new SlivkaAlignmentWSClient(slivkaService);
+        var client = new SlivkaAlignmentWSClient(slivkaClient, slivkaService);
         var actionBuilder = AlignmentAction.newBuilder(client);
         actionBuilder.name("Alignment");
         actionBuilder.webService(msaService);
@@ -124,12 +133,12 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
       else if (serviceClass == SERVICE_CLASS_PROT_SEQ_ANALYSIS)
       {
         var wsb = WebService.<AnnotationAction> newBuilder();
-        initServiceBuilder(slivkaService, wsb);
+        initServiceBuilder(slivkaClient, slivkaService, wsb);
         wsb.category("Protein Disorder");
         wsb.interactive(false);
         wsb.actionClass(AnnotationAction.class);
         var psaService = wsb.build();
-        var client = new SlivkaAnnotationWSClient(slivkaService);
+        var client = new SlivkaAnnotationWSClient(slivkaClient, slivkaService);
         var actionBuilder = AnnotationAction.newBuilder(client);
         actionBuilder.webService(psaService);
         actionBuilder.name("Analysis");
@@ -139,12 +148,12 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
       else if (serviceClass == SERVICE_CLASS_CONSERVATION)
       {
         var wsb = WebService.<AnnotationAction> newBuilder();
-        initServiceBuilder(slivkaService, wsb);
+        initServiceBuilder(slivkaClient, slivkaService, wsb);
         wsb.category("Conservation");
         wsb.interactive(true);
         wsb.actionClass(AnnotationAction.class);
         var conService = wsb.build();
-        var client = new SlivkaAnnotationWSClient(slivkaService);
+        var client = new SlivkaAnnotationWSClient(slivkaClient, slivkaService);
         var actionBuilder = AnnotationAction.newBuilder(client);
         actionBuilder.webService(conService);
         actionBuilder.name("");
@@ -157,12 +166,12 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
       else if (serviceClass == SERVICE_CLASS_RNA_SEC_STR_PRED)
       {
         var wsb = WebService.<AnnotationAction> newBuilder();
-        initServiceBuilder(slivkaService, wsb);
+        initServiceBuilder(slivkaClient, slivkaService, wsb);
         wsb.category("Secondary Structure Prediction");
         wsb.interactive(true);
         wsb.actionClass(AnnotationAction.class);
         var predService = wsb.build();
-        var client = new SlivkaAnnotationWSClient(slivkaService);
+        var client = new SlivkaAnnotationWSClient(slivkaClient, slivkaService);
         var actionBuilder = AnnotationAction.newBuilder(client);
         actionBuilder.webService(predService);
         actionBuilder.name("Prediction");
@@ -183,11 +192,11 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
     return allServices;
   }
 
-  private void initServiceBuilder(SlivkaService service, WebService.Builder<?> wsBuilder)
+  private void initServiceBuilder(SlivkaClient client, SlivkaService service, WebService.Builder<?> wsBuilder)
   {
     try
     {
-      wsBuilder.url(service.getClient().getUrl().toURL());
+      wsBuilder.url(client.getUrl().toURL());
     } catch (MalformedURLException e)
     {
       e.printStackTrace();
@@ -222,10 +231,9 @@ public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
     for (String classifier : service.getClassifiers())
     {
       String[] path = classifier.split("\\s*::\\s*");
-      if (path.length < 3 || !path[0].equalsIgnoreCase("operation") ||
-          !path[1].equalsIgnoreCase("analysis"))
+      if (path.length < 3 || !path[0].equalsIgnoreCase("operation"))
         continue;
-      // classifier is operation :: analysis :: *
+      // classifier is operation :: *
       var tail = path[path.length - 1].toLowerCase();
       switch (tail)
       {
diff --git a/test/jalview/ws2/actions/alignment/AlignmentActionTest.java b/test/jalview/ws2/actions/alignment/AlignmentActionTest.java
new file mode 100644 (file)
index 0000000..5586108
--- /dev/null
@@ -0,0 +1,295 @@
+package jalview.ws2.actions.alignment;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import javax.help.UnsupportedOperationException;
+
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+import org.mockito.ArgumentCaptor;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import jalview.datamodel.Alignment;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.Sequence;
+import jalview.datamodel.SequenceI;
+import jalview.gui.AlignViewport;
+import jalview.viewmodel.AlignmentViewport;
+import jalview.ws.params.ParamDatastoreI;
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebService;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AlignmentWebServiceClientI;
+
+import org.mockito.hamcrest.MockitoHamcrest;
+import org.mockito.internal.hamcrest.HamcrestArgumentMatcher;
+
+import static org.mockito.Mockito.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+public class AlignmentActionTest
+{
+  protected AlignmentWebServiceClientI mockClient;
+
+  protected AlignmentAction.Builder actionBuilder;
+
+  protected WebServiceJobHandle jobRef;
+
+  @BeforeMethod
+  public void setupMockClient() throws IOException
+  {
+    jobRef = new WebServiceJobHandle(
+        "mock", "mock", "http://example.org", "00000001");
+    mockClient = mock(AlignmentWebServiceClientI.class);
+    when(mockClient.getUrl()).thenReturn("http://example.org");
+    when(mockClient.getClientName()).thenReturn("mock");
+    when(mockClient.submit(anyList(), anyList(), any())).thenReturn(jobRef);
+    when(mockClient.getLog(jobRef)).thenReturn("");
+    when(mockClient.getErrorLog(jobRef)).thenReturn("");
+    doThrow(new UnsupportedOperationException()).when(mockClient).cancel(any());
+  }
+
+  @BeforeMethod(dependsOnMethods = { "setupMockClient" })
+  public void setupActionBuilder() throws IOException
+  {
+    actionBuilder = AlignmentAction.newBuilder(mockClient);
+    actionBuilder.name("mock");
+    actionBuilder.webService(
+        WebService.<AlignmentAction> newBuilder()
+            .url(new URL("http://example.org"))
+            .clientName("mock")
+            .category("Alignment")
+            .name("mock")
+            .paramDatastore(mock(ParamDatastoreI.class))
+            .actionClass(AlignmentAction.class)
+            .build());
+  }
+
+  @DataProvider
+  public Object[][] multipleSequencesUnalignedAndAligned()
+  {
+    return new Object[][] {
+        {
+            new Alignment(new SequenceI[]
+            {
+                new Sequence("Seq 1", "----ASTVLITOPDCMMQEGGST-"),
+                new Sequence("Seq 2", "-ASCGLITO------MMQEGGST-"),
+                new Sequence("Seq 3", "AS--TVL--OPDTMMQEL------")
+            }),
+            new Alignment(new SequenceI[]
+            {
+                new Sequence("Sequence0", "ASTV-LITOPDCMMQEGGST----"),
+                new Sequence("Sequence1", "ASC-GLITO---MMQEGGST----"),
+                new Sequence("Sequence2", "ASTV-L--OPDTMMQE--L-----")
+            })
+        }
+    };
+  }
+
+  @Test(dataProvider = "multipleSequencesUnalignedAndAligned")
+  public void submitSequences_verifySequenceNamesUniquified(
+      Alignment unaligned, Alignment aligned)
+      throws IOException
+  {
+    var viewport = new AlignViewport(unaligned);
+    when(mockClient.getAlignment(jobRef)).thenReturn(aligned);
+    when(mockClient.getStatus(jobRef)).thenReturn(JobStatus.COMPLETED);
+    actionBuilder.submitGaps(false);
+    performAction(viewport, actionBuilder.build());
+    ArgumentCaptor<List<SequenceI>> argument = ArgumentCaptor.forClass(List.class);
+    verify(mockClient).submit(argument.capture(), eq(List.of()), eq(Credentials.empty()));
+    assertThat(argument.getValue(),
+        contains(hasProperty("name", is("Sequence0")),
+            hasProperty("name", is("Sequence1")),
+            hasProperty("name", is("Sequence2"))));
+  }
+
+  @Test(dataProvider = "multipleSequencesUnalignedAndAligned")
+  public void submitSequences_submitGapsOff_verifySequencesSubmittedWithoutGaps(Alignment unaligned, Alignment aligned)
+      throws IOException
+  {
+    var viewport = new AlignViewport(unaligned);
+    actionBuilder.submitGaps(false);
+    when(mockClient.getAlignment(jobRef)).thenReturn(aligned);
+    when(mockClient.getStatus(jobRef)).thenReturn(JobStatus.COMPLETED);
+    performAction(viewport, actionBuilder.build());
+    ArgumentCaptor<List<SequenceI>> argument = ArgumentCaptor.forClass(List.class);
+    verify(mockClient).submit(argument.capture(), eq(List.of()), eq(Credentials.empty()));
+    assertThat(argument.getValue(),
+        contains(
+            matchesSequence("ASTVLITOPDCMMQEGGST"),
+            matchesSequence("ASCGLITOMMQEGGST"),
+            matchesSequence("ASTVLOPDTMMQEL")));
+  }
+
+  @Test(dataProvider = "multipleSequencesUnalignedAndAligned")
+  public void submitSequences_submitGapsOn_verifySequencesSubmittedWithGaps(
+      Alignment unaligned, Alignment aligned)
+      throws IOException
+  {
+    var viewport = new AlignViewport(unaligned);
+    actionBuilder.submitGaps(true);
+    when(mockClient.getAlignment(jobRef)).thenReturn(aligned);
+    when(mockClient.getStatus(jobRef)).thenReturn(JobStatus.COMPLETED);
+    performAction(viewport, actionBuilder.build());
+    ArgumentCaptor<List<SequenceI>> argument = ArgumentCaptor.forClass(List.class);
+    verify(mockClient).submit(argument.capture(), eq(List.of()), eq(Credentials.empty()));
+    assertThat(argument.getValue(),
+        contains(
+            matchesSequence("----ASTVLITOPDCMMQEGGST-"),
+            matchesSequence("-ASCGLITO------MMQEGGST-"),
+            matchesSequence("AS--TVL--OPDTMMQEL------")));
+  }
+
+  @Test(dataProvider = "multipleSequencesUnalignedAndAligned")
+  public void retrieveResult_verifySequencesAligned(
+      Alignment unaligned, Alignment aligned)
+      throws IOException
+  {
+    var viewport = new AlignViewport(unaligned);
+    actionBuilder.submitGaps(false);
+    when(mockClient.getAlignment(jobRef)).thenReturn(aligned);
+    when(mockClient.getStatus(jobRef)).thenReturn(JobStatus.COMPLETED);
+    var mockListener = performAction(viewport, actionBuilder.build());
+    var argument = ArgumentCaptor.forClass(AlignmentResult.class);
+    verify(mockListener).taskCompleted(any(), argument.capture());
+    var alignmentResult = argument.getValue().getAlignment();
+    assertThat(alignmentResult, hasProperty("sequences", contains(
+        matchesSequence("ASTV-LITOPDCMMQEGGST----"),
+        matchesSequence("ASC-GLITO---MMQEGGST----"),
+        matchesSequence("ASTV-L--OPDTMMQE--L-----"))));
+  }
+
+  protected static Matcher<SequenceI> matchesSequence(String sequence)
+  {
+    return new TypeSafeMatcher<SequenceI>()
+    {
+      @Override
+      public boolean matchesSafely(SequenceI obj)
+      {
+        if (!(obj instanceof SequenceI))
+          return false;
+        var seq = (SequenceI) obj;
+        return seq.getSequenceAsString().equals(sequence);
+      }
+
+      @Override
+      public void describeTo(Description description)
+      {
+        description.appendText("a sequence ").appendValue(sequence);
+      }
+
+      @Override
+      public void describeMismatchSafely(SequenceI item, Description description)
+      {
+        description.appendText("was ").appendValue(item.getSequenceAsString());
+      }
+    };
+  }
+
+  protected TaskEventListener<AlignmentResult> performAction(
+      AlignmentViewport viewport, AlignmentAction action)
+      throws IOException
+  {
+    TaskEventListener<AlignmentResult> listener = mock(TaskEventListener.class);
+    var latch = new CountDownLatch(1);
+    doAnswer(invocation -> {
+      latch.countDown();
+      return null;
+    })
+        .when(listener).taskCompleted(any(), any());
+    action.perform(viewport, List.of(), Credentials.empty(), listener);
+    try
+    {
+      latch.await(100, TimeUnit.MILLISECONDS);
+    } catch (InterruptedException e)
+    {
+    }
+    return listener;
+  }
+}
+
+class AlignmentActionListenerNotifiedTest extends AlignmentActionTest
+{
+  private AlignViewport viewport;
+
+  @BeforeMethod
+  public void setupViewport()
+  {
+    viewport = new AlignViewport(new Alignment(new SequenceI[] {
+        new Sequence("Seq 1", "----ASTVLITOPDCMMQEGGST-"),
+        new Sequence("Seq 2", "-ASCGLITO------MMQEGGST-"),
+        new Sequence("Seq 3", "AS--TVL--OPDTMMQEL------")
+    }));
+  }
+
+  @DataProvider
+  public JobStatus[] jobStatuses()
+  {
+    // CREATED, INVALID and READY should not be returned by the server
+    return new JobStatus[] {
+        JobStatus.SUBMITTED,
+        JobStatus.QUEUED,
+        JobStatus.RUNNING,
+        JobStatus.COMPLETED,
+        JobStatus.FAILED,
+        JobStatus.CANCELLED,
+        JobStatus.SERVER_ERROR,
+        JobStatus.UNKNOWN
+    };
+  }
+
+  @Test
+  public void allJobsStarted_taskStartedCalled()
+      throws IOException
+  {
+    when(mockClient.getStatus(jobRef)).thenReturn(JobStatus.COMPLETED);
+    var mockListener = performAction(viewport, actionBuilder.build());
+    verify(mockListener).taskStarted(any(), anyList());
+  }
+
+  @Test
+  public void allJobsStarted_taskStatusChangedCalledWithReadyThenSubmitted()
+      throws IOException
+  {
+    when(mockClient.getStatus(jobRef)).thenReturn(JobStatus.COMPLETED);
+    var mockListener = performAction(viewport, actionBuilder.build());
+    var inOrder = inOrder(mockListener);
+    inOrder.verify(mockListener).taskStatusChanged(any(), eq(JobStatus.READY));
+    inOrder.verify(mockListener).taskStatusChanged(any(), eq(JobStatus.SUBMITTED));
+  }
+
+  @Test(dataProvider = "jobStatuses")
+  public void jobStatusChanged_taskStatusChangedCalledWithJobStatus(JobStatus status)
+      throws IOException
+  {
+    when(mockClient.getStatus(jobRef))
+        .thenReturn(status)
+        .thenReturn(JobStatus.COMPLETED);
+    var mockListener = performAction(viewport, actionBuilder.build());
+    verify(mockListener).taskStatusChanged(any(), eq(status));
+  }
+
+  @Test(dataProvider = "jobStatuses")
+  public void jobStatusChanged_subJobStatusChangedCalledWithJobStatus(JobStatus status)
+      throws IOException
+  {
+    when(mockClient.getStatus(jobRef))
+        .thenReturn(status)
+        .thenReturn(JobStatus.COMPLETED);
+    var mockListener = performAction(viewport, actionBuilder.build());
+    verify(mockListener).subJobStatusChanged(any(), any(), eq(status));
+  }
+}
\ No newline at end of file
index 5519156..870acb1 100644 (file)
 package jalview.ws2.client.slivka;
 
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
 import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
 
+import org.hamcrest.Matcher;
 import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
+import jalview.bin.Cache;
+import jalview.bin.Console;
+import jalview.ws.params.ValueConstrainI.ValueType;
+import jalview.ws.params.simple.DoubleParameter;
+import jalview.ws.params.simple.IntegerParameter;
+import jalview.ws.params.simple.StringParameter;
+import jalview.ws2.actions.alignment.AlignmentAction;
+import jalview.ws2.actions.annotation.AnnotationAction;
+import jalview.ws2.client.api.WebServiceDiscovererI;
+import uk.ac.dundee.compbio.slivkaclient.Parameter;
+import uk.ac.dundee.compbio.slivkaclient.SlivkaClient;
+import uk.ac.dundee.compbio.slivkaclient.SlivkaService;
+
 public class SlivkaWSDiscovererTest
 {
-  @BeforeClass
-  public void setupClass() throws IOException
+  private static final String URLS_PROPERTY_NAME = "SLIVKAHOSTURLS";
+
+  SlivkaClient clientMock;
+
+  Function<URL, SlivkaClient> factoryMock;
+
+  @BeforeClass(alwaysRun = true)
+  public void setupProperties()
+  {
+    Cache.loadProperties("test/jalview/ws2/client/slivka/default.jvprops");
+    Console.initLogger();
+  }
+
+  @BeforeMethod
+  public void setupDiscoverer() throws IOException
+  {
+    clientMock = mock(SlivkaClient.class);
+  }
+
+  @Test
+  public void getStatusForUrl_servicesReturned_statusIsOK() throws Exception
+  {
+    when(clientMock.getServices())
+        .thenReturn(List.of(mock(SlivkaService.class)));
+    var discoverer = new SlivkaWSDiscoverer(
+        url -> url.toString().equals("http://example.org") ? clientMock
+            : null);
+    assertThat(discoverer.getStatusForUrl(new URL("http://example.org")),
+        is(WebServiceDiscovererI.STATUS_OK));
+  }
+
+  @Test
+  public void getStatusForUrl_noServicesReturned_statusIsNoServices()
+      throws Exception
+  {
+    when(clientMock.getServices()).thenReturn(List.of());
+    var discoverer = new SlivkaWSDiscoverer(
+        url -> url.toString().equals("http://example.org") ? clientMock
+            : null);
+    assertThat(discoverer.getStatusForUrl(new URL("http://example.org")),
+        is(WebServiceDiscovererI.STATUS_NO_SERVICES));
+  }
+
+  @Test
+  public void getStatusForUrl_exceptionThrown_statusIsInvalid()
+      throws Exception
+  {
+    when(clientMock.getServices()).thenThrow(new IOException());
+    var discoverer = new SlivkaWSDiscoverer(
+        url -> url.toString().equals("http://example.org") ? clientMock
+            : null);
+    assertThat(discoverer.getStatusForUrl(new URL("http://example.org")),
+        is(WebServiceDiscovererI.STATUS_INVALID));
+  }
+
+  @Test
+  public void testGetUrls_noPropEntry_defaultUrlReturned()
+      throws MalformedURLException
   {
     var discoverer = SlivkaWSDiscoverer.getInstance();
-    
+    assertThat(discoverer.getUrls(),
+        contains(new URL("https://www.compbio.dundee.ac.uk/slivka/")));
   }
-  
+
+  @DataProvider
+  public Object[][] urlPropertyValues() throws MalformedURLException
+  {
+    return new Object[][] {
+        { "http://example.org/", List.of(new URL("http://example.org/")) },
+        { "https://example.org/slivka/",
+            List.of(new URL("https://example.org/slivka/")) },
+        { "https://www.compbio.dundee.ac.uk/,http://www.example.org/",
+            List.of(new URL("https://www.compbio.dundee.ac.uk/"),
+                new URL("http://www.example.org/")) },
+        { "http://example.org/,", List.of(new URL("http://example.org/")) },
+        { ",http://example.org", List.of(new URL("http://example.org")) },
+        { "", List.of() },
+        { ",", List.of() },
+        { "example.org", List.of() },
+        { "example.org,http://example.org",
+            List.of(new URL("http://example.org")) } };
+  }
+
+  @Test(dataProvider = "urlPropertyValues")
+  public void testGetUrls_urlsProperlyParsed(String propValue,
+      List<URL> expected)
+  {
+    Cache.setProperty(URLS_PROPERTY_NAME, propValue);
+    var discoverer = SlivkaWSDiscoverer.getInstance();
+    assertThat(discoverer.getUrls(), equalTo(expected));
+  }
+
   @Test
-  public void testServiceFetch() throws IOException
+  public void testSetUrls_emptyList_propertyReset()
   {
+    Cache.setProperty(URLS_PROPERTY_NAME, "http://www.example.org");
     var discoverer = SlivkaWSDiscoverer.getInstance();
-    var services = discoverer.fetchServices(discoverer.getDefaultUrl());
-    for (var service : services)
+    discoverer.setUrls(List.of());
+    assertThat(Cache.getProperty(URLS_PROPERTY_NAME), is(nullValue()));
+  }
+
+  @Test
+  public void testSetUrls_null_propertyReset()
+  {
+    Cache.setProperty(URLS_PROPERTY_NAME, "http://www.example.org");
+    var discoverer = SlivkaWSDiscoverer.getInstance();
+    discoverer.setUrls(null);
+    assertThat(Cache.getProperty(URLS_PROPERTY_NAME), is(nullValue()));
+  }
+
+  @DataProvider
+  public Object[][] urlsList() throws MalformedURLException
+  {
+    return new Object[][] {
+        { List.of(new URL("http://example.org")), "http://example.org" },
+        { List.of(new URL("http://example.org/")), "http://example.org/" },
+        { List.of(new URL("http://example.org/slivka/")),
+            "http://example.org/slivka/" },
+        { List.of(new URL("https://www.compbio.dundee.ac.uk/slivka/"),
+            new URL("http://example.org")),
+            "https://www.compbio.dundee.ac.uk/slivka/,http://example.org" }, };
+  }
+
+  @Test(dataProvider = "urlsList")
+  public void testSetUrls_urlsPropertySet(List<URL> urls, String expected)
+      throws MalformedURLException
+  {
+    var discoverer = SlivkaWSDiscoverer.getInstance();
+    discoverer.setUrls(urls);
+    assertThat(Cache.getProperty(URLS_PROPERTY_NAME), equalTo(expected));
+  }
+
+  @Test
+  public void testFetchServices_oneService_basicDataMatches()
+      throws IOException
+  {
+    var service = new SlivkaService(
+        URI.create("http://example.org/api/services/example"),
+        "example", "Example name", "Example service description",
+        "John Smith", "1.0", "MIT License",
+        List.of("operation::analysis::multiple sequence alignment"),
+        List.of(), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices, hasSize(1));
+    var webService = webServices.get(0);
+    assertThat(webService.getUrl(),
+        equalTo(new URL("http://example.org/")));
+    assertThat(webService.getClientName(), equalTo("slivka"));
+    assertThat(webService.getName(), equalTo("Example name"));
+    assertThat(webService.getDescription(),
+        equalTo("Example service description"));
+  }
+
+  @DataProvider
+  public String[] validMultipleSequenceAlignmentClassifiers()
+  {
+    return new String[] {
+        "Operation :: Analysis :: Multiple sequence alignment",
+        "operation :: analysis :: multiple sequence alignment",
+        "Operation\t::\tAnalysis\t::\tMultiple sequence alignment",
+        "Operation::Analysis::Multiple sequence alignment",
+        "Operation :: Analysis :: Multiple Sequence Alignment",
+        "OPERATION :: ANALYSIS :: MULTIPLE SEQUENCE ALIGNMENT",
+        "Operation :: Analysis :: Sequence alignment :: Multiple sequence alignment",
+        "Operation :: Analysis :: Sequence analysis :: Sequence alignment :: Multiple sequence alignment",
+        "Operation :: Alignment :: Multiple sequence alignment",
+        "Operation :: Alignment :: Sequence alignment :: Multiple sequence alignment",
+        "Operation :: Comparison :: Multiple sequence alignment",
+        "Operation :: Comparison :: Sequence comparison :: Sequence alignment :: Multiple sequence alignment" };
+
+  }
+
+  @Test(dataProvider = "validMultipleSequenceAlignmentClassifiers")
+  public void testFetchServices_multipleSequenceAlignmentClassifier_serviceTypeIsMSA(
+      String classifier) throws IOException
+  {
+    var service = new SlivkaService(URI.create("http://example.org/"),
+        "example", "name", "description", "author", "1.0", "MIT",
+        List.of(classifier), List.of(), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices, hasSize(1));
+    assertThat(webServices.get(0).getCategory(), equalTo("Alignment"));
+    assertThat(webServices.get(0).getActionClass(),
+        typeCompatibleWith(AlignmentAction.class));
+  }
+
+  @DataProvider
+  public SlivkaService[] multipleSequenceAlignmentService()
+  {
+    return new SlivkaService[] {
+        new SlivkaService(
+            URI.create("http://example.org/"), "example", "Examaple name",
+            "Example description", "John Smith", "1.0", "MIT",
+            List.of("Operation :: Analysis :: Multiple sequence alignment"),
+            List.of(), List.of(), null),
+        new SlivkaService(
+            URI.create("http://example.org/api/services/muscle"),
+            "muscle", "MUSCLE",
+            "MUltiple Sequence Comparison by Log- Expectation",
+            "Robert C. Edgar", "3.8.31", "Public domain",
+            List.of("Topic :: Computational biology :: Sequence analysis",
+                "Operation :: Analysis :: Sequence analysis :: Sequence alignment :: Multiple sequence alignment"),
+            List.of(), List.of(), null),
+        new SlivkaService(
+            URI.create("http://example.org/api/services/tcoffee"),
+            "tcoffee", "TCoffee",
+            "Tree-based Consistency Objective Function for Alignment Evaluation",
+            "Cedric Notredame", "13.41.0", "GNU GPL",
+            List.of("Topic :: Computational biology :: Sequence analysis",
+                "Operation :: Analysis :: Sequence analysis :: Sequence alignment :: Multiple sequence alignment"),
+            List.of(), List.of(), null) };
+  }
+
+  @Test(dataProvider = "multipleSequenceAlignmentService")
+  public void testFetchServices_multipleSequenceAlignmentService_actionTypeIsAlignment(
+      SlivkaService service) throws IOException
+  {
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices.get(0).getCategory(), equalTo("Alignment"));
+    assertThat(webServices.get(0).getActionClass(),
+        typeCompatibleWith(AlignmentAction.class));
+  }
+
+  @Test(dataProvider = "multipleSequenceAlignmentService")
+  public void testFetchServices_multipleSequenceAlignmentService_serviceIsNonInteractive(
+      SlivkaService service) throws IOException
+  {
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices.get(0).isInteractive(), is(false));
+  }
+
+  @DataProvider
+  public SlivkaService[] clustalFamilyService()
+  {
+    return new SlivkaService[] {
+        new SlivkaService(
+            URI.create("http://example.org/api/services/clustalo"),
+            "clustalo", "ClustalO",
+            "Clustal Omega is the latest addition to the Clustal family.",
+            "Fabian Sievers, et al.", "1.2.4", "GNU GPL ver. 2",
+            List.of("Topic :: Computational biology :: Sequence analysis",
+                "Operation :: Analysis :: Sequence analysis :: Sequence alignment :: Multiple sequence alignment"),
+            List.of(), List.of(), null),
+        new SlivkaService(
+            URI.create("http://example.org/api/services/clustalw"),
+            "clustalw", "ClustalW",
+            "ClustalW is a general purpose multiple alignment program.",
+            "Larkin MA, et al.", "2.1", "GNU GPL ver. 3",
+            List.of("Topic :: Computation biology :: Sequence analysis",
+                "Operation :: Analysis :: Multiple sequence alignment"),
+            List.of(), List.of(), null),
+        new SlivkaService(
+            URI.create("http://example.org/api/services/clustalw2"),
+            "clustalw2", "ClustalW2",
+            "ClustalW is a general purpose multiple alignment program.",
+            "Larkin MA, et al.", "2.1", "GNU GPL ver. 3",
+            List.of("Topic :: Computation biology :: Sequence analysis",
+                "Operation :: Analysis :: Multiple sequence alignment"),
+            List.of(), List.of(), null), };
+  }
+
+  @Test(dataProvider = "clustalFamilyService")
+  public void testFetchService_clustalFamilyService_containsTwoActions(
+      SlivkaService service) throws IOException
+  {
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org"));
+    var actions = webServices.get(0).getActions();
+    assertThat(actions, hasSize(2));
+    assertThat(actions.get(0), allOf(hasProperty("name", is("Alignment")),
+        hasProperty("subcategory", is("Align"))));
+    assertThat(actions.get(1),
+        allOf(hasProperty("name", is("Re-alignment")),
+            hasProperty("subcategory", is("Realign"))));
+  }
+
+  @DataProvider
+  public String[] validRNASecondaryStructurePredictionClassifiers()
+  {
+    return new String[] {
+        "Operation :: Analysis :: RNA secondary structure prediction",
+        "operation :: analysis :: rna secondary structure prediction",
+        "OPERATION :: ANALYSIS :: RNA SECONDARY STRUCTURE PREDICTION",
+        "Operation\t::\tAnalysis\t::\tRNA secondary structure prediction",
+        "Operation::Analysis::RNA secondary structure prediction",
+        "Operation :: Analysis :: Structure analysis :: RNA secondary structure prediction",
+        "Operation :: Analysis :: Structure analysis :: Nucleic acid structure analysis :: RNA secondary structure analysis :: RNA secondary structure prediction",
+        "Operation :: Analysis :: Structure analysis :: Nucleic acid structure analysis :: Nucleic acid structure prediction :: RNA secondary structure prediction",
+        "Operation :: Analysis :: Sequence analysis :: Nucleic acid sequence analysis :: Nucleic acid feature detection :: RNA secondary structure prediction",
+        "Operation :: Prediction and recognition :: RNA secondary structure prediction",
+        "Operation :: Prediction and recognition :: Nucleic acid feature detection :: RNA secondary structure prediction",
+        "Operation :: Prediction and recignition :: Nucleic acid structure prediction :: RNA secondary structure prediction", };
+  }
+
+  @DataProvider
+  public Iterator<Object> RNASecondaryStructurePredictionService()
+  {
+    var services = new ArrayList<>();
+    for (var classifier : validRNASecondaryStructurePredictionClassifiers())
+    {
+      services.add(new SlivkaService(URI.create("http://example.org/"),
+          "example", "name", "description", "author", "1.0", "MIT",
+          List.of(classifier), List.of(), List.of(), null));
+    }
+    return services.iterator();
+  }
+
+  @Test(dataProvider = "RNASecondaryStructurePredictionService")
+  public void testFetchServices_RNASecStrPredClassifier_serviceTypeIsRNASecStrPred(
+      SlivkaService service) throws IOException
+  {
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices, hasSize(1));
+    assertThat(webServices.get(0).getCategory(),
+        equalTo("Secondary Structure Prediction"));
+    assertThat(webServices.get(0).getActionClass(),
+        typeCompatibleWith(AnnotationAction.class));
+  }
+
+  @DataProvider
+  public String[] validConservationAnalysisClassifiers()
+  {
+    return new String[] {
+        "Operation :: Analysis :: Sequence alignment analysis (conservation)",
+        "Operation::Analysis::Sequence alignment analysis (conservation)",
+        "Operation\t::\tAnalysis\t::\tSequence alignment analysis (conservation)",
+        "Operation :: Analysis :: Sequence analysis :: Sequence alignment analysis (conservation)",
+        "Operation :: Analysis :: Sequence analysis :: Sequence alignment analysis :: Sequence alignment analysis (conservation)", };
+  }
+
+  @DataProvider
+  public Iterator<Object> ConservationAnalysisService()
+  {
+    var services = new ArrayList<>();
+    for (var classifier : validConservationAnalysisClassifiers())
     {
-      System.out.format("Service(%s>%s @%s)%n", service.getCategory(), 
-          service.getName(), service.getUrl());
-      var datastore = service.getParamDatastore();
-      for (var param : datastore.getServiceParameters())
-      {
-        System.out.format("  %s :%s%n", param.getName(), param.getClass().getSimpleName()); 
-      }
+      services.add(new SlivkaService(URI.create("http://example.org/"),
+          "example", "name", "description", "author", "1.0", "MIT",
+          List.of(classifier), List.of(), List.of(), null));
     }
+    return services.iterator();
+  }
+
+  @Test(dataProvider = "validConservationAnalysisClassifiers")
+  public void testFetchServices_conservationAnalysisClassifier_serviceTypeIsConservation(
+      String classifier) throws IOException
+  {
+    var service = new SlivkaService(URI.create("http://example.org/"),
+        "example", "name", "description", "author", "1.0", "MIT",
+        List.of(classifier), List.of(), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices, hasSize(1));
+    assertThat(webServices.get(0).getCategory(), equalTo("Conservation"));
+    assertThat(webServices.get(0).getActionClass(),
+        typeCompatibleWith(AnnotationAction.class));
+  }
+
+  @DataProvider
+  public Object[] validProteinSequenceAnalysisClassifiers()
+  {
+    return new Object[] {
+        "Operation :: Analysis :: Sequence analysis :: Protein sequence analysis", };
+  }
+
+  @Test(dataProvider = "validProteinSequenceAnalysisClassifiers")
+  public void testFetchServices_proteinSequenceAnalysisClassifier_serviceTypeIsProtSeqAnalysis(
+      String classifier) throws IOException
+  {
+    var service = new SlivkaService(URI.create("http://example.org/"),
+        "example", "name", "description", "author", "1.0", "MIT",
+        List.of(classifier), List.of(), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices, hasSize(1));
+    assertThat(webServices.get(0).getCategory(),
+        equalTo("Protein Disorder"));
+    assertThat(webServices.get(0).getActionClass(),
+        typeCompatibleWith(AnnotationAction.class));
+  }
+
+  @DataProvider
+  public Object[] validProteinSecondaryStructurePredictionClassifiers()
+  {
+    return new Object[] {
+        "Operation ;: Analysis :: Protein secondary structure prediction",
+        "Operation :: Analysis :: Structure analysis :: Protein structure analysis :: Protein secondary structure analysis :: Protein secondary structure prediction",
+        "Operation :: Analysis :: Sequence analysis :: Protein sequence analysis :: Protein feature detection :: Protein secondary structure prediction",
+        "Operation :: Analysis :: Sequence analysis :: Protein sequence analysis :: Protein secondary structure prediction",
+        "Operation :: Prediction and recognition :: Protein secondary structure prediction",
+        "Operation :: Prediction and recognition :: Protein feature detection :: Protein secondary structure prediction", };
+  }
+
+  @Test(
+    enabled = false, // sec. str. pred. not implemented for slivka
+    dataProvider = "validProteinSecondaryStructurePredictionClassifiers")
+  public void testFetchServices_proteinSecStrPredClassifier_serviceTypeIsProtSecStrPred(
+      String classifier) throws IOException
+  {
+    var service = new SlivkaService(URI.create("http://example.org/"),
+        "example", "name", "description", "author", "1.0", "MIT",
+        List.of(classifier), List.of(), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    assertThat(webServices, hasSize(1));
+    assertThat(webServices.get(0).getCategory(),
+        equalTo("Protein Disorder"));
+    assertThat(webServices.get(0).getActionClass(),
+        typeCompatibleWith(AnnotationAction.class));
+  }
+
+  @DataProvider
+  public SlivkaService[] unrecognisedService()
+  {
+    return new SlivkaService[] {
+        new SlivkaService(URI.create("http://example.org/"), "example",
+            "Example name", "Example description", "John Smith",
+            "1.0.0", "Apache License, version 2.0",
+            List.of("This :: Classifier :: Does not exist"), List.of(),
+            List.of(), null) };
+  }
+
+  @Test(dataProvider = "unrecognisedService")
+  public void testFetchServices_unrecognisedService_noServiceDiscovered(
+      SlivkaService service) throws IOException
+  {
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org"));
+    assertThat(webServices, hasSize(0));
+  }
+
+  @DataProvider
+  public Object[] serviceParameterAndMappedClass()
+  {
+    return new Object[][] {
+        {
+            new Parameter.IntegerParameter("param", "Parameter", "Description",
+                true, false, null, Map.of(), null, null),
+            IntegerParameter.class
+        },
+        {
+            new Parameter.DecimalParameter("param", "Parameter",
+                "Description", true, false, null, Map.of(), null, null,
+                false, false),
+            DoubleParameter.class
+        },
+        {
+            new Parameter.TextParameter("param", "Parameter", "Description",
+                true, false, null, Map.of(), 0, null),
+            StringParameter.class
+        },
+        {
+            new Parameter.FlagParameter("param", "Parameter", "Description",
+                true, false, null, Map.of()),
+            StringParameter.class
+        },
+        {
+            new Parameter.ChoiceParameter("param", "Parameter", "Description",
+                true, false, null, Map.of(), List.of()),
+            StringParameter.class
+        },
+    };
+  }
+
+  @Test(dataProvider = "serviceParameterAndMappedClass")
+  public void testServiceParameter_slivkaParameterMappedToJalviewParameter(
+      Parameter slivkaParameter, Class<?> expectedClass)
+      throws IOException
+  {
+    var service = new SlivkaService(URI.create("http://example.org"),
+        "example", "name", "description", "author", "1.0",
+        "MIT License",
+        List.of("Operation :: Analysis :: Multiple sequence alignment"),
+        List.of(slivkaParameter), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org"));
+    var paramDatastore = webServices.get(0).getParamDatastore();
+    var arguments = paramDatastore.getServiceParameters();
+    assertThat(arguments.get(0), instanceOf(expectedClass));
+  }
+
+  @DataProvider
+  public Object[][] serviceParametersAndPropertyMatcher()
+  {
+    return new Object[][] {
+        {
+            new Parameter.IntegerParameter("param1", "Parameter 1",
+                "Description of parameter 1", true, false, null, Map.of(),
+                null, null),
+            allOf(
+                hasProperty("name", equalTo("param1")),
+                hasProperty("label", equalTo("Parameter 1")),
+                hasProperty("description", equalTo("Description of parameter 1")),
+                hasProperty("required", is(true)),
+                hasProperty("value", nullValue()))
+        },
+        {
+            new Parameter.IntegerParameter("param2", null, null, true, false,
+                null, Map.of(), null, null),
+            allOf(
+                hasProperty("name", equalTo("param2")),
+                hasProperty("label", equalTo("param2")),
+                hasProperty("description", nullValue()),
+                hasProperty("required", is(true)),
+                hasProperty("value", nullValue()))
+        },
+        {
+            new Parameter.IntegerParameter("param3", "Parameter 3", "", false,
+                false, 12, Map.of(), null, null),
+            allOf(
+                hasProperty("name", equalTo("param3")),
+                hasProperty("label", equalTo("Parameter 3")),
+                hasProperty("description", equalTo("")),
+                hasProperty("required", is(false)),
+                hasProperty("value", equalTo("12")))
+        },
+    };
+  }
+
+  @Test(dataProvider = "serviceParametersAndInfoMatcher")
+  public void testServiceParameters_testBasicParameterProperties(
+      Parameter parameter, Matcher<Object> matcher) throws IOException
+  {
+    var service = new SlivkaService(URI.create("http://example.org/"),
+        "example", "Example name", "Example description", "John Smith",
+        "1.0", "MIT",
+        List.of("Operation :: Analysis :: Multiple sequence alignment"),
+        List.of(parameter), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    var paramDatastore = webServices.get(0).getParamDatastore();
+    var arguments = paramDatastore.getServiceParameters();
+    assertThat(arguments.get(0), matcher);
+  }
+
+  @DataProvider
+  public Object[][] integerParametersAndPropertyMatcher()
+  {
+    return new Object[][] {
+        {
+            new Parameter.IntegerParameter("param", null, null, true, false,
+                null, Map.of(), null, null),
+            hasProperty("validValue", hasProperty("type", is(ValueType.Integer)))
+        },
+        {
+            new Parameter.IntegerParameter("param", null, null, true, false,
+                null, Map.of(), null, null),
+            hasProperty("validValue", allOf(
+                hasProperty("min", nullValue()),
+                hasProperty("max", nullValue()))),
+        },
+        {
+            new Parameter.IntegerParameter("param", null, null, true, false,
+                null, Map.of(), -12, 42),
+            hasProperty("validValue", allOf(
+                hasProperty("min", is(-12)),
+                hasProperty("max", is(42))))
+        },
+    };
+  }
+
+  @Test(dataProvider = "integerParametersAndPropertyMatcher")
+  public void testServiceParameters_testIntegerProperties(
+      Parameter parameter, Matcher<Object> matcher) throws IOException
+  {
+    var service = new SlivkaService(URI.create("http://example.org"),
+        "example", "Example name", "Example description", "John Smith",
+        "1.0", "MIT",
+        List.of("Operation :: Analysis :: Multiple Sequence Alignment"),
+        List.of(parameter), List.of(), null);
+    when(clientMock.getServices()).thenReturn(List.of(service));
+    when(clientMock.getUrl()).thenReturn(URI.create("http://example.org/"));
+    var discoverer = new SlivkaWSDiscoverer(url -> clientMock);
+    var webServices = discoverer
+        .fetchServices(new URL("http://example.org/"));
+    var paramDatastore = webServices.get(0).getParamDatastore();
+    var arguments = paramDatastore.getServiceParameters();
+    assertThat(arguments.get(0), matcher);
   }
 }
diff --git a/test/jalview/ws2/client/slivka/default.jvprops b/test/jalview/ws2/client/slivka/default.jvprops
new file mode 100644 (file)
index 0000000..190ca63
--- /dev/null
@@ -0,0 +1,2 @@
+#---JalviewX Properties File---
+#Wed Jun 07 18:01:12 CET 2023
index 2e64029..c0b582d 100644 (file)
Binary files a/utils/jalviewjs/libjs/slivka-client-site.zip and b/utils/jalviewjs/libjs/slivka-client-site.zip differ
diff --git a/utils/testnglibs/hamcrest-2.2-sources.jar b/utils/testnglibs/hamcrest-2.2-sources.jar
new file mode 100644 (file)
index 0000000..6124211
Binary files /dev/null and b/utils/testnglibs/hamcrest-2.2-sources.jar differ
diff --git a/utils/testnglibs/hamcrest-2.2.jar b/utils/testnglibs/hamcrest-2.2.jar
new file mode 100644 (file)
index 0000000..7106578
Binary files /dev/null and b/utils/testnglibs/hamcrest-2.2.jar differ