Merge branch 'feature/JAL-3954-ebi-phmmer' into mmw/JAL-4199-task-execution-update mmw/JAL-4199-task-execution-update
authorMateusz Warowny <mmzwarowny@dundee.ac.uk>
Fri, 14 Jul 2023 14:01:32 +0000 (16:01 +0200)
committerMateusz Warowny <mmzwarowny@dundee.ac.uk>
Fri, 14 Jul 2023 14:01:32 +0000 (16:01 +0200)
41 files changed:
j11lib/slivka-client.jar
src/jalview/api/AlignCalcWorkerI.java
src/jalview/datamodel/AnnotatedCollectionI.java
src/jalview/datamodel/ContiguousI.java
src/jalview/datamodel/SequenceI.java
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 [deleted file]
src/jalview/ws2/actions/BaseJob.java
src/jalview/ws2/actions/BaseTask.java [new file with mode: 0644]
src/jalview/ws2/actions/NullAction.java
src/jalview/ws2/actions/NullTask.java
src/jalview/ws2/actions/PollingTaskExecutor.java [new file with mode: 0644]
src/jalview/ws2/actions/alignment/AlignmentAction.java
src/jalview/ws2/actions/alignment/AlignmentTask.java
src/jalview/ws2/actions/annotation/AlignCalcWorkerAdapter.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationAction.java
src/jalview/ws2/actions/annotation/AnnotationProviderI.java
src/jalview/ws2/actions/annotation/AnnotationTask.java
src/jalview/ws2/actions/api/ActionI.java
src/jalview/ws2/actions/api/TaskEventListener.java
src/jalview/ws2/actions/api/TaskI.java
src/jalview/ws2/actions/hmmer/PhmmerAction.java
src/jalview/ws2/actions/hmmer/PhmmerTask.java
src/jalview/ws2/api/JobStatus.java
src/jalview/ws2/client/ebi/JobDispatcherWSDiscoverer.java
src/jalview/ws2/client/slivka/SlivkaWSClient.java
src/jalview/ws2/client/slivka/SlivkaWSDiscoverer.java
src/jalview/ws2/gui/AlignmentServiceGuiHandler.java
src/jalview/ws2/gui/AnnotationServiceGuiHandler.java
src/jalview/ws2/gui/SearchServiceGuiHandler.java
src/jalview/ws2/gui/WebServicesMenuManager.java
src/jalview/ws2/helpers/TaskEventSupport.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 b51b94e..89470e0 100644 (file)
@@ -65,4 +65,12 @@ public interface AlignCalcWorkerI
    * @return
    */
   boolean isDeletable();
+
+  /**
+   * Returns the name of this calculation.
+   */
+  public default String getCalcName()
+  {
+    return null;
+  }
 }
index 878f22a..755333c 100644 (file)
@@ -62,5 +62,21 @@ public interface AnnotatedCollectionI extends SequenceCollectionI
    */
   AnnotatedCollectionI getContext();
 
-  
+  /**
+   * Returns the number of the highest annotation graph group attached to this
+   * alignment.
+   */
+  default int getLastGraphGroup()
+  {
+    var annots = getAlignmentAnnotation();
+    if (annots == null)
+      return 1;
+    int graphGroup = 1;
+    for (AlignmentAnnotation ala : annots)
+    {
+      graphGroup = Math.max(graphGroup, ala.graphGroup);
+    }
+    return graphGroup;
+  }
+
 }
index a9b1372..bc72984 100644 (file)
  */
 package jalview.datamodel;
 
+import java.util.Collection;
+
 public interface ContiguousI
 {
   int getBegin(); // todo want long for genomic positions?
 
   int getEnd();
+  
+  public static int[] toStartEndArray(Collection<? extends ContiguousI> ranges)
+  {
+    int[] startend = new int[ranges.size() * 2];
+    int i = 0;
+    for (var range : ranges)
+    {
+      startend[i++] = range.getBegin();
+      startend[i++] = range.getEnd();
+    }
+    return startend;
+  }
 }
index 8f1d160..d8a5462 100755 (executable)
@@ -396,6 +396,19 @@ public interface SequenceI extends ASequenceI
   public SequenceI getDatasetSequence();
 
   /**
+   * Returns the top grandparent in the dataset sequences hierarchy.
+   */
+  public default SequenceI getRootDatasetSequence()
+  {
+    var sequence = this;
+    while (sequence.getDatasetSequence() != null)
+    {
+      sequence = sequence.getDatasetSequence();
+    }
+    return sequence;
+  }
+
+  /**
    * Returns a new array containing this sequence's annotations, or null.
    */
   public AlignmentAnnotation[] getAnnotation();
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;
diff --git a/src/jalview/ws2/actions/AbstractPollableTask.java b/src/jalview/ws2/actions/AbstractPollableTask.java
deleted file mode 100644 (file)
index e692c68..0000000
+++ /dev/null
@@ -1,352 +0,0 @@
-package jalview.ws2.actions;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.CancellationException;
-import java.util.concurrent.CompletionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-import jalview.bin.Cache;
-import jalview.bin.Console;
-import jalview.util.ArrayUtils;
-import jalview.util.MathUtils;
-import jalview.ws.params.ArgumentI;
-import jalview.ws2.actions.api.TaskEventListener;
-import jalview.ws2.actions.api.TaskI;
-import jalview.ws2.api.Credentials;
-import jalview.ws2.api.JobStatus;
-import jalview.ws2.api.WebServiceJobHandle;
-import jalview.ws2.client.api.WebServiceClientI;
-import jalview.ws2.helpers.DelegateJobEventListener;
-import jalview.ws2.helpers.TaskEventSupport;
-import static java.lang.String.format;
-
-/**
- * An abstract base class for non-interactive tasks which implements common
- * tasks methods. Additionally, it manages task execution in a polling loop.
- * Subclasses are only required to implement {@link #prepare()} and
- * {@link #done()} methods.
- * 
- * @author mmwarowny
- *
- * @param <T>
- *          the type of jobs managed by the task
- * @param <R>
- *          the type of result provided by the task
- */
-public abstract class AbstractPollableTask<T extends BaseJob, R> implements TaskI<R>
-{
-  private final long uid = MathUtils.getUID();
-
-  protected final WebServiceClientI client;
-
-  protected final List<ArgumentI> args;
-
-  protected final Credentials credentials;
-
-  private final TaskEventSupport<R> eventHandler;
-
-  protected JobStatus taskStatus = null;
-
-  private Future<?> future = null;
-
-  protected List<T> jobs = Collections.emptyList();
-
-  protected R result;
-
-  protected AbstractPollableTask(WebServiceClientI client, List<ArgumentI> args,
-      Credentials credentials, TaskEventListener<R> eventListener)
-  {
-    this.client = client;
-    this.args = args;
-    this.credentials = credentials;
-    this.eventHandler = new TaskEventSupport<R>(this, eventListener);
-  }
-
-  public long getUid()
-  {
-    return uid;
-  }
-
-  /**
-   * Start the task using provided scheduled executor service. It creates a
-   * polling loop running at set intervals.
-   * 
-   * @param executor
-   *          executor to run the polling loop with
-   */
-  public void start(ScheduledExecutorService executor)
-  {
-    if (future != null)
-      throw new IllegalStateException("task already started");
-    var runnable = new Runnable()
-    {
-      private int stage = STAGE_PREPARE;
-
-      private static final int STAGE_PREPARE = 0;
-
-      private static final int STAGE_START = 1;
-
-      private static final int STAGE_POLL = 2;
-
-      private static final int STAGE_FINALIZE = 3;
-
-      private static final int STAGE_DONE = 4;
-
-      private int retryCount = 0;
-
-      private static final int MAX_RETRY = 5;
-
-      /**
-       * A polling loop run periodically which carries the task through its
-       * consecutive execution stages.
-       */
-      @Override
-      public void run()
-      {
-        if (stage == STAGE_PREPARE)
-        {
-          // first stage - the input data is collected and the jobs are created
-          try
-          {
-            jobs = prepare();
-          } catch (ServiceInputInvalidException e)
-          {
-            stage = STAGE_DONE;
-            setStatus(JobStatus.INVALID);
-            eventHandler.fireTaskException(e);
-            throw new CompletionException(e);
-          }
-          stage = STAGE_START;
-          setStatus(JobStatus.READY);
-          eventHandler.fireTaskStarted(jobs);
-          var jobListener = new DelegateJobEventListener<>(eventHandler);
-          for (var job : jobs)
-          {
-            job.addPropertyChagneListener(jobListener);
-          }
-        }
-        try
-        {
-          if (stage == STAGE_START)
-          {
-            // second stage - jobs are submitted to the server
-            startJobs();
-            stage = STAGE_POLL;
-            setStatus(JobStatus.SUBMITTED);
-          }
-          if (stage == STAGE_POLL)
-          {
-            // third stage - jobs are poolled until all of them are completed
-            if (pollJobs())
-            {
-              stage = STAGE_FINALIZE;
-            }
-            updateGlobalStatus();
-          }
-          if (stage == STAGE_FINALIZE)
-          {
-            // final stage - results are collected and stored
-            result = done();
-            eventHandler.fireTaskCompleted(result);
-            stage = STAGE_DONE;
-          }
-          retryCount = 0;
-        } catch (IOException e)
-        {
-          eventHandler.fireTaskException(e);
-          if (++retryCount > MAX_RETRY)
-          {
-            stage = STAGE_DONE;
-            cancelJobs();
-            setStatus(JobStatus.SERVER_ERROR);
-            throw new CompletionException(e);
-          }
-        }
-        if (stage == STAGE_DONE)
-        {
-          // finalization - terminating the future task
-          throw new CancellationException("task terminated");
-        }
-      }
-    };
-    if (taskStatus != JobStatus.CANCELLED)
-      future = executor.scheduleWithFixedDelay(runnable, 0, 2, TimeUnit.SECONDS);
-  }
-
-  @Override
-  public JobStatus getStatus()
-  {
-    return taskStatus;
-  }
-
-  /**
-   * Set the status of the task and notify the event handler.
-   * 
-   * @param status
-   *          new task status
-   */
-  protected void setStatus(JobStatus status)
-  {
-    if (this.taskStatus != status)
-    {
-      this.taskStatus = status;
-      eventHandler.fireTaskStatusChanged(status);
-    }
-  }
-
-  /**
-   * Update task status according to the overall status of its jobs. The rules
-   * of setting the status are following:
-   * <ul>
-   * <li>task is invalid if all jobs are invalid</li>
-   * <li>task is completed if all but invalid jobs are completed</li>
-   * <li>task is ready, submitted or queued if at least one job is ready,
-   * submitted or queued an none proceeded to the next stage excluding
-   * completed.</li>
-   * <li>task is running if at least one job is running and none are failed or
-   * cancelled</li>
-   * <li>task is cancelled if at least one job is cancelled and none failed</li>
-   * <li>task is failed or server error if at least one job is failed or server
-   * error</li>
-   * </ul>
-   */
-  private void updateGlobalStatus()
-  {
-    int precedence = -1;
-    for (BaseJob job : jobs)
-    {
-      JobStatus status = job.getStatus();
-      int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
-      if (precedence < jobPrecedence)
-        precedence = jobPrecedence;
-    }
-    if (precedence >= 0)
-    {
-      setStatus(JobStatus.statusPrecedence[precedence]);
-    }
-  }
-
-  @Override
-  public void cancel()
-  {
-    setStatus(JobStatus.CANCELLED);
-    if (future != null)
-      future.cancel(false);
-    cancelJobs();
-  }
-
-  @Override
-  public List<? extends BaseJob> getSubJobs()
-  {
-    return jobs;
-  }
-
-  /**
-   * Collect and process input sequences for submission and return the list of
-   * jobs to be submitted.
-   * 
-   * @return list of jobs to be submitted
-   * @throws ServiceInputInvalidException
-   *           input is invalid and the task should not be started
-   */
-  protected abstract List<T> prepare() throws ServiceInputInvalidException;
-
-  /**
-   * Submit all valid jobs to the server and store their job handles.
-   * 
-   * @throws IOException
-   *           if server error occurred
-   */
-  protected void startJobs() throws IOException
-  {
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && job.getStatus() == JobStatus.READY)
-      {
-        WebServiceJobHandle serverJob = client.submit(job.getInputSequences(),
-            args, credentials);
-        job.setServerJob(serverJob);
-        job.setStatus(JobStatus.SUBMITTED);
-      }
-    }
-  }
-
-  /**
-   * Poll all running jobs and update their status and logs. Polling is repeated
-   * periodically until this method return true when all jobs are done.
-   * 
-   * @return {@code true] if all jobs are done @throws IOException if server
-   *         error occurred
-   */
-  protected boolean pollJobs() throws IOException
-  {
-    boolean allDone = true;
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && !job.getStatus().isDone())
-      {
-        WebServiceJobHandle serverJob = job.getServerJob();
-        job.setStatus(client.getStatus(serverJob));
-        job.setLog(client.getLog(serverJob));
-        job.setErrorLog(client.getErrorLog(serverJob));
-      }
-      allDone &= job.isCompleted();
-    }
-    return allDone;
-  }
-
-  /**
-   * Fetch and process the outputs produced by jobs and return the final result
-   * of the task. The method is called once all jobs have finished execution. If
-   * this method raises {@link IOException} it will be called again after a
-   * delay. All IO operations should happen before data processing, so
-   * potentially expensive computation is avoided in case of an error.
-   * 
-   * @return final result of the computation
-   * @throws IOException
-   *           if server error occurred
-   */
-  protected abstract R done() throws IOException;
-
-  /**
-   * Cancel all running jobs. Used in case of task failure to cleanup the
-   * resources or when the task has been cancelled.
-   */
-  protected void cancelJobs()
-  {
-    for (BaseJob job : jobs)
-    {
-      if (!job.isCompleted())
-      {
-        try
-        {
-          if (job.getServerJob() != null)
-          {
-            client.cancel(job.getServerJob());
-          }
-          job.setStatus(JobStatus.CANCELLED);
-        } catch (IOException e)
-        {
-          Console.error(format("failed to cancel job %s", job.getServerJob()), e);
-        }
-      }
-    }
-  }
-
-  @Override
-  public R getResult()
-  {
-    return result;
-  }
-
-  @Override
-  public String toString()
-  {
-    var status = taskStatus != null ? taskStatus.name() : "UNSET";
-    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, status);
-  }
-}
index 6a4a600..79c1aa8 100644 (file)
@@ -32,7 +32,7 @@ public abstract class BaseJob implements JobI
 
   protected final List<SequenceI> inputSeqs;
 
