Repository: cogent3/cogent3 (python)

Issue:
BUG: dotplot alignment path is funky
Dotplot of two sequences from this alignment [ENSG00000006118.fa.zip](https://github.com/user-attachments/files/16790754/ENSG00000006118.fa.zip) is very odd.

```python
from cogent3 import load_aligned_seqs

aln = load_aligned_seqs("ENSG00000006118.fa", moltype="dna")
dp = aln.dotplot(name1="Cow", name2="Dingo")
```
Produces
![dotplot](https://github.com/user-attachments/assets/21cdc228-9437-4b0a-a9d2-6b59ba758c0e)

The bug is that the dashed line at ~x=420, y=150 changes direction from negative to positive slope. Should always be a positive slope!

The error will be in the function `cogent3.draw.dotplot.get_align_coords()`. Clearly we need a better test of that function.

Pull request:
BUG: using new IndelMap methods eliminates shared gaps for dotplots, fixes #2006

Patch:
diff --git a/changelog.d/20240919_105311_Gavin.Huttley.md b/changelog.d/20240919_105311_Gavin.Huttley.md
new file mode 100644
index 000000000..2cfe0f0be
--- /dev/null
+++ b/changelog.d/20240919_105311_Gavin.Huttley.md
@@ -0,0 +1,42 @@
+<!--
+A new scriv changelog fragment.
+
+Uncomment the section that is right (remove the HTML comment wrapper).
+-->
+
+<!--
+### Contributors
+
+- A bullet item for the Contributors category.
+
+-->
+<!--
+### ENH
+
+- A bullet item for the ENH category.
+
+-->
+### BUG
+
+- Dotplots of sequences that gaps in common is now correctly handled. Previously,
+  the displayed aligned path was very funky because aligned segments could be
+  interrupted.
+
+<!--
+### DOC
+
+- A bullet item for the DOC category.
+
+-->
+<!--
+### Deprecations
+
+- A bullet item for the Deprecations category.
+
+-->
+<!--
+### Discontinued
+
+- A bullet item for the Discontinued category.
+
+-->
diff --git a/src/cogent3/draw/dotplot.py b/src/cogent3/draw/dotplot.py
index 4495205a3..98578392a 100644
--- a/src/cogent3/draw/dotplot.py
+++ b/src/cogent3/draw/dotplot.py
@@ -97,6 +97,17 @@ def get_align_coords(
     return paths
 
 
+def _prep_seqs(moltype, seq1, seq2, is_aligned):
+    ig1, seq1 = _convert_input(seq1, moltype)
+    ig2, seq2 = _convert_input(seq2, moltype)
+    if is_aligned:
+        # remove any shared gaps so the aligned path is useful
+        intersect = ig1.shared_gaps(ig2)
+        ig1 = ig1.minus_gaps(intersect)
+        ig2 = ig2.minus_gaps(intersect)
+    return ig1, ig2, seq1, seq2
+
+
 class Dotplot(Drawable):
     """calculates matches between sequences and displays as a dotplot"""
 
@@ -144,14 +155,12 @@ class Dotplot(Drawable):
             AnnotatedDrawable
         title : str
             title for the plot
-        show_progress : bool
-            displays progress bar
         """
 
         # we ensure sequences have gaps parsed and the calculate aspect ratio
         moltype = seq1.moltype if hasattr(seq1, "moltype") else get_moltype(moltype)
-        map1, seq1 = _convert_input(seq1, moltype)
-        map2, seq2 = _convert_input(seq2, moltype)
+        map1, map2, seq1, seq2 = _prep_seqs(moltype, seq1, seq2, is_aligned)
+
         len1, len2 = len(seq1), len(seq2)
         height = width * len2 / len1
 
@@ -159,7 +168,7 @@ class Dotplot(Drawable):
             seq1.name = "seq1"
             seq2.name = "seq2"
 
-        super(Dotplot, self).__init__(
+        super().__init__(
             visible_axes=True,
             showlegend=True,
             width=width,


Test patch:
diff --git a/tests/test_draw/test_dotplot.py b/tests/test_draw/test_dotplot.py
index eebd83bc4..6a53c7617 100644
--- a/tests/test_draw/test_dotplot.py
+++ b/tests/test_draw/test_dotplot.py
@@ -1,11 +1,12 @@
 from unittest import TestCase
 
 import numpy
+import pytest
 
 from cogent3 import get_moltype, make_unaligned_seqs
 from cogent3.core.alignment import Aligned, ArrayAlignment
 from cogent3.core.location import IndelMap
-from cogent3.draw.dotplot import Dotplot, _convert_input, get_align_coords
+from cogent3.draw.dotplot import Dotplot, _convert_input, _prep_seqs, get_align_coords
 
 DNA = get_moltype("dna")
 
@@ -28,37 +29,6 @@ class TestUtilFunctions(TestCase):
         self.assertEqual(str(mapped_gap), str(m))
         self.assertEqual(str(new_seq), str(seq))
 
-    def test_get_align_coords(self):
-        """correctly returns the alignment coordinates"""
-        # 01234  5
-        # ACGGT--A
-        #   012345
-        # --GGTTTA
-        m1, seq1 = DNA.make_seq(seq="ACGGT--A").parse_out_gaps()
-        m2, seq2 = DNA.make_seq(seq="--GGTTTA").parse_out_gaps()
-        path = get_align_coords(m1, m2)
-        expect = [2, 4, None, 5, 5], [0, 2, None, 5, 5]
-        self.assertEqual(path.get_coords(), expect)
-
-        # we have no gaps, so coords will be None
-        m1, _ = seq1.parse_out_gaps()
-        m2, _ = seq2.parse_out_gaps()
-        path = get_align_coords(m1, m2)
-        self.assertEqual(path.get_coords(), ([], []))
-
-        # unless we indicate the seqs came from an Alignment
-        m1, seq1 = DNA.make_seq(seq="ACGGTTTA").parse_out_gaps()
-        m2, seq2 = DNA.make_seq(seq="GGGGTTTA").parse_out_gaps()
-        paths = get_align_coords(m1, m2, aligned=True)
-        # display ranges are inclusive, thus length - 1
-        self.assertEqual(paths.get_coords(), ([0, len(seq1) - 1], [0, len(seq1) - 1]))
-
-        # raises an exception if the Aligned seqs are different lengths
-        m1, seq1 = DNA.make_seq(seq="ACGGTTTA").parse_out_gaps()
-        m2, seq2 = DNA.make_seq(seq="GGGGTT").parse_out_gaps()
-        with self.assertRaises(AssertionError):
-            get_align_coords(m1, m2, aligned=True)
-
     def test_display2d(self):
         """correctly constructs a Display2d"""
         #           111111
@@ -177,3 +147,48 @@ def test_dotplot_unaligned():
     # trigger building traces
     _ = dp.figure
     assert len(dp.traces[0].x)
+
+
+def test_get_align_coords():
+    """correctly returns the alignment coordinates"""
+    # 01234  5
+    # ACGGT--A
+    #   012345
+    # --GGTTTA
+    m1, seq1 = DNA.make_seq(seq="ACGGT--A").parse_out_gaps()
+    m2, seq2 = DNA.make_seq(seq="--GGTTTA").parse_out_gaps()
+    path = get_align_coords(m1, m2)
+    expect = [2, 4, None, 5, 5], [0, 2, None, 5, 5]
+    assert path.get_coords() == expect
+
+    # we have no gaps, so coords will be None
+    m1, _ = seq1.parse_out_gaps()
+    m2, _ = seq2.parse_out_gaps()
+    path = get_align_coords(m1, m2)
+    assert path.get_coords() == ([], [])
+
+    # unless we indicate the seqs came from an Alignment
+    m1, seq1 = DNA.make_seq(seq="ACGGTTTA").parse_out_gaps()
+    m2, seq2 = DNA.make_seq(seq="GGGGTTTA").parse_out_gaps()
+    paths = get_align_coords(m1, m2, aligned=True)
+    # display ranges are inclusive, thus length - 1
+    assert paths.get_coords() == ([0, len(seq1) - 1], [0, len(seq1) - 1])
+
+
+def test_get_aligned_coords_invalid_maps():
+    # raises an exception if the Aligned seqs are different lengths
+    m1, _ = DNA.make_seq(seq="ACGGTTTA").parse_out_gaps()
+    m2, _ = DNA.make_seq(seq="GGGGTT").parse_out_gaps()
+    with pytest.raises(AssertionError):
+        get_align_coords(m1, m2, aligned=True)
+
+
+def test_get_align_coords_common_gaps():
+    # 01234  5
+    # ACGGT--A
+    #   012345
+    # --GGTTTA
+    m1, m2, *_ = _prep_seqs(DNA, "ACG--GT--A", "--G--GTTTA", True)
+    path = get_align_coords(m1, m2)
+    expect = [2, 4, None, 5, 5], [0, 2, None, 5, 5]
+    assert path.get_coords() == expect