Repository: cogent3/cogent3 (python)

Issue:
ENH: implement support for strides in IndelMap
The following currently is not supported
```python
import numpy
from cogent3.core.location import IndelMap

im = IndelMap(gap_pos=numpy.array([3, 8]), gap_lengths=numpy.array([2, 1]), parent_length=11)
im[::3]
```
We need to support, this means adjusting the gap positions and the cum_lengths.

Pull request description:
ENH: add support for reversing and strides when indexing IndelMaps and Alignments, fixes #1967
in this PR: 
- support for slicing IndelMaps with stride < 0,  fixes #1967 
- support for slicing Alignments with stride < 0 
- support for negative indexing of Alignments


Patch:
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index deb9a1a86..e5ffe0ed8 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -2514,7 +2514,7 @@ class AlignmentI(object):
     default_gap = "-"  # default gap character for padding
     gap_chars = dict.fromkeys("-?")  # default gap chars for comparisons
 
-    def __repr__(self):
+    def __repr__(self):  # ported
         seqs = []
         limit = 10
         delimiter = ""
diff --git a/src/cogent3/core/location.py b/src/cogent3/core/location.py
index e6c148dce..ebc066e52 100644
--- a/src/cogent3/core/location.py
+++ b/src/cogent3/core/location.py
@@ -6,7 +6,6 @@ import json
 from abc import ABC, abstractmethod
 from bisect import bisect_left, bisect_right
 from functools import total_ordering
-from itertools import chain
 from typing import Any, Iterator, Optional, Sequence, Union
 
 import numpy
@@ -16,17 +15,15 @@ from cogent3._version import __version__
 from cogent3.util import warning as c3warn
 from cogent3.util.deserialise import register_deserialiser
 from cogent3.util.misc import (
-    ClassChecker,
-    ConstrainedList,
-    FunctionWrapper,
     get_object_provenance,
-    iterable,
 )
 
 strip = str.strip
 
 _DEFAULT_GAP_DTYPE = numpy.int32
 
+OptInt = Optional[int]
+
 
 def _norm_index(i, length, default):
     """For converting s[:3] to s[0:3], s[-1] to s[len(s)-1] and s[0:lots] to s[0:len(s)]"""