-  protected JobStatus status = null;
+  protected JobStatus status = JobStatus.CREATED;
 
   protected String log = "";
 
@@ -177,7 +177,7 @@ public abstract class BaseJob implements JobI
    * @param listener
    *          property change listener
    */
-  public final void addPropertyChagneListener(PropertyChangeListener listener)
+  public final void addPropertyChangeListener(PropertyChangeListener listener)
   {
     pcs.addPropertyChangeListener(listener);
   }
diff --git a/src/jalview/ws2/actions/BaseTask.java b/src/jalview/ws2/actions/BaseTask.java
new file mode 100644 (file)
index 0000000..387ca69
--- /dev/null
@@ -0,0 +1,311 @@
+package jalview.ws2.actions;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import jalview.bin.Console;
+import jalview.util.ArrayUtils;
+import jalview.util.MathUtils;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.client.api.WebServiceClientI;
+import jalview.ws2.helpers.DelegateJobEventListener;
+import jalview.ws2.helpers.TaskEventSupport;
+
+import static java.lang.String.format;
+
+public abstract class BaseTask<T extends BaseJob, R> implements TaskI<R>
+{
+  protected final long uid = MathUtils.getUID();
+
+  protected final WebServiceClientI webClient;
+
+  protected final List<ArgumentI> args;
+
+  protected final Credentials credentials;
+
+  private final TaskEventSupport<R> eventHandler;
+
+  protected JobStatus status = JobStatus.CREATED;
+
+  protected List<T> jobs = Collections.emptyList();
+
+  protected R result = null;
+
+  protected Runnable cancelAction = () -> {
+  };
+
+  protected BaseTask(WebServiceClientI webClient, List<ArgumentI> args,
+      Credentials credentials)
+  {
+    this.webClient = webClient;
+    this.args = args;
+    this.credentials = credentials;
+    this.eventHandler = new TaskEventSupport<>(this);
+  }
+
+  @Override
+  public final long getUid()
+  {
+    return uid;
+  }
+
+  @Override
+  public final JobStatus getStatus()
+  {
+    return status;
+  }
+
+  @Override
+  public final List<? extends BaseJob> getSubJobs()
+  {
+    return jobs;
+  }
+
+  @Override
+  public final void addTaskEventListener(TaskEventListener<R> listener)
+  {
+    eventHandler.addListener(listener);
+  }
+
+  @Override
+  public final void removeTaskEventListener(TaskEventListener<R> listener)
+  {
+    eventHandler.addListener(listener);
+  }
+
+  @Override
+  public final R getResult()
+  {
+    return result;
+  }
+
+  @Override
+  public final void init() throws Exception
+  {
+    try
+    {
+      jobs = prepareJobs();
+    } catch (ServiceInputInvalidException e)
+    {
+      setStatus(JobStatus.INVALID);
+      eventHandler.fireTaskException(e);
+      throw e;
+    }
+    setStatus(JobStatus.READY);
+    eventHandler.fireTaskStarted(jobs);
+    var jobListener = new DelegateJobEventListener<>(eventHandler);
+    for (var job : jobs)
+      job.addPropertyChangeListener(jobListener);
+    submitJobs(jobs);
+  }
+
+  static final int MAX_SUBMIT_RETRY = 5;
+
+  protected final void submitJobs(List<T> jobs) throws IOException
+  {
+    var retryCounter = 0;
+    while (true)
+    {
+      try
+      {
+        submitJobs0(jobs);
+        setStatus(JobStatus.SUBMITTED);
+        break;
+      } catch (IOException e)
+      {
+        eventHandler.fireTaskException(e);
+        if (++retryCounter > MAX_SUBMIT_RETRY)
+        {
+          cancel();
+          setStatus(JobStatus.SERVER_ERROR);
+          throw e;
+        }
+      }
+    }
+  }
+
+  private final void submitJobs0(List<T> jobs) throws IOException
+  {
+    IOException exception = null;
+    for (BaseJob job : jobs)
+    {
+      if (job.getStatus() != JobStatus.READY || !job.isInputValid())
+        continue;
+      try
+      {
+        var jobRef = webClient.submit(job.getInputSequences(), args, credentials);
+        job.setServerJob(jobRef);
+        job.setStatus(JobStatus.SUBMITTED);
+      } catch (IOException e)
+      {
+        exception = e;
+      }
+    }
+    if (exception != null)
+      throw exception;
+  }
+
+  /**
+   * Poll all running jobs and update their status and logs. Polling is repeated
+   * periodically until this method return true when all jobs are done.
+   * 
+   * @return {@code true] if all jobs are done @throws IOException if server
+   *         error occurred
+   */
+  @Override
+  public final boolean poll() throws IOException
+  {
+    boolean allDone = true;
+    IOException exception = null;
+    for (BaseJob job : jobs)
+    {
+      if (job.isInputValid() && !job.getStatus().isDone())
+      {
+        var serverJob = job.getServerJob();
+        try
+        {
+          job.setStatus(webClient.getStatus(serverJob));
+          job.setLog(webClient.getLog(serverJob));
+          job.setErrorLog(webClient.getErrorLog(serverJob));
+        } catch (IOException e)
+        {
+          exception = e;
+        }
+      }
+      allDone &= job.isCompleted();
+    }
+    updateGlobalStatus();
+    if (exception != null)
+      throw exception;
+    return allDone;
+  }
+
+  @Override
+  public final void complete() throws IOException
+  {
+    for (var job : jobs)
+    {
+      if (!job.isCompleted())
+      {
+        // a fallback in case the executor decides to finish prematurely
+        cancelJob(job);
+        job.setStatus(JobStatus.SERVER_ERROR);
+      }
+    }
+    updateGlobalStatus();
+    try {
+      result = collectResult(jobs);
+      eventHandler.fireTaskCompleted(result);
+    }
+    catch (Exception e)
+    {
+      eventHandler.fireTaskException(e);
+      throw e;
+    }
+  }
+
+  /**
+   * Cancel all running jobs. Used in case of task failure to cleanup the
+   * resources or when the task has been cancelled.
+   */
+  @Override
+  public final void cancel()
+  {
+    cancelAction.run();
+    for (T job : jobs)
+    {
+      cancelJob(job);
+    }
+    setStatus(JobStatus.CANCELLED);
+  }
+
+  private final void cancelJob(T job)
+  {
+    if (!job.isCompleted())
+    {
+      try
+      {
+        if (job.getServerJob() != null)
+          webClient.cancel(job.getServerJob());
+        job.setStatus(JobStatus.CANCELLED);
+      } catch (IOException e)
+      {
+        Console.error(format("failed to cancel job %s", job.getServerJob()), e);
+      }
+    }
+  }
+
+  protected final void setStatus(JobStatus status)
+  {
+    Objects.requireNonNull(status);
+    if (this.status != status)
+    {
+      this.status = status;
+      eventHandler.fireTaskStatusChanged(status);
+    }
+  }
+
+  protected abstract List<T> prepareJobs() throws ServiceInputInvalidException;
+
+  protected abstract R collectResult(List<T> jobs) throws IOException;
+
+  /**
+   * Update task status according to the overall status of its jobs. The rules
+   * of setting the status are following:
+   * <ul>
+   * <li>task is invalid if all jobs are invalid</li>
+   * <li>task is completed if all but invalid jobs are completed</li>
+   * <li>task is ready, submitted or queued if at least one job is ready,
+   * submitted or queued an none proceeded to the next stage excluding
+   * completed.</li>
+   * <li>task is running if at least one job is running and none are failed or
+   * cancelled</li>
+   * <li>task is cancelled if at least one job is cancelled and none failed</li>
+   * <li>task is failed or server error if at least one job is failed or server
+   * error</li>
+   * </ul>
+   */
+  protected final void updateGlobalStatus()
+  {
+    int precedence = -1;
+    for (BaseJob job : jobs)
+    {
+      JobStatus status = job.getStatus();
+      int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
+      if (precedence < jobPrecedence)
+        precedence = jobPrecedence;
+    }
+    if (precedence >= 0)
+    {
+      setStatus(JobStatus.statusPrecedence[precedence]);
+    }
+  }
+
+  /**
+   * Set the action that will be run when the {@link #cancel()} method is
+   * invoked. The action should typically stop the executor polling the task and
+   * release resources and threads running the task.
+   * 
+   * @param action
+   *          runnable to be executed when the task is cancelled
+   */
+  public void setCancelAction(Runnable action)
+  {
+    Objects.requireNonNull(action);
+    this.cancelAction = action;
+  }
+
+  @Override
+  public String toString()
+  {
+    var statusName = status != null ? status.name() : "UNSET";
+    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, statusName);
+  }
+}
index 655a133..f91cef6 100644 (file)
@@ -2,10 +2,10 @@ package jalview.ws2.actions;
 
 import java.util.List;
 
