Repository: cogent3/cogent3
Pull request #2077 (2024-10-29 04:33:44)

DEV: update `SequenceCollection` and `Alignment` construction functions
## Summary by Sourcery

Refactor the SequenceCollection and Alignment constructor functions to support various input types and improve sequence name handling. Enhance the seq_to_gap_coords function to support new_sequence.Sequence objects and improve its return type. Add comprehensive tests to verify the correct functionality of these enhancements.

Enhancements:
- Refactor the SequenceCollection and Alignment constructor functions to handle various input types such as dict, list, and set, and improve the handling of sequence names and offsets.
- Improve the handling of sequence names by introducing a name_map to map between provided names and sequence names when constructing alignments.
- Enhance the seq_to_gap_coords function to support new_sequence.Sequence objects and improve its return type to include numpy arrays.

Tests:
- Add comprehensive tests for the SequenceCollection and Alignment constructor functions to ensure they handle different input types and scenarios, including dicts, lists, sets, and sequences with offsets.
- Introduce tests to verify the correct handling of sequence names and offsets in SequenceCollection and Alignment constructors.
- Add tests for the seq_to_gap_coords function to ensure it correctly processes sequences and returns expected gap coordinates.


Patch:
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index c33f9be83..cd3be9b46 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -1035,8 +1035,6 @@ class SequenceCollection:
         if self.annotation_db is None:
             self.annotation_db = type(seq_db)()
 
-        # refactor
-        # if we update a db with the same db, it just hangs... need to fix
         if self.annotation_db.compatible(seq_db, symmetric=False):
             # our db contains the tables in other, so we update in place
             self.annotation_db.update(annot_db=seq_db, seqids=self.names)