@@ -1002,14 +999,15 @@ def _update_lengths(
     result_lengths[result_indices] += gap_lengths[other_indices]
 
 
-def _step_adjustment(gap_start, start, step):
-    """determines how many more steps we need to take from the beginning of
-    a gap/ungapped segment to reach a position which is a multiple of the step"""
+def _step_adjustment(gap_start: int, start: int, step: int) -> int:
+    """adjustment to a start, given a step size. The adjustment determines how
+    many more steps we need to take from the beginning of a gap/ungapped segment
+    to reach a position that is a multiple of the step"""
     overstep = (gap_start - start) % step
     return step - overstep if overstep else 0
 
 
-def _step_adjusted_length(start, end, adj, step):
+def _step_adjusted_length(start: int, end: int, adj: int, step: int) -> int:
     """returns the adjusted length of a segment given a step size
     and its start and stop index. The adjustment is the number of steps
     needed to reach the next multiple of the step size"""
@@ -1017,6 +1015,82 @@ def _step_adjusted_length(start, end, adj, step):
     return max(adjusted_length, 0)
 
 
+def _input_vals_pos_step(
+    seqlen: int, start: OptInt, stop: OptInt, step: int
+) -> tuple[int, int, int]:
+    """returns standardised start, stop, step values for positive step slicing.
+
+    Notes
+    -----
+    The input values for start and stop can be +ve, -ve or None. The returned
+    start and stop are strictly +ve and within the sequence length, and if not
+    provided default to 0 and seqlen respectively.
+
+    If the slice results in a zero-length slice, the returned start, stop, step
+    will be 0, 0, 1 respectively.
+    """
+    start = start or 0
+    if start > 0 and start >= seqlen:
+        # start beyond seq is an empty slice
+        return 0, 0, 1
+
+    stop = seqlen if stop is None else stop
+    if stop < 0 and abs(stop) >= seqlen:
+        # finished slice before we started seq!
+        return 0, 0, 1
+
+    start = max(seqlen + start, 0) if start < 0 else start
+
+    if stop > 0:
+        stop = min(seqlen, stop)
+    elif stop < 0:
+        stop += seqlen
+
+    if start >= stop:
+        start = stop = 0
+        step = 1
+
+    return start, stop, step
+
+
+def _input_vals_neg_step(
+    seqlen: int, start: OptInt, stop: OptInt, step: int
+) -> tuple[int, int, int]:
+    """returns standardised start, stop, step values for negative step slicing.
+
+    Notes
+    -----
+    The input values for start and stop can be positive, negative or None. The
+    returned start and stop are strictly negative and correspond to indices
+    within the sequence length. If not provided, start defaults to -1 and stop
+    to -seqlen-1.
+
+    If the slice results in a zero-length slice, the returned start, stop, step
+    will be 0, 0, 1 respectively.
+    """
+    # Note how Python reverse slicing works
+    # we need to make sure the start and stop are both
+    # negative, for example "abcd"[-1:-5:-1] returns "dcba"
+    # returning the complete string in reverse order is only
+    # possible with negative indexing
+    if start is None or start >= seqlen:  # set default
+        start = -1  # Done
+    elif start >= 0:  # convert to -ve index
+        start = start - seqlen
+    elif start < -seqlen:  # start is bounded by len(seq)
+        return 0, 0, 1
+
+    if stop is None:  # set default
+        stop = -seqlen - 1
+    elif stop >= 0:
+        stop -= seqlen
+
+    stop = max(stop, -seqlen - 1)  # stop should always be <= len(seq)
+
+    # checking for zero-length slice
+    return (0, 0, 1) if start < stop else (start, stop, step)
+
+
 @dataclasses.dataclass
 class IndelMap(MapABC):
     """store locations of deletions in a Aligned sequence
@@ -1144,64 +1218,29 @@ class IndelMap(MapABC):
         # that will also be sliced. Hence, we need to shift the gap insertion
         # positions relative to this newly sliced sequence.
 
-        zero_array = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
-        start = item.start or 0
-        stop = item.stop if item.stop is not None else len(self)
-        step = item.step or 1
-
-        if step < 1:
-            raise NotImplementedError(
-                f"{type(self).__name__!r} does not yet support negative strides"
-            )
+        # refactor: simplify
 
-        # convert negative indices
-        start = start if start >= 0 else len(self) + start
-        stop = stop if stop >= 0 else len(self) + stop
-        if min((start, stop)) < 0:
-            raise IndexError("item.start or item.stop is out of range")
-
-        if start >= stop:
-            # standard slice behaviour without negative step
-            return self.__class__(
-                gap_pos=zero_array.copy(),
-                cum_gap_lengths=zero_array.copy(),
-                parent_length=0,
-            )
+        step = 1 if item.step is None else item.step
+        func = _input_vals_pos_step if step > 0 else _input_vals_neg_step
+        start, stop, step = func(len(self), item.start, item.stop, step)
 
-        # we address three easy cases:
-        # 1 - no gaps; 2 - slice before first gap; 3 - after last gap
-        no_gaps = self.__class__(
-            gap_pos=zero_array.copy(),
-            cum_gap_lengths=zero_array.copy(),
-            parent_length=_step_adjusted_length(start, stop, 0, step),
-        )
-        if not self.num_gaps:
-            return no_gaps
+        if step < 0:
+            start = -start - 1
+            stop = -stop - 1
+            return self.nucleic_reversed()[start:stop:-step]
 
-        first_gap = self.gap_pos[0]  # start of first gap
-        last_gap = self.gap_pos[-1] + self.cum_gap_lengths[-1]  # end of last gap
-        if stop < first_gap or start >= last_gap:
-            return no_gaps
+        if self._check_no_gap_slice(start, stop):
+            return self._zero_imap(start, stop, step)
 
         gap_starts, gap_ends = _gap_spans(self.gap_pos, self.cum_gap_lengths)
-        gap_pos = self.gap_pos.copy()
-        cum_lengths = self.cum_gap_lengths.copy()
-        # we find where the slice starts
-        # searchsorted finds indices where elements should be inserted to maintain order.
-        # l is the index of the first gap included in the slice, using start + 1 to account
-        # for exclusive indexing of gap ends
+
+        # determine the gap index where the slice starts
         l = numpy.searchsorted(gap_ends, start + 1, side="left")
+
+        # check if entire slice is within a single gap
         if gap_starts[l] <= start < gap_ends[l] and stop <= gap_ends[l]:
-            # entire span is within a single gap
-            # pos now 0
-            gap_pos = numpy.array([0], dtype=_DEFAULT_GAP_DTYPE)
-            cum_lengths = cum_lengths[l : l + 1]
-            cum_lengths[0] = _step_adjusted_length(start, stop, 0, step)
-            return self.__class__(
-                gap_pos=gap_pos, cum_gap_lengths=cum_lengths, parent_length=0
-            )
+            return self._single_imap(start, stop, step)
 
-        # start a counter for the cumulative sequence length
         cum_seq_length = 0
         adj_gaps = []
         lengths = self.get_gap_lengths()
@@ -1219,7 +1258,7 @@ class IndelMap(MapABC):
             # start is within a ungapped segment
             if stop <= gap_starts[l]:
                 # the stop is within the same ungapped segment
-                return no_gaps
+                return self._zero_imap(start, stop, step)
 
             # determine the preceding seq length
             cum_seq_length += _step_adjusted_length(
@@ -1228,16 +1267,15 @@ class IndelMap(MapABC):
             # adj is how many more steps we need to take from the beginning of
             # the gap to reach a position which is a multiple of the step
             adj = _step_adjustment(gap_starts[l], start, step)
+            # check gap is not stepped over entirely
             if adj < lengths[l]:
-                # if adj > gap length, then the gap is "stepped over" by the stride
                 adj_gap_len = -((lengths[l] - adj) // -step)
                 adj_gaps.append([cum_seq_length, adj_gap_len])
 
         # start search for rhs index
         r = numpy.searchsorted(gap_ends[l:], stop, side="right") + l
 
-        # iterate through the gaps between l and r
-        # these are the gaps that are fully within the slice
+        # iterate through the gaps that are fully within the slice
         for j in range(l + 1, r):
             # determine how long the preceding ungapped segment was
             adj = _step_adjustment(gap_ends[j - 1], start, step)
@@ -1309,6 +1347,50 @@ class IndelMap(MapABC):
             parent_length=parent_length,
         )
 
+    def _zero_imap(self, start: int, stop: int, step: int) -> "IndelMap":
+        """returns a new IndelMap with zero gaps"""
+        zero_array = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
+        return self.__class__(
+            gap_pos=zero_array.copy(),
+            cum_gap_lengths=zero_array.copy(),
+            parent_length=_step_adjusted_length(start, stop, 0, step),
+        )
+
+    def _single_imap(self, start: int, stop: int, step: int) -> "IndelMap":
+        """returns a new IndelMap with a single gap"""
+        gap_pos = numpy.array([0], dtype=_DEFAULT_GAP_DTYPE)
+        cum_gap_length = numpy.array([_step_adjusted_length(start, stop, 0, step)])
+        return self.__class__(
+            gap_pos=gap_pos, cum_gap_lengths=cum_gap_length, parent_length=0
+        )
+
+    def _check_no_gap_slice(self, start: int, stop: int) -> bool:
+        """check for easy cases where slicing an indel maps results in no gaps.
+
+        Returns
+            True if no gaps are present in the slice
+
+        Notes
+        -----
+        this method assumes positive indexing and a positive step
+        (i.e. start, stop, step > 0)
+        """
+        # when slicing an indel maps, we can have four easy cases:
+        # 1 - invalid slice
+        if start == stop:
+            return True
+
+        # 2 - no gaps
+        if not self.num_gaps:
+            return True
+
+        first_gap = self.gap_pos[0]  # start of first gap
+        last_gap = self.gap_pos[-1] + self.cum_gap_lengths[-1]  # end of last gap
+
+        # 3 - slice before first gap; 4 - after last gap (for positive step)
+        if stop <= first_gap or start >= last_gap:
+            return True
+
     def get_align_index(self, seq_index: int, slice_stop: bool = False) -> int:
         """convert a sequence index into an alignment index
 
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index cdffdd95d..044903164 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -21,7 +21,7 @@ from cogent3.core.annotation_db import (
     SupportsFeatures,
 )
 from cogent3.core.info import Info as InfoClass
-from cogent3.core.location import IndelMap
+from cogent3.core.location import IndelMap, _input_vals_neg_step, _input_vals_pos_step
 from cogent3.core.profile import PSSM, MotifCountsArray, MotifFreqsArray, load_pssm
 from cogent3.format.alignment import save_to_filename
 from cogent3.format.fasta import seqs_to_fasta
@@ -2794,20 +2794,15 @@ class AlignedSeqsData(AlignedSeqsDataABC):
         Interweaves gaps into the sequence if gapped is True. Returns the ungapped
         sequence if gapped is False.
         """
+        step = 1 if step is None else step
+        func = _input_vals_pos_step if step > 0 else _input_vals_neg_step
+        start, stop, step = func(self.align_len, start, stop, step)
         seq = self._seqs[seqid]
-        start = start if start is not None else 0
-        stop = stop if stop is not None else self.align_len
-        step = step or 1
 
         # if there's no gaps, just slice the sequence
         if len(seq) == self.align_len:
             return seq[start:stop:step]
 
-        gaps = self._gaps[seqid]
-        indel_map = IndelMap(
-            gap_pos=gaps[:, 0], cum_gap_lengths=gaps[:, 1], parent_length=self.align_len
-        )
-
         unknown = self.alphabet.gap_index
 
         # there is only gaps
@@ -2819,6 +2814,18 @@ class AlignedSeqsData(AlignedSeqsDataABC):
             else:
                 return numpy.array([], dtype=numpy.uint8)
 
+        gaps = self._gaps[seqid]
+        indel_map = IndelMap(
+            gap_pos=gaps[:, 0], cum_gap_lengths=gaps[:, 1], parent_length=len(seq)
+        )
+
+        if step < 0:
+            seq = seq[::-1]
+            indel_map = indel_map[::-1]
+            start = -start - 1
+            stop = -stop - 1
+            step = -step
+
         # convert from alignment coordinates to sequences coordinates
         # selecting only the indices that are in the frame of the stride
         indices = []
@@ -3228,6 +3235,22 @@ class Alignment(SequenceCollection):
 
         return names, output
 
+    def __repr__(self):
+        seqs = []
+        limit = 10
+        delimiter = ""
+        for count, name in enumerate(self.names):
+            if count == 3:
+                seqs.append("...")
+                break
+            elts = list(str(self.seqs[name])[: limit + 1])
+            if len(elts) > limit:
+                elts[-1] = "..."
+            seqs.append(f"{name}[{delimiter.join(elts)}]")
+        seqs = ", ".join(seqs)
+
+        return f"{len(self.names)} x {len(self)} {self.moltype.label} alignment: {seqs}"
+
     def _repr_html_(self) -> str:
         settings = self._repr_policy.copy()
         env_vals = get_setting_from_environ(
diff --git a/src/cogent3/core/new_sequence.py b/src/cogent3/core/new_sequence.py
index 24fabdaf2..d5b074e44 100644
--- a/src/cogent3/core/new_sequence.py
+++ b/src/cogent3/core/new_sequence.py
@@ -34,7 +34,13 @@ from cogent3.core.annotation_db import (
     load_annotations,
 )
 from cogent3.core.info import Info as InfoClass
-from cogent3.core.location import FeatureMap, IndelMap, LostSpan
+from cogent3.core.location import (
+    FeatureMap,
+    IndelMap,
+    LostSpan,
+    _input_vals_neg_step,
+    _input_vals_pos_step,
+)
 from cogent3.format.fasta import seqs_to_fasta
 from cogent3.maths.stats.contingency import CategoryCounts
 from cogent3.maths.stats.number import CategoryCounter
@@ -1941,53 +1947,6 @@ def deserialise_rna_sequence(data) -> RnaSequence:
     return RnaSequence.from_rich_dict(data)
 
 
-def _input_vals_pos_step(seqlen, start, stop, step):
-    start = 0 if start is None else start
-    if start > 0 and start >= seqlen:
-        # start beyond seq is an empty slice
-        return 0, 0, 1
-
-    stop = seqlen if stop is None else stop
-    if stop < 0 and abs(stop) >= seqlen:
-        # finished slice before we started seq!
-        return 0, 0, 1
-
-    start = max(seqlen + start, 0) if start < 0 else start
-
-    if stop > 0:
-        stop = min(seqlen, stop)
-    elif stop < 0:
-        stop += seqlen
-
-    if start >= stop:
-        start = stop = 0
-        step = 1
-
-    return start, stop, step
-
-
-def _input_vals_neg_step(seqlen, start, stop, step):
-    # Note how Python reverse slicing works
-    # we need to make sure the start and stop are both
-    # negative, for example "abcd"[-1:-5:-1] returns "dcba"
-    if start is None or start >= seqlen:  # set default
-        start = -1  # Done
-    elif start >= 0:  # convert to -ve index
-        start = start - seqlen
-    elif start < -seqlen:  # start is bounded by len(seq)
-        return 0, 0, 1
-
-    if stop is None:  # set default
-        stop = -seqlen - 1
-    elif stop >= 0:
-        stop -= seqlen
-
-    stop = max(stop, -seqlen - 1)  # stop should always be <= len(seq)
-
-    # checking for zero-length slice
-    return (0, 0, 1) if start < stop else (start, stop, step)
-
-
 class SliceRecordABC(ABC):
     """Abstract base class for recording the history of operations to be applied
     to some underlying data. Provides slicing functionality for the underlying data.


Test patch:
diff --git a/tests/test_core/test_location.py b/tests/test_core/test_location.py
index e0c91087e..a367f7e39 100644
--- a/tests/test_core/test_location.py
+++ b/tests/test_core/test_location.py
@@ -696,19 +696,26 @@ def test_indelmap_slice_zero():
 
 
 def test_indelmap_invalid_slice_range():
+    # If we mirror python slicing, an invalid slice should return an empty map
     imap = IndelMap(
         gap_pos=numpy.array([10], dtype=int),
         gap_lengths=numpy.array([2], dtype=int),
         parent_length=10,
     )
-    with pytest.raises(IndexError):
-        imap[-100]
 
-    with pytest.raises(IndexError):
-        imap[-100:]
+    expect = numpy.array([], dtype=int)
 
-    with pytest.raises(IndexError):
-        imap[:-99]
+    got = imap[-100]
+    assert (got.gap_pos == expect).all()
+    assert (got.cum_gap_lengths == expect).all()
+
+    got = imap[-100:]
+    assert (got.gap_pos == expect).all()
+    assert (got.cum_gap_lengths == expect).all()
+
+    got = imap[:-99]
+    assert (got.gap_pos == expect).all()
+    assert (got.cum_gap_lengths == expect).all()
 
 
 def test_indelmap_get_indices_errors():
@@ -949,17 +956,6 @@ def test_gapped_convert_aln2seq_invalid():
         gaps.get_seq_index(-100)
 
 
-@pytest.mark.parametrize(
-    "invalid_slice",
-    (slice(None, None, -1), slice(None, None, -2)),
-)
-def test_gap_pos_invalid_slice(invalid_slice):
-    pos, lengths = numpy.array([[1, 3]], dtype=numpy.int32).T
-    gp = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=20)
-    with pytest.raises(NotImplementedError):
-        _ = gp[invalid_slice]
-
-
 @pytest.mark.parametrize(
     "aslice",
     (
@@ -1215,7 +1211,33 @@ def test_indelmap_make_seq_feature_map():
         "----------",
     ],
 )