+import jalview.api.AlignViewportI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.actions.api.ActionI;
-import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.actions.api.TaskI;
 import jalview.ws2.api.Credentials;
 
@@ -37,9 +37,8 @@ public final class NullAction extends BaseAction<Void>
   }
 
   @Override
-  public TaskI<Void> perform(AlignmentViewport viewport,
-          List<ArgumentI> args, Credentials credentials,
-          TaskEventListener<Void> handler)
+  public TaskI<Void> createTask(AlignViewportI viewport,
+          List<ArgumentI> args, Credentials credentials)
   {
     return new NullTask();
   }
index 223b9fb..5dd5ab0 100644 (file)
@@ -4,12 +4,13 @@ import java.util.Collections;
 import java.util.List;
 
 import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.actions.api.TaskI;
 import jalview.ws2.api.JobStatus;
 
 /**
- * An empty task returned by the {@link NullAction}. Use as a placeholder
- * for testing purposes.
+ * An empty task returned by the {@link NullAction}. Use as a placeholder for
+ * testing purposes.
  * 
  * @author mmwarowny
  *
@@ -35,6 +36,22 @@ class NullTask implements TaskI<Void>
   }
 
   @Override
+  public void init() throws Exception
+  {
+  }
+
+  @Override
+  public boolean poll() throws Exception
+  {
+    return true;
+  }
+
+  @Override
+  public void complete() throws Exception
+  {
+  }
+
+  @Override
   public Void getResult()
   {
     return null;
@@ -44,4 +61,14 @@ class NullTask implements TaskI<Void>
   public void cancel()
   {
   }
+
+  @Override
+  public void addTaskEventListener(TaskEventListener<Void> listener)
+  {
+  }
+
+  @Override
+  public void removeTaskEventListener(TaskEventListener<Void> listener)
+  {
+  }
 }
\ No newline at end of file
diff --git a/src/jalview/ws2/actions/PollingTaskExecutor.java b/src/jalview/ws2/actions/PollingTaskExecutor.java
new file mode 100644 (file)
index 0000000..ff7a3db
--- /dev/null
@@ -0,0 +1,108 @@
+package jalview.ws2.actions;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.WeakHashMap;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import jalview.ws2.actions.api.TaskI;
+
+public class PollingTaskExecutor
+{
+  private static final Map<ScheduledExecutorService, PollingTaskExecutor> executorPool =
+      Collections.synchronizedMap(new WeakHashMap<>());
+  
+  public static PollingTaskExecutor fromPool(ScheduledExecutorService executor)
+  {
+    return executorPool.computeIfAbsent(executor, PollingTaskExecutor::new);
+  }
+  
+  private final ScheduledExecutorService executor;
+
+  public PollingTaskExecutor(ScheduledExecutorService executor)
+  {
+    this.executor = executor;
+  }
+
+  public Future<?> submit(TaskI<?> task)
+  {
+    Objects.requireNonNull(task);
+    return executor.scheduleWithFixedDelay(
+        new TaskRunnable(task), 0, 2, TimeUnit.SECONDS);
+  }
+
+  private static class TaskRunnable implements Runnable
+  {
+    private final TaskI<?> task;
+
+    private static final int STAGE_INIT = 0;
+
+    private static final int STAGE_POLLING = 2;
+
+    private static final int STAGE_FINISHED = 3;
+
+    private static final int STAGE_STOPPED = 4;
+
+    private int stage = STAGE_INIT;
+
+    private static final int MAX_POLL_RETRY = 5;
+
+    private int pollRetryCount = 0;
+
+    private TaskRunnable(TaskI<?> task)
+    {
+      this.task = task;
+    }
+
+    @Override
+    public void run()
+    {
+      if (task.getStatus().isDone())
+      {
+        stage = STAGE_STOPPED;
+      }
+      if (stage == STAGE_INIT)
+      {
+        try
+        {
+          task.init();
+          stage = STAGE_POLLING;
+        } catch (Exception e)
+        {
+          stage = STAGE_STOPPED;
+          throw new CompletionException(e);
+        }
+      }
+      try
+      {
+        if (stage == STAGE_POLLING && task.poll())
+        {
+          stage = STAGE_FINISHED;
+        }
+        if (stage == STAGE_FINISHED)
+        {
+          task.complete();
+          stage = STAGE_STOPPED;
+        }
+      } catch (Exception e)
+      {
+        if (++pollRetryCount > MAX_POLL_RETRY || e instanceof RuntimeException)
+        {
+          task.cancel();
+          stage = STAGE_STOPPED;
+          throw new CompletionException(e);
+        }
+      }
+      if (stage == STAGE_STOPPED)
+      {
+        throw new CancellationException();
+      }
+    }
+  }
+}
index 7f935bf..ce4d499 100644 (file)
@@ -3,9 +3,12 @@ package jalview.ws2.actions.alignment;
 import java.util.List;
 import java.util.Objects;
 
+import jalview.api.AlignViewportI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.actions.BaseAction;
+import jalview.ws2.actions.BaseTask;
+import jalview.ws2.actions.PollingTaskExecutor;
 import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.actions.api.TaskI;
 import jalview.ws2.api.Credentials;
@@ -68,15 +71,11 @@ public class AlignmentAction extends BaseAction<AlignmentResult>
   }
 
   @Override
-  public TaskI<AlignmentResult> perform(AlignmentViewport viewport,
-      List<ArgumentI> args, Credentials credentials,
-      TaskEventListener<AlignmentResult> handler)
+  public AlignmentTask createTask(AlignViewportI viewport,
+      List<ArgumentI> args, Credentials credentials)
   {
-    var msa = viewport.getAlignmentView(true);
-    var task = new AlignmentTask(
-        client, this, args, credentials, msa, viewport, submitGaps, handler);
-    task.start(viewport.getServiceExecutor());
-    return task;
+    return new AlignmentTask(
+        client, this, args, credentials, viewport, submitGaps);
   }
 
   /**
index 6a0c4dd..944e5dd 100644 (file)
@@ -5,7 +5,6 @@ import static java.lang.String.format;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -14,7 +13,6 @@ import jalview.analysis.AlignmentSorter;
 import jalview.analysis.SeqsetUtils;
 import jalview.analysis.SeqsetUtils.SequenceInfo;
 import jalview.api.AlignViewportI;
-import jalview.bin.Cache;
 import jalview.bin.Console;
 import jalview.datamodel.AlignedCodonFrame;
 import jalview.datamodel.Alignment;
@@ -25,9 +23,8 @@ import jalview.datamodel.HiddenColumns;
 import jalview.datamodel.Sequence;
 import jalview.datamodel.SequenceI;
 import jalview.ws.params.ArgumentI;
-import jalview.ws2.actions.AbstractPollableTask;
+import jalview.ws2.actions.BaseTask;
 import jalview.ws2.actions.ServiceInputInvalidException;
-import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.api.Credentials;
 import jalview.ws2.api.JobStatus;
 import jalview.ws2.client.api.AlignmentWebServiceClientI;
@@ -39,7 +36,7 @@ import jalview.ws2.client.api.AlignmentWebServiceClientI;
  * @author mmwarowny
  *
  */
-class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
+class AlignmentTask extends BaseTask<AlignmentJob, AlignmentResult>
 {
   /* task parameters set in the constructor */
   private final AlignmentWebServiceClientI client;
@@ -48,8 +45,6 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
 
   private final AlignmentView msa; // a.k.a. input
 
-  private final AlignViewportI viewport;
-
   private final boolean submitGaps;
 
   private final AlignmentI currentView;
@@ -62,14 +57,12 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
 
   AlignmentTask(AlignmentWebServiceClientI client, AlignmentAction action,
       List<ArgumentI> args, Credentials credentials,
-      AlignmentView msa, AlignViewportI viewport, boolean submitGaps,
-      TaskEventListener<AlignmentResult> eventListener)
+      AlignViewportI viewport, boolean submitGaps)
   {
-    super(client, args, credentials, eventListener);
+    super(client, args, credentials);
     this.client = client;
     this.action = action;
-    this.msa = msa;
-    this.viewport = viewport;
+    this.msa = viewport.getAlignmentView(true);
     this.submitGaps = submitGaps;
     this.currentView = viewport.getAlignment();
     this.dataset = viewport.getAlignment().getDataset();
@@ -80,7 +73,7 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
   }
   
   @Override
-  protected List<AlignmentJob> prepare() throws ServiceInputInvalidException
+  protected List<AlignmentJob> prepareJobs() throws ServiceInputInvalidException
   { 
     Console.info(format("starting alignment service %s:%s",
         client.getClientName(), action.getName()));
@@ -107,7 +100,7 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
   }
 
   @Override