@@ -2251,7 +2249,7 @@ def assign_names(
 def _(
     first: new_sequence.Sequence, data: Union[list, set]
 ) -> dict[str, new_sequence.Sequence]:
-    return {seq.name: seq for seq in data}
+    return {(seq.name or f"seq_{i}"): seq for i, seq in enumerate(data)}
 
 
 @assign_names.register
@@ -2264,9 +2262,25 @@ def _(first: tuple, data: Union[list, set]) -> dict[str, StrORBytesORArray]:
     return {name_seq[0]: name_seq[1] for name_seq in data}
 
 
+def _make_name_map(data: dict) -> tuple[OptDict, dict]:
+    if not isinstance(data, dict) or not isinstance(
+        next(iter(data.values()), None), new_sequence.Sequence
+    ):
+        return None, data
+    # if we have a dict of Sequences, {name: seq, ...}, then the provided names
+    # may differ to the name attribute on the Sequences. If the Sequences have
+    # annotations, then we will need to map between the names in order to
+    # query the annotation database. We do this by creating a name_map.
+    # Note that we do (seq.name or name) to handle when the Sequence name is
+    # None
+    name_map = {name: (seq.name or name) for name, seq in data.items()}
+    data = {(seq.name or name): numpy.array(seq) for name, seq in data.items()}
+    return name_map, data
+
+
 @singledispatch
 def make_unaligned_seqs(
-    data: Union[dict[str, StrORBytesORArray], SeqsData, list],
+    data: Union[dict[str, StrORBytesORArray], list, SeqsDataABC],
     *,
     moltype: Union[str, new_moltype.MolType],
     label_to_name: OptRenamerCallable = None,
@@ -2313,22 +2327,19 @@ def make_unaligned_seqs(
     # rename offset to offsets as it could track potentially multiple offsets
     # refactor: add offset/s to docstring
 
+    # refactor: design
+    # define a source attribute rather than storing as .info["source"]
+    # this will also replace the previous name attribute
+
     if len(data) == 0:
         raise ValueError("data must be at least one sequence.")
 
     annotation_db = annotation_db or merged_db_collection(data)
 
-    if isinstance(data, dict) and isinstance(
-        next(iter(data.values()), None), new_sequence.Sequence
-    ):
-        # if we have a dict of Sequences, {name: seq, ...}, then the provided names
-        # may differ to the name attribute on the Sequences. If the Sequences have
-        # annotations, then we will need to map between the names in order to
-        # query the annotation database. We do this by creating a name_map.
-        # Note that we do (seq.name or name) to handle when the Sequence name is
-        # None
-        name_map = {name: (seq.name or name) for name, seq in data.items()}
-        data = {(seq.name or name): numpy.array(seq) for name, seq in data.items()}
+    # if we have Sequences, we need to construct the name map before we construct
+    # the SeqsData object - however, if a name_map is provided, we assume that it
+    # corrects for any naming differences in data and skip this step
+    name_map, data = _make_name_map(data) if name_map is None else (name_map, data)
 
     seqs_data = coerce_to_seqs_data_dict(data, label_to_name=label_to_name)
 
@@ -2336,14 +2347,13 @@ def make_unaligned_seqs(
     alphabet = moltype.most_degen_alphabet()
 
     seqs_data = SeqsData(data=seqs_data, alphabet=alphabet, offset=offset)
+    # we do not pass on offset/label_to_name as they are handled in this function
     return make_unaligned_seqs(
         seqs_data,
         moltype=moltype,
-        label_to_name=label_to_name,
         info=info,
         source=source,
         annotation_db=annotation_db,
-        offset=offset,
         name_map=name_map,
     )
 
@@ -2366,13 +2376,13 @@ def _(
             f"Provided moltype: {moltype} is not compatible with SeqsData alphabet {data.alphabet}"
         )
 
-    info = info if isinstance(info, dict) else {}
-    if source:
-        info["source"] = str(source)
-    else:
-        info["source"] = str(info.get("source", "unknown"))
+    # we cannot set offset when creating from an SeqsData
+    if offset:
+        raise ValueError(f"Setting offset is not supported for {data=}")
 
-    return SequenceCollection(
+    info = info if isinstance(info, dict) else {}
+    info["source"] = str(source) if source else str(info.get("source", "unknown"))
+    seqs = SequenceCollection(
         seqs_data=data,
         moltype=moltype,
         info=info,
@@ -2380,6 +2390,9 @@ def _(
         source=source,
         name_map=name_map,
     )
+    if label_to_name:
+        seqs = seqs.rename_seqs(label_to_name)
+    return seqs
 
 
 @singledispatch
@@ -2434,7 +2447,9 @@ def _(
 
 
 @seq_to_gap_coords.register
-def _(seq: str, *, alphabet: new_alphabet.AlphabetABC) -> tuple[str, numpy.ndarray]:
+def _(
+    seq: str, *, alphabet: new_alphabet.AlphabetABC
+) -> tuple[numpy.ndarray, numpy.ndarray]:
     if not alphabet.is_valid(seq):
         raise new_alphabet.AlphabetError(f"Sequence is invalid for alphabet {alphabet}")
 
@@ -2448,6 +2463,13 @@ def _(
     return seq_to_gap_coords(seq.decode("utf-8"), alphabet=alphabet)
 
 
+@seq_to_gap_coords.register
+def _(
+    seq: new_sequence.Sequence, *, alphabet: new_alphabet.AlphabetABC
+) -> tuple[numpy.ndarray, numpy.ndarray]:
+    return seq_to_gap_coords(numpy.array(seq), alphabet=alphabet)
+
+
 class Aligned:
     """A single sequence in an alignment."""
 
@@ -2704,10 +2726,10 @@ class AlignedSeqsData(AlignedSeqsDataABC):
         if check or not align_len:
             if not seqs or not gaps:
                 raise ValueError("Both seqs and gaps must be provided.")
-            if set(seqs.keys()) != set(gaps.keys()):
+            if seqs.keys() != gaps.keys():
                 raise ValueError("Keys in seqs and gaps must be identical.")
             if offset:
-                assert set(offset.keys()).issubset(seqs.keys())
+                assert offset.keys().issubset(seqs.keys())
             gapped_seq_lengths = {
                 _gapped_seq_len(v, l) for v, l in zip(seqs.values(), gaps.values())
             }
@@ -4350,36 +4372,43 @@ class Alignment(SequenceCollection):
 
 @singledispatch
 def make_aligned_seqs(
-    data: Union[dict[str, StrORBytesORArray], AlignedSeqsData],
+    data: Union[dict[str, StrORBytesORArray], list, AlignedSeqsDataABC],
     *,
     moltype: str,
+    label_to_name: OptRenamerCallable = None,
     info: dict = None,
-    offset: dict[str, int] = None,
+    source: OptPathType = None,
     annotation_db: SupportsFeatures = None,
-) -> Alignment:
-    raise NotImplementedError(f"make_aligned_seqs not implemented for {type(data)}")
-
-
-@make_aligned_seqs.register
-def _(
-    data: dict,
-    *,
-    moltype: str,
-    info: dict = None,
     offset: dict[str, int] = None,
-    annotation_db: SupportsFeatures = None,
+    name_map: dict[str, str] = None,
 ) -> Alignment:
-    # todo: implement a coerce to AlignedSeqsData dict function
+    if len(data) == 0:
+        raise ValueError("data must be at least one sequence.")
+
     moltype = new_moltype.get_moltype(moltype)
     alphabet = moltype.most_degen_alphabet()
-    aligned_data = AlignedSeqsData.from_seqs(
+
+    # if we have Sequences, we need to construct the name map before we construct
+    # the SeqsData object - however, if a name_map is provided, we assume that it
+    # corrects for any naming differences in data and skip this step
+    name_map, data = _make_name_map(data) if name_map is None else (name_map, data)
+
+    data = coerce_to_seqs_data_dict(data, label_to_name=label_to_name)
+
+    seqs_data = AlignedSeqsData.from_seqs(
         data=data,
         alphabet=alphabet,
         offset=offset,
     )
     annotation_db = annotation_db or merged_db_collection(data)
+    # we do not pass on offset/label_to_name as they are handled in this function
     return make_aligned_seqs(
-        aligned_data, moltype=moltype, info=info, annotation_db=annotation_db
+        seqs_data,
+        moltype=moltype,
+        info=info,
+        annotation_db=annotation_db,
+        source=source,
+        name_map=name_map,
     )
 
 
@@ -4388,9 +4417,12 @@ def _(
     data: AlignedSeqsDataABC,
     *,
     moltype: str,
+    label_to_name: OptRenamerCallable = None,
     info: dict = None,
-    offset: dict[str, int] = None,
+    source: OptPathType = None,
     annotation_db: SupportsFeatures = None,
+    offset: dict[str, int] = None,
+    name_map: dict[str, str] = None,
 ) -> Alignment:
     moltype = new_moltype.get_moltype(moltype)
     if not moltype.is_compatible_alphabet(data.alphabet):
@@ -4399,6 +4431,21 @@ def _(
             f" alphabet: {data.alphabet}",
         )
 
-    return Alignment(
-        seqs_data=data, moltype=moltype, info=info, annotation_db=annotation_db
+    # we cannot set offset when creating from an AlignedSeqsData
+    if offset:
+        raise ValueError(f"Setting offset is not supported for {data=}")
+
+    info = info if isinstance(info, dict) else {}
+    info["source"] = str(source) if source else str(info.get("source", "unknown"))
+
+    aln = Alignment(
+        seqs_data=data,
+        moltype=moltype,
+        info=info,
+        source=source,
+        annotation_db=annotation_db,
+        name_map=name_map,
     )
+    if label_to_name:
+        aln = aln.rename_seqs(label_to_name)
+    return aln


Test patch:
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index fffaf3cdc..0858ce712 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -497,108 +497,223 @@ def test_bytes(sdv_s2: new_alignment.SeqDataView):
     assert expect == got
 
 
+# tests of SequenceCollection and Alignment constructor utility function
+
+
+@pytest.mark.parametrize(
+    "mk_cls, cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SequenceCollection),
+        (new_alignment.make_aligned_seqs, new_alignment.Alignment),
+    ],
+)
 @pytest.mark.parametrize("moltype", ("dna", "rna", "protein", "protein_with_stop"))
-def test_make_unaligned_seqs_dict(moltype):
-    """test SequenceCollection constructor utility function"""
+def test_make_seqs(moltype, mk_cls, cls):
+    """SequenceCollection and Alignment constructor functions should handle
+    dict/list/set as input"""
+    # dict of strings
     data = {"a": "AGGCCC", "b": "AGAAAA"}
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
 
-    # should also work if seqs are arrays
+    # dict of arrays
     data = {"a": numpy.array([0, 2, 1, 3, 2, 3]), "b": numpy.array([0, 2, 1, 3, 2, 1])}
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
 
+    # list of str
+    data = ["AGGCCC", "AGAAAA"]
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
+    assert got.num_seqs == 2
 
-@pytest.mark.parametrize("moltype", ("dna", "rna", "protein", "protein_with_stop"))
-@pytest.mark.parametrize("cls", (list, set))
-def test_make_unaligned_seqs_collection(moltype, cls):
-    """test SequenceCollection constructor utility function"""
-    data = cls(["AGGCCC", "AGAAAA"])
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    # set of str
+    data = {"AGGCCC", "AGAAAA"}
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
     assert got.num_seqs == 2
 
-    # should also work if seqs are arrays
+    # list of array
     data = [numpy.array([0, 2, 1, 3, 2, 3]), numpy.array([0, 2, 1, 3, 2, 1])]
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
 
-    # also when seqs are list(name: seq) pairs
+    # list(name: seq) pairs
     data = [["seq1", "AGGCCC"], ["seq2", "AGAAAA"]]
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
 
-    # or tuples
+    # tuples
     data = [("seq1", "AGGCCC"), ("seq2", "AGAAAA")]
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
 
 
-def test_make_unaligned_seqs_label_to_name():
-    """test SequenceCollection constructor utility function"""
-    data = {"a": "AGGCCC", "b": "AGAAAA"}
+@pytest.mark.parametrize(
+    "mk_cls, cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SeqsData),
+        (new_alignment.make_aligned_seqs, new_alignment.AlignedSeqsData),
+    ],
+)
+def test_make_seqs_label_to_name(mk_cls, cls, dna_alphabet):
+    """SequenceCollection and Alignment constructor functions should convert names
+    using label_to_name function if provided"""
 
     def f(x):
         return x.upper()
 
-    got = new_alignment.make_unaligned_seqs(data, moltype="dna", label_to_name=f)
+    # for data as a dict
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+    got = mk_cls(data, moltype="dna", label_to_name=f)
+    assert list(got.names) == ["A", "B"]
+
+    # for data as a SeqsData object
+    data = cls.from_seqs(data=data, alphabet=dna_alphabet)
+    got = mk_cls(data, moltype="dna", label_to_name=f)
     assert list(got.names) == ["A", "B"]
 
 
-def test_make_unaligned_seqs_raises():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_raises(mk_cls):
+    """cannot construct SequenceCollection or Alignment from a string"""
     data = "AGTCCTGA"
     with pytest.raises(NotImplementedError):
-        new_alignment.make_unaligned_seqs(data, moltype="dna")
-
-
-def test_make_unaligned_seqs_incompatible_moltype(dna_sd):
-    with pytest.raises(ValueError):
-        _ = new_alignment.make_unaligned_seqs(dna_sd, moltype="rna")
+        mk_cls(data, moltype="dna")
 
 
-def test_make_unaligned_seqs_no_seqs():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_no_seqs(mk_cls):
+    """cannot construct SequenceCollection or Alignment from an empty dict"""
     data = {}
     with pytest.raises(ValueError):
-        new_alignment.make_unaligned_seqs(data, moltype="dna")
+        mk_cls(data, moltype="dna")
 
 
 @pytest.mark.parametrize(
-    "collection_maker",
-    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+    "mk_cls, seqs_data_cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SeqsData),
+        (new_alignment.make_aligned_seqs, new_alignment.AlignedSeqsData),
+    ],
 )
-def test_sequence_collection_names_is_list(collection_maker):
-    """expected to be a list"""
-    seqs = collection_maker({"a": b"AAAAA", "b": b"TTTTT"}, moltype="dna")
-    assert isinstance(seqs.names, list)
+def test_make_seqs_incompatible_moltype(mk_cls, seqs_data_cls, dna_alphabet):
+    """SequenceCollection and Alignment constructor functions should raise an error
+    if the provided moltype is incompatible with the alphabet of the seqs data"""
+
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+    seqs_data = seqs_data_cls.from_seqs(data=data, alphabet=dna_alphabet)
+
+    with pytest.raises(ValueError):
+        _ = mk_cls(seqs_data, moltype="rna")
 
 
-def test_make_unaligned_seqs_list_str():
-    """SequenceCollection init from list of sequences should use indices as keys"""
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_from_list_generates_correct_names(mk_cls):
+    """SequenceCollection/Alignment init from list of sequences should use indices as keys"""
     seqs = ["TTTTT", "CCCCC", "GGGGG"]
-    a = new_alignment.make_unaligned_seqs(seqs, moltype="dna")
-    assert a.seqs["seq_0"] == "TTTTT"
-    assert a.seqs["seq_1"] == "CCCCC"
-    assert a.seqs["seq_2"] == "GGGGG"
+    a = mk_cls(seqs, moltype="dna")
+    assert str(a.seqs["seq_0"]) == "TTTTT"
+    assert str(a.seqs["seq_1"]) == "CCCCC"
+    assert str(a.seqs["seq_2"]) == "GGGGG"
     assert a.names == ["seq_0", "seq_1", "seq_2"]
 
 
-def test_make_unaligned_seqs_pairs():
-    """SequenceCollection init from list of (key,val) pairs should work correctly"""
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_from_pairs(mk_cls):
+    """SequenceCollection/Alignment init from list of (key,val) pairs should work correctly"""
     seqs = [["a", "AAA"], ["t", "TTT"], ["c", "CCC"]]
-    a = new_alignment.make_unaligned_seqs(seqs, moltype="dna")
-    assert a.seqs["a"] == "AAA"
-    assert a.seqs["t"] == "TTT"
-    assert a.seqs["c"] == "CCC"
+    a = mk_cls(seqs, moltype="dna")
+    assert str(a.seqs["a"]) == "AAA"
+    assert str(a.seqs["t"]) == "TTT"
+    assert str(a.seqs["c"]) == "CCC"
     assert a.names == ["a", "t", "c"]
 
 
-def test_make_unaligned_seqs_list_sequences():
-    """correctly construct from list of sequences of length 2"""
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_from_sequences(mk_cls):
+    """SequenceCollection and Alignment constructor functions can be provided with
+    a list of Sequence objects"""
+    # if no names, they should be generated via the standard naming convention,
+    # seq_0, seq_1, etc.
+    seq1 = new_moltype.DNA.make_seq(seq="AC")
+    seq2 = new_moltype.DNA.make_seq(seq="AC")
+    coll = mk_cls([seq1, seq2], moltype="dna")
+    assert isinstance(coll, new_alignment.SequenceCollection)
+    assert coll.names == ["seq_0", "seq_1"]
+
+    # if the sequences have names, they should be used
     seq1 = new_moltype.DNA.make_seq(seq="AC", name="seq1")
     seq2 = new_moltype.DNA.make_seq(seq="AC", name="seq2")
-    coll = new_alignment.make_unaligned_seqs([seq1, seq2], moltype="dna")
+    coll = mk_cls([seq1, seq2], moltype="dna")
+    assert isinstance(coll, new_alignment.SequenceCollection)
+    assert coll.names == ["seq1", "seq2"]
+
+    # if the data dict has different names to the seq names,
+    # the names from data should be used
+    coll = mk_cls({"s1": seq1, "s2": seq2}, moltype="dna")
     assert isinstance(coll, new_alignment.SequenceCollection)
+    assert coll.names == ["s1", "s2"]
+    assert coll.get_seq("s1") == seq1
+
+
+@pytest.mark.parametrize(
+    "mk_cls, data_cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SeqsData),
+        (new_alignment.make_aligned_seqs, new_alignment.AlignedSeqsData),
+    ],
+)
+@pytest.mark.parametrize("seq", ("a", "b"))
+def test_make_seqs_offset(mk_cls, data_cls, seq):
+    """SequenceCollection and Alignment constructor functions should handle
+    offset argument"""
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+    offset = {"a": 1, "b": 2}
+    seqs = mk_cls(data, moltype="dna", offset=offset)
+    got = seqs.get_seq(seq)
+    assert got._seq.offset == offset[seq]
+
+    # if data is a SeqsData object, this should fail
+    data = data_cls.from_seqs(data=data, alphabet=new_moltype.DNA.degen_gapped_alphabet)
+    with pytest.raises(ValueError):
+        _ = mk_cls(data, moltype="dna", offset=offset)
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_invalid_chars(mk_cls):
+    data = {"seq1": "AGT1CCT", "seq2": "AGT$CCC"}
+    with pytest.raises(new_alphabet.AlphabetError):
+        mk_cls(data, moltype="dna")
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_sequence_collection_names_is_list(mk_cls):
+    """expected to be a list"""
+    seqs = mk_cls({"a": b"AAAAA", "b": b"TTTTT"}, moltype="dna")
+    assert isinstance(seqs.names, list)
+    assert seqs.names == ["a", "b"]
 
 
 def test_sequence_collection_init_ordered(ordered1, ordered2):
@@ -2340,6 +2455,18 @@ def gap_seqs():
     ]
 
 
+@pytest.mark.parametrize("i", range(3))
+def test_seq_to_gap_coords_sequences(gap_seqs, i, dna_alphabet):
+    seq, gap_coords = gap_seqs[i]
+    dna = new_moltype.get_moltype("dna")
+    got_ungapped, got_map = new_alignment.seq_to_gap_coords(
+        dna.make_seq(seq=seq), alphabet=dna_alphabet
+    )
+    expect = dna_alphabet.to_indices(seq.replace("-", ""))
+    assert numpy.array_equal(got_ungapped, expect)
+    assert numpy.array_equal(got_map, gap_coords)
+
+
 @pytest.mark.parametrize("i", range(3))
 def test_seq_to_gap_coords_str(gap_seqs, i, dna_alphabet):
     seq, gap_coords = gap_seqs[i]