-def test_indelmap_positive_step_varaint_slices(start, stop, step, data):
+def test_indelmap_positive_step_variant_slices(start, stop, step, data):
+    imap, _ = new_moltype.DNA.make_seq(seq=data).parse_out_gaps()
+    got = imap[start:stop:step]
+    expect, _ = new_moltype.DNA.make_seq(seq=data[start:stop:step]).parse_out_gaps()
+    assert (got.gap_pos == expect.gap_pos).all()
+    assert (got.cum_gap_lengths == expect.cum_gap_lengths).all()
+
+
+@pytest.mark.parametrize("start", range(11))
+@pytest.mark.parametrize("stop", range(10))
+@pytest.mark.parametrize("step", [-1, -2, -3])
+@pytest.mark.parametrize(
+    "data",
+    [
+        "TCAGTCAGTC",
+        "AAAAA-----",
+        "AAAAAAA--A",
+        "-----AAAAA",
+        "--AA--AA--",
+        "AA--AA--AA",
+        "A-A-A-A-A-",
+        "---TTTT---",
+        "C--------C",
+        "----------",
+    ],
+)
+def test_indelmap_negative_step_variant_slices(start, stop, step, data):
     imap, _ = new_moltype.DNA.make_seq(seq=data).parse_out_gaps()
     got = imap[start:stop:step]
     expect, _ = new_moltype.DNA.make_seq(seq=data[start:stop:step]).parse_out_gaps()
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 38452ae60..3f12fcec5 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -2883,9 +2883,6 @@ def test_alignment_to_html():
     assert got.find(ref_row) < got.find(other_row)
 
 