-  protected AlignmentResult done() throws IOException
+  protected AlignmentResult collectResult(List<AlignmentJob> jobs) throws IOException
   {
     IOException lastIOE = null;
     for (AlignmentJob job : jobs)
diff --git a/src/jalview/ws2/actions/annotation/AlignCalcWorkerAdapter.java b/src/jalview/ws2/actions/annotation/AlignCalcWorkerAdapter.java
new file mode 100644 (file)
index 0000000..0905ea2
--- /dev/null
@@ -0,0 +1,188 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
+
+import jalview.analysis.AlignmentAnnotationUtils;
+import jalview.api.AlignViewportI;
+import jalview.api.AlignmentViewPanel;
+import jalview.api.PollableAlignCalcWorkerI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.AlignmentI;
+import jalview.workers.AlignCalcWorker;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+
+public class AlignCalcWorkerAdapter extends AlignCalcWorker implements PollableAlignCalcWorkerI
+{
+  private static int calcCount = 0;
+
+  private final int calcNumber = calcCount++;
+
+  private final AnnotationAction action;
+
+  private final List<ArgumentI> args;
+
+  private final Credentials credentials;
+
+  private TaskI<AnnotationResult> currentTask = null;
+
+  private TaskEventListener<AnnotationResult> taskListener = new TaskEventListener<>()
+  {
+    @Override
+    public void taskCompleted(TaskI source, AnnotationResult result)
+    {
+      int graphGroup = alignViewport.getAlignment().getLastGraphGroup();
+      List<AlignmentAnnotation> annotations = new ArrayList<>();
+      for (AlignmentAnnotation ala : result.getAnnotations())
+      {
+        if (ala.graphGroup > 0)
+          ala.graphGroup += graphGroup;
+        var newAnnot = alignViewport.getAlignment()
+            .updateFromOrCopyAnnotation(ala);
+        if (ala.sequenceRef != null)
+        {
+          ala.sequenceRef.addAlignmentAnnotation(newAnnot);
+          newAnnot.adjustForAlignment();
+          AlignmentAnnotationUtils.replaceAnnotationOnAlignmentWith(
+              newAnnot, newAnnot.label, newAnnot.getCalcId());
+        }
+        annotations.add(newAnnot);
+      }
+      updateOurAnnots(annotations);
+      listener.workerHasResult(
+          AlignCalcWorkerAdapter.this,
+          new AnnotationResult(
+              annotations,
+              result.transferFeatures,
+              result.featureColours,
+              result.featureFilters));
+    }
+  };
+
+  public AlignCalcWorkerAdapter(AlignViewportI alignViewport, AlignmentViewPanel alignPanel, AnnotationAction action,
+      List<ArgumentI> args, Credentials credentials)
+  {
+    super(alignViewport, alignPanel);
+    this.action = action;
+    this.args = args;
+    this.credentials = credentials;
+  }
+
+  @Override
+  public String getCalcName()
+  {
+    return action.getWebService().getName();
+  }
+
+  @Override
+  public void updateAnnotation()
+  {
+
+  }
+
+  private void updateOurAnnots(List<AlignmentAnnotation> newAnnots)
+  {
+    List<AlignmentAnnotation> oldAnnots = ourAnnots != null ? ourAnnots : List.of();
+    AlignmentI alignment = alignViewport.getAlignment();
+    for (AlignmentAnnotation annotation : oldAnnots)
+      if (!newAnnots.contains(annotation))
+        alignment.deleteAnnotation(annotation);
+    for (AlignmentAnnotation annotation : newAnnots)
+      alignment.validateAnnotation(annotation);
+    ourAnnots = Collections.synchronizedList(newAnnots);
+  }
+
+  @Override
+  public synchronized void startUp() throws Throwable
+  {
+    if (alignViewport.isClosed())
+    {
+      calcMan.disableWorker(this);
+      abortAndDestroy();
+      throw new IllegalStateException("Starting calculation for closed viewport");
+    }
+    currentTask = action.createTask(alignViewport, args, credentials);
+    currentTask.addTaskEventListener(taskListener);
+    currentTask.init();
+    listener.workerStarted(this);
+  }
+
+  @Override
+  public boolean poll() throws Throwable
+  {
+    return currentTask.poll();
+  }
+
+  @Override
+  public synchronized void cancel()
+  {
+    try
+    {
+      currentTask.cancel();
+    } finally
+    {
+      currentTask.removeTaskEventListener(taskListener);
+      listener.workerStopped(this);
+    }
+  }
+
+  @Override
+  public synchronized void done()
+  {
+    try
+    {
+      currentTask.complete();
+    } catch (Exception e)
+    {
+    } finally
+    {
+      currentTask.removeTaskEventListener(taskListener);
+      listener.workerStopped(this);
+    }
+  }
+
+  @Override
+  public boolean isDeletable()
+  {
+    return true;
+  }
+
+  @Override
+  public String toString()
+  {
+    return "AlignCalcWorkerAdapter-" + calcNumber + " for " + getCalcName();
+  }
+
+  public interface WorkerListener extends java.util.EventListener
+  {
+    default void workerStarted(AlignCalcWorkerAdapter source)
+    {
+    };
+
+    default void workerStopped(AlignCalcWorkerAdapter source)
+    {
+    };
+
+    default void workerHasResult(AlignCalcWorkerAdapter source, AnnotationResult result)
+    {
+    };
+
+    static final WorkerListener NULL_LISTENER = new WorkerListener()
+    {
+    };
+  }
+
+  private WorkerListener listener = WorkerListener.NULL_LISTENER;
+  
+  public void setWorkerListener(WorkerListener listener)
+  {
+    if (listener == null) listener = WorkerListener.NULL_LISTENER;
+    this.listener = listener;
+  }
+}
index 02829fd..b8450a9 100644 (file)
@@ -3,6 +3,7 @@ package jalview.ws2.actions.annotation;
 import java.util.List;
 import java.util.Objects;
 
+import jalview.api.AlignViewportI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.actions.BaseAction;
@@ -84,18 +85,14 @@ public class AnnotationAction extends BaseAction<AnnotationResult>
     requireAlignedSequences = builder.requireAlignedSequences;
     filterSymbols = builder.filterSymbols;
   }
-
+  
   @Override
-  public TaskI<AnnotationResult> perform(AlignmentViewport viewport,
-      List<ArgumentI> args, Credentials credentials,
-      TaskEventListener<AnnotationResult> handler)
+  public AnnotationTask createTask(AlignViewportI viewport,
+      List<ArgumentI> args, Credentials credentials)
   {
-    var task = new AnnotationTask(client, this, args, credentials, viewport,
-        handler);
-    task.start(viewport.getCalcManager());
-    return task;
+    return new AnnotationTask(client, this, args, credentials, viewport);
   }
-
+  
   /**
    * Return if this action is an alignment analysis service.
    */
index 3a836a0..d865352 100644 (file)
@@ -32,7 +32,8 @@ public interface AnnotationProviderI
    * @param job
    *          web service job
    * @param sequences
-   *          sequences the annotations will be added to
+   *          features and alignment annotation added to these will be
+   *          imported to the dataset for the alignment
    * @param colours
    *          container for feature colours
    * @param filters
index 271d9ed..866f862 100644 (file)
@@ -2,19 +2,13 @@ package jalview.ws2.actions.annotation;
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 import jalview.analysis.AlignmentAnnotationUtils;
-import jalview.api.AlignCalcManagerI2;
-import jalview.api.AlignCalcWorkerI;
 import jalview.api.AlignViewportI;
 import jalview.api.FeatureColourI;
-import jalview.api.PollableAlignCalcWorkerI;
-import jalview.bin.Cache;
-import jalview.bin.Console;
 import jalview.datamodel.AlignmentAnnotation;
 import jalview.datamodel.AlignmentI;
 import jalview.datamodel.AnnotatedCollectionI;
@@ -23,306 +17,33 @@ import jalview.datamodel.ContiguousI;
 import jalview.datamodel.Mapping;
 import jalview.datamodel.SequenceI;
 import jalview.datamodel.features.FeatureMatcherSetI;
-import jalview.schemes.FeatureSettingsAdapter;
-import jalview.util.ArrayUtils;
 import jalview.util.MapList;
-import jalview.util.MathUtils;
-import jalview.util.Pair;
-import jalview.workers.AlignCalcWorker;
 import jalview.ws.params.ArgumentI;
-import jalview.ws2.actions.AbstractPollableTask;
-import jalview.ws2.actions.BaseJob;
+import jalview.ws2.actions.BaseTask;
 import jalview.ws2.actions.ServiceInputInvalidException;
-import jalview.ws2.actions.api.JobI;
-import jalview.ws2.actions.api.TaskEventListener;
-import jalview.ws2.actions.api.TaskI;
 import jalview.ws2.api.Credentials;
 import jalview.ws2.api.JobStatus;
-import jalview.ws2.api.WebServiceJobHandle;
 import jalview.ws2.client.api.AnnotationWebServiceClientI;
-import jalview.ws2.helpers.DelegateJobEventListener;
-import jalview.ws2.helpers.TaskEventSupport;
 