-@pytest.mark.xfail(
-    reason="todo: Alignment repr currently using Seqcollection code, need to update"
-)
 def test_alignment_repr():
     data = {
         "ENSMUSG00000056468": "GCCAGGGGGAAAA",
@@ -2927,7 +2924,7 @@ def test_alignment_repr():
         "d": "D" * 11,
         "e": "E" * 11,
     }
-    seqs = new_alignment.make_aligned_seqs(data, moltype="dna")
+    seqs = new_alignment.make_aligned_seqs(data, moltype="text")
     assert (
         repr(seqs)
         == "5 x 11 text alignment: a[AAAAAAAAAA...], b[BBBBBBBBBB...], c[CCCCCCCCCC...], ..."
@@ -2938,7 +2935,7 @@ def test_alignment_repr():
 @pytest.mark.parametrize("stop", range(6))
 @pytest.mark.parametrize("step", range(1, 3))
 @pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
-def test_alignment_slice(aligned_dict, start, stop, step, seqid):
+def test_alignment_slice_pos_step_gapped(aligned_dict, start, stop, step, seqid):
     """slicing an alignment should propogate the slice to aligned instances"""
     aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
     sliced_aln = aln[start:stop:step]
@@ -2946,6 +2943,41 @@ def test_alignment_slice(aligned_dict, start, stop, step, seqid):
     expect = aligned_dict[seqid][start:stop:step]
     assert got == expect
 
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(1, 3))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_slice_pos_step_ungapped(aligned_dict, start, stop, step, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    sliced_aln = aln[start:stop:step]
     got = sliced_aln.seqs[seqid].seq
-    expect = expect.replace("-", "")
+    expect = aligned_dict[seqid][start:stop:step].replace("-", "")
+    assert got == expect
+
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(-3, 0))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_slice_neg_step_gapped(aligned_dict, start, stop, step, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    sliced_aln = aln[start:stop:step]
+    got = sliced_aln.seqs[seqid].gapped_seq
+    expect = aligned_dict[seqid][start:stop:step]
+    assert got == expect
+
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(-3, 0))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_slice_neg_step_ungapped(aligned_dict, start, stop, step, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    sliced_aln = aln[start:stop:step]
+    got = sliced_aln.seqs[seqid].seq
+    expect = aligned_dict[seqid][start:stop:step].replace("-", "")
     assert got == expect