-public class AnnotationTask implements TaskI<AnnotationResult>
+public class AnnotationTask extends BaseTask<AnnotationJob, AnnotationResult>
 {
-  private final long uid = MathUtils.getUID();
-
   private AnnotationWebServiceClientI client;
 
   private final AnnotationAction action;
 
-  private final List<ArgumentI> args;
-
-  private final Credentials credentials;
-
-  private final AlignViewportI viewport;
-
-  private final TaskEventSupport<AnnotationResult> eventHandler;
-
-  private JobStatus taskStatus = null;
-
-  private AlignCalcWorkerAdapter worker = null;
-
-  private List<AnnotationJob> jobs = Collections.emptyList();
-
-  private AnnotationResult result = null;
-
-  private DelegateJobEventListener<AnnotationResult> jobEventHandler;
-
-  private class AlignCalcWorkerAdapter extends AlignCalcWorker
-      implements PollableAlignCalcWorkerI
-  {
-    private boolean restarting = false;
-
-    AlignCalcWorkerAdapter(AlignCalcManagerI2 calcMan)
-    {
-      super(viewport, null);
-      this.calcMan = calcMan;
-    }
-
-    String getServiceName()
-    {
-      return action.getWebService().getName();
-    }
-
-    @Override
-    public void startUp() throws Throwable
-    {
-      if (alignViewport.isClosed())
-      {
-        stop();
-        throw new IllegalStateException("Starting annotation for closed viewport");
-      }
-      if (restarting)
-        eventHandler.fireTaskRestarted();
-      else
-        restarting = true;
-      jobs = Collections.emptyList();
-      try
-      {
-        jobs = prepare();
-      } catch (ServiceInputInvalidException e)
-      {
-        setStatus(JobStatus.INVALID);
-        eventHandler.fireTaskException(e);
-        throw e;
-      }
-      setStatus(JobStatus.READY);
-      eventHandler.fireTaskStarted(jobs);
-      for (var job : jobs)
-      {
-        job.addPropertyChagneListener(jobEventHandler);
-      }
-      try
-      {
-        startJobs();
-      } catch (IOException e)
-      {
-        eventHandler.fireTaskException(e);
-        cancelJobs();
-        setStatus(JobStatus.SERVER_ERROR);
-        throw e;
-      }
-      setStatus(JobStatus.SUBMITTED);
-    }
-
-    @Override
-    public boolean poll() throws Throwable
-    {
-      boolean done = AnnotationTask.this.poll();
-      updateGlobalStatus();
-      if (done)
-      {
-        retrieveAndProcessResult();
-        eventHandler.fireTaskCompleted(result);
-      }
-      return done;
-    }
-
-    private void retrieveAndProcessResult() throws IOException
-    {
-      result = retrieveResult();
-      updateOurAnnots(result.annotations);
-      if (result.transferFeatures)
-      {
-        final var featureColours = result.featureColours;
-        final var featureFilters = result.featureFilters;
-        viewport.applyFeaturesStyle(new FeatureSettingsAdapter()
-        {
-          @Override
-          public FeatureColourI getFeatureColour(String type)
-          {
-            return featureColours.get(type);
-          }
-
-          @Override
-          public FeatureMatcherSetI getFeatureFilters(String type)
-          {
-            return featureFilters.get(type);
-          }
-
-          @Override
-          public boolean isFeatureDisplayed(String type)
-          {
-            return featureColours.containsKey(type);
-          }
-        });
-      }
-    }
-
-    @Override
-    public void updateAnnotation()
-    {
-      var job = jobs.size() > 0 ? jobs.get(0) : null;
-      if (!calcMan.isWorking(this) && job != null)
-      {
-        var ret = updateResultAnnotation(job, job.returnedAnnotations);
-        updateOurAnnots(ret.get0());
-      }
-    }
-
-    private void updateOurAnnots(List<AlignmentAnnotation> newAnnots)
-    {
-      List<AlignmentAnnotation> oldAnnots = ourAnnots != null ? ourAnnots : Collections.emptyList();
-      ourAnnots = newAnnots;
-      AlignmentI alignment = viewport.getAlignment();
-      for (AlignmentAnnotation an : oldAnnots)
-      {
-        if (!newAnnots.contains(an))
-        {
-          alignment.deleteAnnotation(an);
-        }
-      }
-      oldAnnots.clear();
-      for (AlignmentAnnotation an : ourAnnots)
-      {
-        viewport.getAlignment().validateAnnotation(an);
-      }
-    }
-
-    @Override
-    public void cancel()
-    {
-      cancelJobs();
-    }
-
-    void stop()
-    {
-      calcMan.disableWorker(this);
-      super.abortAndDestroy();
-    }
+  private final AlignmentI alignment;
 
-    @Override
-    public void done()
-    {
-      for (var job : jobs)
-      {
-        if (job.isInputValid() && !job.isCompleted())
-        {
-          /* if done was called but job is not completed then it
-           * must have been stopped by an exception */
-          job.setStatus(JobStatus.SERVER_ERROR);
-        }
-      }
-      updateGlobalStatus();
-      // dispose of unfinished jobs just in case
-      cancelJobs();
-    }
-
-    @Override
-    public String toString()
-    {
-      return AnnotationTask.this.toString() + "$AlignCalcWorker@"
-          + Integer.toHexString(hashCode());
-    }
-  }
+  private final AnnotatedCollectionI selectionGroup;
 
   public AnnotationTask(AnnotationWebServiceClientI client,
       AnnotationAction action, List<ArgumentI> args, Credentials credentials,
-      AlignViewportI viewport,
-      TaskEventListener<AnnotationResult> eventListener)
+      AlignViewportI viewport)
   {
+    super(client, args, credentials);
     this.client = client;
     this.action = action;
-    this.args = args;
-    this.credentials = credentials;
-    this.viewport = viewport;
-    this.eventHandler = new TaskEventSupport<>(this, eventListener);
-    this.jobEventHandler = new DelegateJobEventListener<>(this.eventHandler);
-  }
-
-  @Override
-  public long getUid()
-  {
-    return uid;
-  }
-
-  public void start(AlignCalcManagerI2 calcManager)
-  {
-    if (this.worker != null)
-      throw new IllegalStateException("task already started");
-    this.worker = new AlignCalcWorkerAdapter(calcManager);
-    if (taskStatus != JobStatus.CANCELLED)
-    {
-      List<AlignCalcWorkerI> oldWorkers = calcManager.getWorkersOfClass(
-          AlignCalcWorkerAdapter.class);
-      for (var worker : oldWorkers)
-      {
-        if (action.getWebService().getName().equalsIgnoreCase(
-            ((AlignCalcWorkerAdapter) worker).getServiceName()))
-        {
-          // remove interactive workers for the same service.
-          calcManager.removeWorker(worker);
-          calcManager.cancelWorker(worker);
-        }
-      }
-      if (action.getWebService().isInteractive())
-        calcManager.registerWorker(worker);
-      else
-        calcManager.startWorker(worker);
-    }
-  }
-
-  /*
-   * The following methods are mostly copied from the {@link AbstractPollableTask}
-   * TODO: move common functionality to a base class
-   */
-  @Override
-  public JobStatus getStatus()
-  {
-    return taskStatus;
-  }
-
-  private void setStatus(JobStatus status)
-  {
-    if (this.taskStatus != status)
-    {
-      Console.debug(String.format("%s status change to %s", this, status.name()));
-      this.taskStatus = status;
-      eventHandler.fireTaskStatusChanged(status);
-    }
-  }
-
-  private void updateGlobalStatus()
-  {
-    int precedence = -1;
-    for (BaseJob job : jobs)
-    {
-      JobStatus status = job.getStatus();
-      int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
-      if (precedence < jobPrecedence)
-        precedence = jobPrecedence;
-    }
-    if (precedence >= 0)
-    {
-      setStatus(JobStatus.statusPrecedence[precedence]);
-    }
-  }
-
-  @Override
-  public List<? extends JobI> getSubJobs()
-  {
-    return jobs;
+    this.alignment = viewport.getAlignment();
+    this.selectionGroup = viewport.getSelectionGroup();
   }
 
   /**
@@ -334,9 +55,9 @@ public class AnnotationTask implements TaskI<AnnotationResult>
    * @throws ServiceInputInvalidException
    *           input data is not valid
    */
-  private List<AnnotationJob> prepare() throws ServiceInputInvalidException
+  @Override
+  public List<AnnotationJob> prepareJobs() throws ServiceInputInvalidException
   {
-    AlignmentI alignment = viewport.getAlignment();
     if (alignment == null || alignment.getWidth() <= 0 ||
         alignment.getSequences() == null)
       throw new ServiceInputInvalidException("Alignment does not contain sequences");
@@ -347,7 +68,7 @@ public class AnnotationTask implements TaskI<AnnotationResult>
       throw new ServiceInputInvalidException(
           action.getFullName() + " does not allow protein sequences");
     boolean bySequence = !action.isAlignmentAnalysis();
-    AnnotatedCollectionI inputSeqs = bySequence ? viewport.getSelectionGroup() : null;
+    AnnotatedCollectionI inputSeqs = bySequence ? selectionGroup : null;
     if (inputSeqs == null || inputSeqs.getWidth() <= 0 ||
         inputSeqs.getSequences() == null || inputSeqs.getSequences().size() < 1)
       inputSeqs = alignment;
@@ -366,38 +87,8 @@ public class AnnotationTask implements TaskI<AnnotationResult>
     return List.of(job);
   }
 
-  private void startJobs() throws IOException
-  {
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && job.getStatus() == JobStatus.READY)
-      {
-        var serverJob = client.submit(job.getInputSequences(),
-            args, credentials);
-        job.setServerJob(serverJob);
-        job.setStatus(JobStatus.SUBMITTED);
-      }
-    }
-  }
-
-  private boolean poll() throws IOException
-  {
-    boolean allDone = true;
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && !job.getStatus().isDone())
-      {
-        WebServiceJobHandle serverJob = job.getServerJob();
-        job.setStatus(client.getStatus(serverJob));
-        job.setLog(client.getLog(serverJob));
-        job.setErrorLog(client.getErrorLog(serverJob));
-      }
-      allDone &= job.isCompleted();
-    }
-    return allDone;
-  }
-
-  private AnnotationResult retrieveResult() throws IOException
+  @Override
+  protected AnnotationResult collectResult(List<AnnotationJob> jobs) throws IOException
   {
     final Map<String, FeatureColourI> featureColours = new HashMap<>();
     final Map<String, FeatureMatcherSetI> featureFilters = new HashMap<>();
@@ -410,179 +101,88 @@ public class AnnotationTask implements TaskI<AnnotationResult>
      * sequence, excluding regions not annotated due to gapMap/column
      * visibility */
 
-    // update calcId if it is not already set on returned annotation
-    for (AlignmentAnnotation annot : returnedAnnot)
-    {
-      if (annot.getCalcId() == null || annot.getCalcId().isEmpty())
-      {
-        annot.setCalcId(action.getFullName());
-      }
-      annot.autoCalculated = action.isAlignmentAnalysis() &&
-          action.getWebService().isInteractive();
-    }
-    job.returnedAnnotations = returnedAnnot;
-    job.featureColours = featureColours;
-    job.featureFilters = featureFilters;
-    var ret = updateResultAnnotation(job, returnedAnnot);
-    var annotations = ret.get0();
-    var transferFeatures = ret.get1();
-    return new AnnotationResult(annotations, transferFeatures, featureColours,
-        featureFilters);
-  }
-
-  private Pair<List<AlignmentAnnotation>, Boolean> updateResultAnnotation(
-      AnnotationJob job, List<AlignmentAnnotation> annotations)
-  {
-    List<AlignmentAnnotation> newAnnots = new ArrayList<>();
-    // update graphGroup for all annotation
-    /* find a graphGroup greater than any existing one, could be moved
-     * to Alignment#getNewGraphGroup() - returns next unused graph group */
-    int graphGroup = 1;
-    if (viewport.getAlignment().getAlignmentAnnotation() != null)
+    udpateCalcId(returnedAnnot);
+    for (AlignmentAnnotation ala : returnedAnnot)
     {
-      for (var ala : viewport.getAlignment().getAlignmentAnnotation())
-      {
-        graphGroup = Math.max(graphGroup, ala.graphGroup);
-      }
-    }
-    // update graphGroup in the annotation rows returned form service'
-    /* TODO: look at sequence annotation rows and update graph groups in the
-     * case of reference annotation */
-    for (AlignmentAnnotation ala : annotations)
-    {
-      if (ala.graphGroup > 0)
-        ala.graphGroup += graphGroup;
       SequenceI aseq = null;
-      // transfer sequence refs and adjust gapMap
       if (ala.sequenceRef != null)
       {
-        aseq = job.seqNames.get(ala.sequenceRef.getName());
+        SequenceI seq = job.seqNames.get(ala.sequenceRef.getName());
+        aseq = seq.getRootDatasetSequence();
       }
       ala.sequenceRef = aseq;
-
-      Annotation[] resAnnot = ala.annotations;
-      boolean[] gapMap = job.gapMap;
-      Annotation[] gappedAnnot = new Annotation[Math.max(
-          viewport.getAlignment().getWidth(), gapMap.length)];
-      for (int p = 0, ap = job.start; ap < gappedAnnot.length; ap++)
-      {
-        if (gapMap.length > ap && !gapMap[ap])
-          gappedAnnot[ap] = new Annotation("", "", ' ', Float.NaN);
-        else if (p < resAnnot.length)
-          gappedAnnot[ap] = resAnnot[p++];
-        // is this loop exhaustive of resAnnot?
-      }
-      ala.annotations = gappedAnnot;
-
-      AlignmentAnnotation newAnnot = viewport.getAlignment()
-          .updateFromOrCopyAnnotation(ala);
-      if (aseq != null)
-      {
-        aseq.addAlignmentAnnotation(newAnnot);
-        newAnnot.adjustForAlignment();
-        AlignmentAnnotationUtils.replaceAnnotationOnAlignmentWith(
-            newAnnot, newAnnot.label, newAnnot.getCalcId());
-      }
-      newAnnots.add(newAnnot);
+      Annotation[] gappedAnnots = createGappedAnnotations(ala.annotations, job.start, job.gapMap);
+      ala.annotations = gappedAnnots;
     }
 
-    boolean transferFeatures = false;
+    boolean hasFeatures = false;
     for (SequenceI sq : job.getInputSequences())
     {
-      if (!sq.getFeatures().hasFeatures() &&
-          (sq.getDBRefs() == null || sq.getDBRefs().size() == 0))
+      if (!sq.getFeatures().hasFeatures() && (sq.getDBRefs() == null || sq.getDBRefs().isEmpty()))
         continue;
-      transferFeatures = true;
+      hasFeatures = true;
       SequenceI seq = job.seqNames.get(sq.getName());
-      SequenceI dseq;
-      int start = job.start, end = job.end;
-      boolean[] gapMap = job.gapMap;
-      ContiguousI seqRange = seq.findPositions(start, end);
-      while ((dseq = seq).getDatasetSequence() != null)
-      {
-        seq = seq.getDatasetSequence();
-      }
-      List<ContiguousI> sourceRange = new ArrayList<>();
-      if (gapMap.length >= end)
-      {
-        int lastcol = start, col = start;
-        do
-        {
-          if (col == end || !gapMap[col])
-          {
-            if (lastcol <= (col - 1))
-            {
-              seqRange = seq.findPositions(lastcol, col);
-              sourceRange.add(seqRange);
-            }
-            lastcol = col + 1;
-          }
-        } while (col++ < end);
-      }
-      else
-      {
-        sourceRange.add(seq.findPositions(start, end));
-      }
-
-      int i = 0;
-      int sourceStartEnd[] = new int[sourceRange.size() * 2];
-      for (ContiguousI range : sourceRange)
-      {
-        sourceStartEnd[i++] = range.getBegin();
-        sourceStartEnd[i++] = range.getEnd();
-      }
+      SequenceI datasetSeq = seq.getRootDatasetSequence();
+      List<ContiguousI> sourceRange = findContiguousRanges(datasetSeq, job.gapMap, job.start, job.end);
+      int[] sourceStartEnd = ContiguousI.toStartEndArray(sourceRange);
       Mapping mp = new Mapping(new MapList(
           sourceStartEnd, new int[]
-          { seq.getStart(), seq.getEnd() }, 1, 1));
-      dseq.transferAnnotation(sq, mp);
+          { datasetSeq.getStart(), datasetSeq.getEnd() }, 1, 1));
+      datasetSeq.transferAnnotation(sq, mp);
     }
 
-    return new Pair<>(newAnnots, transferFeatures);
-  }
-
-  @Override
-  public AnnotationResult getResult()
-  {
-    return result;
+    return new AnnotationResult(returnedAnnot, hasFeatures, featureColours, featureFilters);
   }
 
-  @Override
-  public void cancel()
+  /**
+   * Updates calcId on provided annotations if not already set.
+   */
+  public void udpateCalcId(Iterable<AlignmentAnnotation> annotations)
   {
-    setStatus(JobStatus.CANCELLED);
-    if (worker != null)
+    for (var annotation : annotations)
     {
-      worker.stop();
+      if (annotation.getCalcId() == null || annotation.getCalcId().isEmpty())
+      {
+        annotation.setCalcId(action.getFullName());
+      }
+      annotation.autoCalculated = action.isAlignmentAnalysis() &&
+          action.getWebService().isInteractive();
     }
-    cancelJobs();
   }
 
-  public void cancelJobs()
+  private Annotation[] createGappedAnnotations(Annotation[] annotations, int start, boolean[] gapMap)
   {
-    for (BaseJob job : jobs)
+    var size = Math.max(alignment.getWidth(), gapMap.length);
+    Annotation[] gappedAnnotations = new Annotation[size];
+    for (int p = 0, ap = start; ap < size; ap++)
     {
-      if (!job.isCompleted())
+      if (gapMap != null && gapMap.length > ap && !gapMap[ap])
+      {
+        gappedAnnotations[ap] = new Annotation("", "", ' ', Float.NaN);
+      }
+      else if (p < annotations.length)
       {
-        try
-        {
-          if (job.getServerJob() != null)
-          {
-            client.cancel(job.getServerJob());
-          }
-          job.setStatus(JobStatus.CANCELLED);
-        } catch (IOException e)
-        {
-          Console.error(String.format(
-              "failed to cancel job %s", job.getServerJob()), e);
-        }
+        gappedAnnotations[ap] = annotations[p++];
       }
     }
+    return gappedAnnotations;
   }
 
-  @Override
-  public String toString()
+  private List<ContiguousI> findContiguousRanges(SequenceI seq, boolean[] gapMap, int start, int end)
   {
-    var status = taskStatus != null ? taskStatus.name() : "UNSET";
-    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, status);
+    if (gapMap == null || gapMap.length < end)
+      return List.of(seq.findPositions(start, end));
+    List<ContiguousI> ranges = new ArrayList<>();
+    int lastcol = start, col = start;
+    do
+    {
+      if (col == end || !gapMap[col])
+      {
+        if (lastcol < col)
+          ranges.add(seq.findPositions(lastcol, col));
+        lastcol = col + 1;
+      }
+    } while (++col <= end);
+    return ranges;
   }
 }
index 52d70df..e84fd58 100644 (file)
@@ -5,6 +5,7 @@ import java.util.List;
 
 import javax.swing.Icon;
 
+import jalview.api.AlignViewportI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.api.CredentialType;
@@ -115,10 +116,8 @@ public interface ActionI<R>
   EnumSet<CredentialType> getRequiredCredentials();
 
   /**
-   * Run the action, create and start a new task with provided viewport,
-   * arguments and credentials and attach the handler to the task. The
-   * implementations of this method are responsible for starting the task using
-   * execution method appropriate for the action class.
+   * Create a new task with provided viewport, arguments and credentials ready
+   * to be started.
    * 
    * @param viewport
    *          current alignment viewport
@@ -126,12 +125,10 @@ public interface ActionI<R>
    *          job parameters appropriate for the service
    * @param credentials
    *          optional user credentials
-   * @param handler
-   *          event handler attached to the new task
-   * @return new running task
+   * @return new task
    */
-  TaskI<R> perform(AlignmentViewport viewport, List<ArgumentI> args,
-      Credentials credentials, TaskEventListener<R> handler);
+  TaskI<R> createTask(AlignViewportI viewport, List<ArgumentI> args,
+      Credentials credentials);
 
   /**
    * Return if the action is currently active for the given viewport. Active
index 7427aa8..b0bb4b6 100644 (file)
@@ -30,7 +30,7 @@ public interface TaskEventListener<T>
    * @param subJobs
    *          list of sub-jobs for this run
    */
-  void taskStarted(TaskI<T> source, List<? extends JobI> subJobs);
+  default void taskStarted(TaskI<T> source, List<? extends JobI> subJobs) {};
 
   /**
    * Invoked when the global task status has changed.
@@ -40,7 +40,7 @@ public interface TaskEventListener<T>
    * @param status
    *          new task status
    */
-  void taskStatusChanged(TaskI<T> source, JobStatus status);
+  default void taskStatusChanged(TaskI<T> source, JobStatus status) {};
 
   /**
    * Invoked when the task has completed. If the task completed with a result,
@@ -52,7 +52,7 @@ public interface TaskEventListener<T>
    * @param result
    *          computation result or null if result not present
    */
-  void taskCompleted(TaskI<T> source, T result);
+  default void taskCompleted(TaskI<T> source, T result) {};
 
   /**
    * Invoked when an unhandled exception has occurred during task execution.
@@ -62,17 +62,7 @@ public interface TaskEventListener<T>
    * @param e
    *          exception
    */
-  void taskException(TaskI<T> source, Exception e);
-
-  /**
-   * Invoked when the task had been restarted. This event is only applicable to
-   * restartable tasks and will precede each {@link #taskStarted} after the
-   * first one.
-   * 
-   * @param source
-   *          task this event originates from
-   */
-  void taskRestarted(TaskI<T> source);
+  default void taskException(TaskI<T> source, Exception e) {};
 
   /**
    * Invoked when the status of a sub-job has changed.
@@ -84,7 +74,7 @@ public interface TaskEventListener<T>
    * @param status
    *          new job status
    */
-  void subJobStatusChanged(TaskI<T> source, JobI job, JobStatus status);
+  default void subJobStatusChanged(TaskI<T> source, JobI job, JobStatus status) {};
 
   /**
    * Invoked when a log string of the sub-job has changed.
@@ -96,7 +86,7 @@ public interface TaskEventListener<T>
    * @param log
    *          new log string
    */
-  void subJobLogChanged(TaskI<T> source, JobI job, String log);
+  default void subJobLogChanged(TaskI<T> source, JobI job, String log) {};
 
   /**
    * Invoked when an error log string of the sub-job has changed.
@@ -108,7 +98,7 @@ public interface TaskEventListener<T>
    * @param log
    *          new log string
    */
-  void subJobErrorLogChanged(TaskI<T> source, JobI job, String log);
+  default void subJobErrorLogChanged(TaskI<T> source, JobI job, String log) {};
 
   @SuppressWarnings("rawtypes")
   static final TaskEventListener NULL_LISTENER = new TaskEventListener()
@@ -138,12 +128,6 @@ public interface TaskEventListener<T>
     }
 
     @Override
-    public void taskRestarted(TaskI source)
-    {
-      Console.info("task restarted");
-    }
-
-    @Override
     public void subJobStatusChanged(TaskI source, JobI job,
             JobStatus status)
     {
index cb84944..5f4d575 100644 (file)
@@ -38,6 +38,10 @@ public interface TaskI<T>
    */
   List<? extends JobI> getSubJobs();
 
+  void addTaskEventListener(TaskEventListener<T> listener);
+
+  void removeTaskEventListener(TaskEventListener<T> listener);
+
   /**
    * Get the last result of the task or {@code null} if not present. Note that
    * the result is subject to change for restartable tasks.
@@ -46,6 +50,12 @@ public interface TaskI<T>
    */
   T getResult();
 
+  public void init() throws Exception;
+
+  public boolean poll() throws Exception;
+
+  public void complete() throws Exception;
+
   /**
    * Cancel the task, stop all sub-jobs running on a server and stop all threads
    * managing this task.
index 5e6ef08..bdba2f7 100644 (file)
@@ -3,10 +3,12 @@ package jalview.ws2.actions.hmmer;
 import java.util.List;
 import java.util.Objects;
 
+import jalview.api.AlignViewportI;
 import jalview.datamodel.AlignmentI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.actions.BaseAction;
+import jalview.ws2.actions.PollingTaskExecutor;
 import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.actions.api.TaskI;
 import jalview.ws2.api.Credentials;
@@ -54,16 +56,23 @@ public class PhmmerAction extends BaseAction<AlignmentI>
     client = builder.client;
   }
 
-  @Override
   public TaskI<AlignmentI> perform(AlignmentViewport viewport,
           List<ArgumentI> args, Credentials credentials,
           TaskEventListener<AlignmentI> handler)
   {
-    var task = new PhmmerTask(client, args, credentials,
-            viewport.getAlignmentView(true), handler);
-    task.start(viewport.getServiceExecutor());
+    var task = createTask(viewport, args, credentials);
+    task.addTaskEventListener(handler);
+    var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
+    var future = executor.submit(task);
+    task.setCancelAction(() -> { future.cancel(true); });
     return task;
   }
+  
+  public PhmmerTask createTask(AlignViewportI viewport,
+      List<ArgumentI> args, Credentials credentials) 
+  {
+    return new PhmmerTask(client, args, credentials, viewport.getAlignmentView(true));
+  }
 
   @Override
   public boolean isActive(AlignmentViewport viewport)
index 8a7a826..ede61d3 100644 (file)
@@ -3,7 +3,6 @@ package jalview.ws2.actions.hmmer;
 import static jalview.util.Comparison.GapChars;
 
 import java.io.IOException;
-import java.util.Arrays;
 import java.util.List;
 
 import jalview.analysis.AlignSeq;
@@ -16,30 +15,28 @@ import jalview.datamodel.Sequence;
 import jalview.datamodel.SequenceI;
 import jalview.util.Comparison;
 import jalview.ws.params.ArgumentI;
-import jalview.ws2.actions.AbstractPollableTask;
 import jalview.ws2.actions.BaseJob;
+import jalview.ws2.actions.BaseTask;
 import jalview.ws2.actions.ServiceInputInvalidException;
-import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.api.Credentials;
 import jalview.ws2.api.JobStatus;
 import jalview.ws2.client.api.AlignmentWebServiceClientI;
 
-class PhmmerTask extends AbstractPollableTask<BaseJob, AlignmentI>
+class PhmmerTask extends BaseTask<BaseJob, AlignmentI>
 {
   private final AlignmentWebServiceClientI client;
   private final AlignmentView view;
 
   PhmmerTask(AlignmentWebServiceClientI client, List<ArgumentI> args,
-          Credentials credentials, AlignmentView view,
-          TaskEventListener<AlignmentI> eventListener)
+          Credentials credentials, AlignmentView view)
   {
-    super(client, args, credentials, eventListener);
+    super(client, args, credentials);
     this.client = client;
     this.view = view;
   }
 
   @Override
-  protected List<BaseJob> prepare() throws ServiceInputInvalidException
+  protected List<BaseJob> prepareJobs() throws ServiceInputInvalidException
   {
     Console.info("Preparing sequence for phmmer job");
     var sequence = view.getVisibleAlignment('-').getSequenceAt(0);
@@ -58,9 +55,9 @@ class PhmmerTask extends AbstractPollableTask<BaseJob, AlignmentI>
   }
 
   @Override
-  protected AlignmentI done() throws IOException
+  protected AlignmentI collectResult(List<BaseJob> jobs) throws IOException
   {
-    var job = getSubJobs().get(0);
+    var job = jobs.get(0);
     var status = job.getStatus();
     Console.info(String.format("phmmer finished job \"%s\" with status %s",
             job.getServerJob().getJobId(), status));
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 a20575d..48d0ba5 100644 (file)
@@ -84,7 +84,6 @@ public final class JobDispatcherWSDiscoverer extends AbstractWebServiceDiscovere
     if (!phmmerClient.testEndpoint())
       throw new IOException(
               "unable to reach dispatcher server at " + url);
-    // TODO change once a concrete action is implemented
     var wsBuilder = WebService.<PhmmerAction> newBuilder();
     wsBuilder.url(url);
     wsBuilder.clientName("job dispatcher");
index bef502b..477bc34 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());
@@ -277,7 +272,8 @@ class SlivkaAnnotationWSClient extends SlivkaWSClient
       {
         FeaturesFile ff = new FeaturesFile(f.getContentUrl().toString(),
             DataSourceType.URL);
-        featPresent = ff.parse(aln, colours, true);
+        // TODO: determine if relaxed id matching is T/F
+        featPresent = ff.parse(aln, colours, filters, false, true);
         if (featPresent)
           Console.debug(format("loaded features for %s", service.getId()));
       }
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)
       {
index fd1d0ad..caa0573 100644 (file)
@@ -230,12 +230,6 @@ class AlignmentServiceGuiHandler
   }
 
   @Override
-  public void taskRestarted(TaskI<AlignmentResult> source)
-  {
-    // alignment services are not restartable
-  }
-
-  @Override
   public void subJobStatusChanged(TaskI<AlignmentResult> source, JobI job, JobStatus status)
   {
     int i = ArrayUtils.indexOf(jobs, job);
index 43e2680..a7202e9 100644 (file)
@@ -1,21 +1,22 @@
 package jalview.ws2.gui;
 
-import java.util.List;
-
+import jalview.api.FeatureColourI;
+import jalview.datamodel.features.FeatureMatcherSetI;
 import jalview.gui.AlignFrame;
 import jalview.gui.AlignmentPanel;
 import jalview.gui.IProgressIndicator;
 import jalview.gui.IProgressIndicatorHandler;
+import jalview.schemes.FeatureSettingsAdapter;
+import jalview.util.MathUtils;
+import jalview.ws2.actions.annotation.AlignCalcWorkerAdapter;
 import jalview.ws2.actions.annotation.AnnotationAction;
 import jalview.ws2.actions.annotation.AnnotationResult;
-import jalview.ws2.actions.api.JobI;
-import jalview.ws2.actions.api.TaskEventListener;
-import jalview.ws2.actions.api.TaskI;
-import jalview.ws2.api.JobStatus;
 
 public class AnnotationServiceGuiHandler
-    implements TaskEventListener<AnnotationResult>
+    implements AlignCalcWorkerAdapter.WorkerListener
 {
+  private final long progressId = MathUtils.getUID();
+
   private final AlignFrame alignFrame;
 
   private final AlignmentPanel alignPanel;
@@ -33,9 +34,10 @@ public class AnnotationServiceGuiHandler
   }
 
   @Override
-  public void taskStarted(TaskI<AnnotationResult> source, List<? extends JobI> subJobs)
+  public void workerStarted(AlignCalcWorkerAdapter source)
   {
-    progressIndicator.registerHandler(source.getUid(),
+    progressIndicator.addProgressBar(progressId, action.getFullName());
+    progressIndicator.registerHandler(progressId,
         new IProgressIndicatorHandler()
         {
           @Override
@@ -54,67 +56,44 @@ public class AnnotationServiceGuiHandler
   }
 
   @Override
-  public void taskStatusChanged(TaskI<AnnotationResult> source, JobStatus status)
+  public void workerStopped(AlignCalcWorkerAdapter source)
   {
-    switch (status)
-    {
-    case INVALID:
-    case COMPLETED:
-    case CANCELLED:
-    case FAILED:
-    case SERVER_ERROR:
-      progressIndicator.removeProgressBar(source.getUid());
-      break;
-    case READY:
-    case SUBMITTED:
-    case QUEUED:
-    case RUNNING:
-    case UNKNOWN:
-      progressIndicator.addProgressBar(source.getUid(), action.getFullName());
-      break;
-    }
+    progressIndicator.removeProgressBar(progressId);
   }
 
   @Override
-  public void taskCompleted(TaskI<AnnotationResult> source, AnnotationResult result)
+  public void workerHasResult(AlignCalcWorkerAdapter source, final AnnotationResult result)
   {
     if (result == null)
       return;
-    if (result.getTransferFeatures() && alignFrame.alignPanel == alignPanel)
+    if (result.getTransferFeatures())
     {
-      alignFrame.getViewport().setShowSequenceFeatures(true);
-      alignFrame.setMenusForViewport();
-    }
-    alignPanel.adjustAnnotationHeight();
-  }
-
-  @Override
-  public void taskException(TaskI<AnnotationResult> source, Exception e)
-  {
-
-  }
-
-  @Override
-  public void taskRestarted(TaskI<AnnotationResult> source)
-  {
-
-  }
-
-  @Override
-  public void subJobStatusChanged(TaskI<AnnotationResult> source, JobI job, JobStatus status)
-  {
-
-  }
-
-  @Override
-  public void subJobLogChanged(TaskI<AnnotationResult> source, JobI job, String log)
-  {
-
-  }
+      alignFrame.getViewport().applyFeaturesStyle(new FeatureSettingsAdapter()
+      {
+        @Override
+        public FeatureColourI getFeatureColour(String type)
+        {
+          return result.getFeatureColours().get(type);
+        }
 
-  @Override
-  public void subJobErrorLogChanged(TaskI<AnnotationResult> source, JobI job, String log)
-  {
+        @Override
+        public FeatureMatcherSetI getFeatureFilters(String type)
+        {
+          return result.getFeatureFilters().get(type);
+        }
 
+        @Override
+        public boolean isFeatureDisplayed(String type)
+        {
+          return result.getFeatureColours().containsKey(type);
+        }
+      });
+      if (alignFrame.alignPanel == alignPanel)
+      {
+        alignFrame.getViewport().setShowSequenceFeatures(true);
+        alignFrame.setMenusForViewport();
+      }
+    }
+    alignPanel.adjustAnnotationHeight();
   }
 }
index 519adc7..49df466 100644 (file)
@@ -169,12 +169,6 @@ class SearchServiceGuiHandler implements TaskEventListener<AlignmentI>
   }
 
   @Override
-  public void taskRestarted(TaskI<AlignmentI> source)
-  {
-    // search services non-restartable
-  }
-
-  @Override
   public void subJobStatusChanged(TaskI<AlignmentI> source, JobI job,
           JobStatus status)
   {
index 12aeaa9..3757199 100644 (file)
@@ -3,17 +3,14 @@ package jalview.ws2.gui;
 import java.awt.Color;
 import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
-import java.lang.ref.WeakReference;
 import java.net.URL;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.TreeMap;
 import java.util.concurrent.CompletionStage;
 
@@ -25,6 +22,7 @@ import javax.swing.ToolTipManager;
 import javax.swing.border.EmptyBorder;
 
 import jalview.bin.Console;
+import jalview.datamodel.AlignmentI;
 import jalview.gui.AlignFrame;
 import jalview.gui.Desktop;
 import jalview.gui.JvSwingUtils;
@@ -34,7 +32,11 @@ import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws.params.ParamDatastoreI;
 import jalview.ws.params.WsParamSetI;
+import jalview.ws2.actions.BaseTask;
+import jalview.ws2.actions.PollingTaskExecutor;
 import jalview.ws2.actions.alignment.AlignmentAction;
+import jalview.ws2.actions.alignment.AlignmentResult;
+import jalview.ws2.actions.annotation.AlignCalcWorkerAdapter;
 import jalview.ws2.actions.annotation.AnnotationAction;
 import jalview.ws2.actions.api.ActionI;
 import jalview.ws2.actions.api.TaskEventListener;
@@ -44,8 +46,6 @@ import jalview.ws2.api.Credentials;
 import jalview.ws2.api.WebService;
 import jalview.ws2.client.api.WebServiceProviderI;
 
-import static java.lang.String.format;
-
 public class WebServicesMenuManager
 {
   private final JMenu menu;
@@ -61,8 +61,6 @@ public class WebServicesMenuManager
     noServicesItem.setEnabled(false);
   }
 
-  private Map<String, WeakReference<TaskI<?>>> interactiveTasks = new HashMap<>();
-
   public WebServicesMenuManager(String name, AlignFrame frame)
   {
     this.frame = frame;
@@ -167,7 +165,8 @@ public class WebServicesMenuManager
         // sort actions by name pulling nulls to the front
         actions.sort(Comparator.comparing(
             ActionI::getName, Comparator.nullsFirst(Comparator.naturalOrder())));
-        for (int i = 0; i < actions.size(); i++) {
+        for (int i = 0; i < actions.size(); i++)
+        {
           addEntriesForAction(actions.get(i), atMenu, atMenu == menu);
         }
       }
@@ -370,15 +369,8 @@ public class WebServicesMenuManager
         serviceItem.removeActionListener(l);
       WebService<?> service = action.getWebService();
       serviceItem.addActionListener(e -> {
-        if (serviceItem.getState())
-        {
-          cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
-              Credentials.empty());
-        }
-        else
-        {
-          cancelInteractive(service.getName());
-        }
+        runAction(action, frame.getCurrentView(), arguments,
+            Credentials.empty());
       });
       serviceItem.setSelected(true);
 
@@ -397,7 +389,7 @@ public class WebServicesMenuManager
                   lastPreset[0] = null;
                   arguments.clear();
                   arguments.addAll(args);
-                  cancelAndRunInteractive(action, frame.getCurrentView(),
+                  runAction(action, frame.getCurrentView(),
                       arguments, Credentials.empty());
                 }
               });
@@ -414,14 +406,14 @@ public class WebServicesMenuManager
           var item = new JMenuItem(preset.getName());
           item.addActionListener(e -> {
             lastPreset[0] = preset;
-            cancelAndRunInteractive(action, frame.getCurrentView(),
+            runAction(action, frame.getCurrentView(),
                 preset.getArguments(), Credentials.empty());
           });
           presetsMenu.add(item);
         }
       }
 
-      cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
+      runAction(action, frame.getCurrentView(), arguments,
           Credentials.empty());
     }
 
@@ -436,24 +428,8 @@ public class WebServicesMenuManager
     }
   }
 
-  private void cancelInteractive(String wsName)
-  {
-    var taskRef = interactiveTasks.get(wsName);
-    if (taskRef != null && taskRef.get() != null)
-      taskRef.get().cancel();
-    interactiveTasks.put(wsName, null);
-  }
-
-  private void cancelAndRunInteractive(ActionI<?> action,
-      AlignmentViewport viewport, List<ArgumentI> args, Credentials credentials)
-  {
-    var wsName = action.getWebService().getName();
-    cancelInteractive(wsName);
-    var task = runAction(action, viewport, args, credentials);
-    interactiveTasks.put(wsName, new WeakReference<>(task));
-  }
 
-  private TaskI<?> runAction(ActionI<?> action, AlignmentViewport viewport,
+  private void runAction(ActionI<?> action, AlignmentViewport viewport,
       List<ArgumentI> args, Credentials credentials)
   {
     // casting and instance checks can be avoided with some effort,
@@ -463,25 +439,53 @@ public class WebServicesMenuManager
       // TODO: test if selection contains enough sequences
       var _action = (AlignmentAction) action;
       var handler = new AlignmentServiceGuiHandler(_action, frame);
-      return _action.perform(viewport, args, credentials, handler);
+      BaseTask<?, AlignmentResult> task = _action.createTask(viewport, args, credentials);
+      var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
+      task.addTaskEventListener(handler);
+      var future = executor.submit(task);
+      task.setCancelAction(() -> { future.cancel(true); });
+      return;
     }
     if (action instanceof AnnotationAction)
     {
+      var calcManager = viewport.getCalcManager();
+
       var _action = (AnnotationAction) action;
+      var worker = new AlignCalcWorkerAdapter(viewport, frame.alignPanel,
+          _action, args, credentials);
       var handler = new AnnotationServiceGuiHandler(_action, frame);
-      return _action.perform(viewport, args, credentials, handler);
+      worker.setWorkerListener(handler);
+      for (var w : calcManager.getWorkers())
+      {
+        if (worker.getCalcName() != null && worker.getCalcName().equals(w.getCalcName()))
+        {
+          calcManager.cancelWorker(w);
+          calcManager.removeWorker(w);
+        }
+      }
+      if (action.getWebService().isInteractive())
+        calcManager.registerWorker(worker);
+      else
+        calcManager.startWorker(worker);
+      return;
     }
     if (action instanceof PhmmerAction)
     {
       var _action = (PhmmerAction) action;
       var handler = new SearchServiceGuiHandler(_action, frame);
-      return _action.perform(viewport, args, credentials, handler);
+      TaskI<AlignmentI> task = _action.createTask(viewport, args, credentials);
+      var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
+      task.addTaskEventListener(handler);
+      _action.perform(viewport, args, credentials, handler);
+      return;
     }
     Console.warn(String.format(
             "No known handler for action type %s. All output will be discarded.",
             action.getClass().getName()));
-    return action.perform(viewport, args, credentials,
-            TaskEventListener.nullListener());
+    var task = action.createTask(viewport, args, credentials);
+    task.addTaskEventListener(TaskEventListener.nullListener());
+    PollingTaskExecutor.fromPool(viewport.getServiceExecutor())
+      .submit(task);
   }
 
   private static CompletionStage<List<ArgumentI>> openEditParamsDialog(
index c7b6052..dcf965a 100644 (file)
@@ -1,6 +1,7 @@
 package jalview.ws2.helpers;
 
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 
 import jalview.ws2.actions.api.JobI;
 import jalview.ws2.actions.api.TaskEventListener;
@@ -9,52 +10,70 @@ import jalview.ws2.api.JobStatus;
 
 public class TaskEventSupport<T>
 {
-  TaskI<T> source;
-  TaskEventListener<T> handler;
-  
-  public TaskEventSupport(TaskI<T> source, TaskEventListener<T> handler)
+  private TaskI<T> source;
+
+  private List<TaskEventListener<T>> listeners = new CopyOnWriteArrayList<>();
+
+  public TaskEventSupport(TaskI<T> source)
   {
     this.source = source;
-    this.handler = handler;
+  }
+
+  public TaskEventSupport(TaskI<T> source, TaskEventListener<T> handler)
+  {
+    this(source);
+    addListener(handler);
+  }
+
+  public void addListener(TaskEventListener<T> listener)
+  {
+    listeners.add(listener);
   }
   
+  public void removeListener(TaskEventListener<T> listener)
+  {
+    listeners.remove(listener);
+  }
+
   public void fireTaskStarted(List<? extends JobI> subJobs)
   {
-    handler.taskStarted(source, subJobs);
+    for (var listener : listeners)
+      listener.taskStarted(source, subJobs);
   }
-  
+
   public void fireTaskStatusChanged(JobStatus status)
   {
-    handler.taskStatusChanged(source, status);
+    for (var listener : listeners)
+      listener.taskStatusChanged(source, status);
   }
-  
+
   public void fireTaskCompleted(T result)
   {
-    handler.taskCompleted(source, result);
+    for (var listener : listeners)
+      listener.taskCompleted(source, result);
   }
-  
+
   public void fireTaskException(Exception e)
   {
-    handler.taskException(source, e);
-  }
-  
-  public void fireTaskRestarted()
-  {
-    handler.taskRestarted(source);
+    for (var listener : listeners)
+      listener.taskException(source, e);
   }
-  
+
   public void fireSubJobStatusChanged(JobI job, JobStatus status)
   {
-    handler.subJobStatusChanged(source, job, status);
+    for (var listener : listeners)
+      listener.subJobStatusChanged(source, job, status);
   }
-  
+
   public void fireSubJobLogChanged(JobI job, String log)
   {
-    handler.subJobLogChanged(source, job, log);
+    for (var listener : listeners)
+      listener.subJobLogChanged(source, job, log);
   }
-  
+
   public void fireSubJobErrorLogChanged(JobI job, String log)
   {
-    handler.subJobErrorLogChanged(source, job, log);
+    for (var listener : listeners)
+      listener.subJobErrorLogChanged(source, job, log);
   }
 }
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..e573032
--- /dev/null
@@ -0,0 +1,300 @@
+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.PollingTaskExecutor;
+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());
+    var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
+    var task = action.createTask(viewport, List.of(), Credentials.empty());
+    task.addTaskEventListener(listener);
+    var cancellable = executor.submit(task);
+    try
+    {
+      latch.await(100, TimeUnit.MILLISECONDS);
+    } catch (InterruptedException e)
+    {
+      cancellable.cancel(true);
+    }
+    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