from unittest import TestCase

from dark.aaVars import CODONS
from dark.dna import (
    AMBIGUOUS,
    BASES_TO_AMBIGUOUS,
    Bases,
    FloatBaseCounts,
    aaCompatible,
    compareDNAReads,
    compatibleAAs,
    findKozakConsensus,
    leastAmbiguous,
    leastAmbiguousFromCounts,
    matchToString,
    sequenceToRegex,
    translatable,
)
from dark.reads import DNAKozakRead, DNARead, Read

# The following are the letters that used to be on
# from Bio.Alphabet.IUPAC import IUPACAmbiguousDNA
# IUPACAmbiguousDNA.letters
# But Bio.Alphabet is now deprecated and will be removed.
AMBIGUOUS_DNA_LETTERS = "GATCRYWSMKHBVDN"

AMBIGUOUS_PAIRS = (
    ("AC", "M"),
    ("AG", "R"),
    ("AT", "W"),
    ("GC", "S"),
    ("GT", "K"),
    ("CT", "Y"),
)

AMBIGUOUS_TRIPLES = (("ACG", "V"), ("ACT", "H"), ("AGT", "D"), ("CGT", "B"))


class TestAmbiguousLetters(TestCase):
    """
    Test the ambiguous DNA mapping.
    """

    def testExpectedAmbiguousLetters(self):
        """
        The ambiguous DNA letters must match those given by IUPAC.
        """
        self.assertEqual(sorted(AMBIGUOUS_DNA_LETTERS), sorted(AMBIGUOUS))

    def testExpectedLengthOne(self):
        """
        The unambiguous DNA letters must be in sets by themselves.
        """
        for base in "ACGT":
            self.assertEqual({base}, AMBIGUOUS[base])

    def testExpectedLengthsGreaterThanOne(self):
        """
        The ambiguous DNA letters must be in sets of size greater than one
        and less than 5.
        """
        for base in set(AMBIGUOUS_DNA_LETTERS) - set("ACGT"):
            self.assertTrue(5 > len(AMBIGUOUS[base]) > 1)

    def testAmbiguousLettersAreAllACGT(self):
        """
        The ambiguous DNA letter sets must all be drawn from A, C, G, T.
        """
        for bases in AMBIGUOUS.values():
            self.assertEqual(set(), bases - set("ACGT"))

    def testAmbiguousLettersAreAllSets(self):
        """
        The ambiguous DNA letter sets must be of the set type (this test
        is in lieu of testing that a tuple or list has no repeats).
        """
        for bases in AMBIGUOUS.values():
            self.assertTrue(isinstance(bases, set))


class TestReversedAmbiguousLetters(TestCase):
    """
    Test the reversed ambiguous DNA mapping.
    """

    def testExpectedSingleBase(self):
        """
        The unambiguous bases must map to themselves.
        """
        for base in "ACGT":
            self.assertEqual(base, BASES_TO_AMBIGUOUS[base])

    def testNIsAllBases(self):
        """
        N must map to all bases.
        """
        self.assertEqual("N", BASES_TO_AMBIGUOUS["ACGT"])


class TestCompareDNAReads(TestCase):
    """
    Test the compareDNAReads function.
    """

    def testEmptySequences(self):
        """
        Two empty sequences must compare as expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 0,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(Read("id1", ""), Read("id2", "")),
        )

    def testExactMatch(self):
        """
        Two sequences that match exactly must compare as expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(Read("id1", "ACGTT"), Read("id2", "ACGTT")),
        )

    def testNoOffsets(self):
        """
        If an empty set of wanted offsets is passed, the result must be empty.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 0,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(Read("id1", "ATT-T"), Read("id2", "A-GTC"), offsets=set()),
        )

    def testOffsets(self):
        """
        If a set of wanted offsets is passed, the result must be restricted to
        just those offsets.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 1,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(4, "T", "C")],
                    "differenceOffsets": {
                        "TC": [4],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ATT-T"), Read("id2", "A-GTC"), offsets=set([0, 4])
            ),
        )

    def testMatchWithAmbiguityButStrict(self):
        """
        Two sequences that match exactly, apart from one ambiguity in the first
        sequence, must compare as expected when we specify matchAmbiguous=False
        to disallow ambiguous matching.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(5, "S", "C")],
                    "differenceOffsets": {
                        "SC": [5],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTS"),
                Read("id2", "ACGTTC"),
                matchAmbiguous=False,
            ),
        )

    def testMatchWithAmbiguityInFirst(self):
        """
        Two sequences that match exactly, apart from one ambiguity in the first
        sequence, must compare as expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 1,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [(5, "S", "C")],
                    "nonGapMismatches": [],
                    # There are no differences since the ambiguous S code matches
                    # the C.
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTS"),
                Read("id2", "ACGTTC"),
            ),
        )

    def testMatchWithAmbiguityInSecond(self):
        """
        Two sequences that match exactly, apart from one ambiguity in the
        second sequence, must compare as expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 1,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [(5, "C", "S")],
                    "nonGapMismatches": [],
                    # There are no differences since the ambiguous S code matches
                    # the C.
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTC"),
                Read("id2", "ACGTTS"),
            ),
        )

    def testNonMatchingAmbiguityInFirst(self):
        """
        Two sequences that match exactly, apart from one (incompatible)
        ambiguity in the second sequence, must compare as expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(5, "W", "C")],
                    "differenceOffsets": {
                        "WC": [5],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTW"),
                Read("id2", "ACGTTC"),
            ),
        )

    def testMatchWithAmbiguityInBoth(self):
        """
        Two sequences that match exactly, apart from one (compatible)
        ambiguity at the same location in the sequence, must compare as
        expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 1,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [(5, "K", "S")],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTK"),
                Read("id2", "ACGTTS"),
            ),
        )

    def testMatchWithAmbiguityInBothButStrict(self):
        """
        Two sequences that match exactly, apart from one (compatible)
        ambiguity at the same location in the sequence, must compare as
        expected. Strict.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(5, "K", "S")],
                    "differenceOffsets": {
                        "KS": [5],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTK"),
                Read("id2", "ACGTTS"),
                matchAmbiguous=False,
            ),
        )

    def testMatchWithIncompatibleAmbiguityInBoth(self):
        """
        Two sequences that match exactly, apart from one (incompatible)
        ambiguity at the same location in the sequence, must compare as
        expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(5, "W", "S")],
                    "differenceOffsets": {
                        "WS": [5],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(Read("id1", "ACGTTW"), Read("id2", "ACGTTS")),
        )

    def testMatchWithIncompatibleAmbiguityInBothButStrict(self):
        """
        Two sequences that match exactly, apart from one (incompatible)
        ambiguity at the same location in the sequence, must compare as
        expected. Strict.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(5, "W", "S")],
                    "differenceOffsets": {
                        "WS": [5],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTW"),
                Read("id2", "ACGTTS"),
                matchAmbiguous=False,
            ),
        )

    def testMatchWithIdenticalAmbiguity(self):
        """
        Two sequences that match exactly, including one (identical)
        ambiguity at the same location in the sequence, must compare as
        expected.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 1,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [(5, "N", "N")],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(Read("id1", "ACGTTN"), Read("id2", "ACGTTN")),
        )

    def testMatchWithIdenticalAmbiguityButStrict(self):
        """
        Two sequences that match exactly, including one (identical)
        ambiguity at the same location in the sequence, must compare as
        expected. Strict.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(5, "N", "N")],
                    "differenceOffsets": {
                        # The two N's are not considered to match, because we
                        # pass matchAmbiguous=False to compareDNAReads.
                        "NN": [5],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [5],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTN"), Read("id2", "ACGTTN"), matchAmbiguous=False
            ),
        )

    def testGapInFirst(self):
        """
        A gap in the first sequence must be dealt with correctly.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 4,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 1,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {
                        "-G": [2],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [2],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "AC-TT"),
                Read("id2", "ACGTT"),
            ),
        )

    def testGapInSecond(self):
        """
        A gap in the second sequence must be dealt with correctly.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 3,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 2,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {
                        "C-": [1],
                        "G-": [2],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [1, 2],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTT"),
                Read("id2", "A--TT"),
            ),
        )

    def testNonDefaultGapChars(self):
        """
        We must be able to specify the gap characters.
        """
        for gap in "+$":
            self.assertEqual(
                {
                    "match": {
                        "identicalMatchCount": 3,
                        "ambiguousMatchCount": 0,
                        "gapMismatchCount": 2,
                        "gapGapMismatchCount": 0,
                        "nonGapMismatchCount": 0,
                        "noCoverageCount": 0,
                        "noCoverageNoCoverageCount": 0,
                        "ambiguousMatches": [],
                        "nonGapMismatches": [],
                        "differenceOffsets": {
                            f"A{gap}": [0],
                            f"{gap}G": [2],
                        },
                    },
                    "read1": {
                        "ambiguousOffsets": [],
                        "extraCount": 0,
                        "gapOffsets": [2],
                        "noCoverageOffsets": [],
                    },
                    "read2": {
                        "ambiguousOffsets": [],
                        "extraCount": 0,
                        "gapOffsets": [0],
                        "noCoverageOffsets": [],
                    },
                },
                compareDNAReads(
                    Read("id1", "AC%sTT" % gap),
                    Read("id2", "%sCGTT" % gap),
                    gapChars="+$",
                ),
            )

    def testGapGap(self):
        """
        Coinciding gaps in the sequences must be dealt with correctly.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 2,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 2,
                    "gapGapMismatchCount": 1,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {
                        "C-": [1],
                        "-T": [3],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [2, 3],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [1, 2],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "AC--T"),
                Read("id2", "A--TT"),
            ),
        )

    def testGapAmbiguous(self):
        """
        Testing that the ambiguousOffset shows ambiguous characters paired
        with gaps as expected
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 2,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 2,
                    "gapGapMismatchCount": 1,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {
                        "N-": [1],
                        "-N": [3],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [1],
                    "extraCount": 0,
                    "gapOffsets": [2, 3],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [3],
                    "extraCount": 0,
                    "gapOffsets": [1, 2],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "AN--T"),
                Read("id2", "A--NT"),
            ),
        )

    def testNoCoverageInFirst(self):
        """
        A no coverage character in the first sequence must be dealt with
        correctly.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 4,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 1,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [2],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "AC?TT"), Read("id2", "ACGTT"), noCoverageChars="?"
            ),
        )

    def testNoCoverageInSecond(self):
        """
        A no coverage in the second sequence must be dealt with correctly.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 3,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 2,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [1, 2],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTT"), Read("id2", "A??TT"), noCoverageChars="?"
            ),
        )

    def testNoCoverageNoCoverage(self):
        """
        Coinciding no coverages in the sequences must be dealt with correctly.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 2,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 2,
                    "noCoverageNoCoverageCount": 1,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [2, 3],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [1, 2],
                },
            },
            compareDNAReads(
                Read("id1", "AC??T"),
                Read("id2", "A??TT"),
                noCoverageChars="?",
            ),
        )

    def testNoCoverageAndGaps(self):
        """
        When no coverage and gap characters are both found in the sequences,
        the result must be correct.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 2,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 3,
                    "gapGapMismatchCount": 2,
                    "nonGapMismatchCount": 1,
                    "noCoverageCount": 2,
                    "noCoverageNoCoverageCount": 1,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(10, "T", "A")],
                    "differenceOffsets": {
                        "-T": [7],
                        "C-": [8],
                        "G-": [9],
                        "TA": [10],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [5, 6, 7],
                    "noCoverageOffsets": [2, 3],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [5, 6, 8, 9],
                    "noCoverageOffsets": [1, 2],
                },
            },
            compareDNAReads(
                Read("id1", "AC??T---CGT"),
                Read("id2", "A??TT--T--A"),
                noCoverageChars="?",
            ),
        )

    def testExtraInFirst(self):
        """
        If the first sequence has extra bases, they must be indicated in the
        extraCount.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 2,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTTCC"),
                Read("id2", "ACGTT"),
            ),
        )

    def testExtraInSecond(self):
        """
        If the second sequence has extra bases, they must be indicated in the
        extraCount.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 2,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTT"),
                Read("id2", "ACGTTCC"),
            ),
        )

    def testExtraAmbiguous(self):
        """
        If the first sequence has extra bases which are ambiguous,they must
        be indicated in the extraCount and in the ambiguousOffset.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 5,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 0,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [],
                    "differenceOffsets": {},
                },
                "read1": {
                    "ambiguousOffsets": [6],
                    "extraCount": 2,
                    "gapOffsets": [5],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTT-N"),
                Read("id2", "ACGTT"),
            ),
        )

    def testMismatch(self):
        """
        If the sequences have mismatched (non-ambiguous) bases, their count
        must be given correctly in the nonGapMismatchCount.
        """
        self.assertEqual(
            {
                "match": {
                    "identicalMatchCount": 3,
                    "ambiguousMatchCount": 0,
                    "gapMismatchCount": 0,
                    "gapGapMismatchCount": 0,
                    "nonGapMismatchCount": 2,
                    "noCoverageCount": 0,
                    "noCoverageNoCoverageCount": 0,
                    "ambiguousMatches": [],
                    "nonGapMismatches": [(3, "T", "C"), (4, "T", "C")],
                    "differenceOffsets": {
                        "TC": [3, 4],
                    },
                },
                "read1": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
                "read2": {
                    "ambiguousOffsets": [],
                    "extraCount": 0,
                    "gapOffsets": [],
                    "noCoverageOffsets": [],
                },
            },
            compareDNAReads(
                Read("id1", "ACGTT"),
                Read("id2", "ACGCC"),
            ),
        )


class TestMatchToString(TestCase):
    """
    Test the matchToString function.
    """

    def testMatchWithAmbiguityButStrict(self):
        """
        Two sequences that match exactly, apart from one ambiguity in the first
        sequence, must compare as expected when we specify matchAmbiguous=False
        to disallow ambiguous matching.
        """
        read1 = Read("id1", "ACGTTS")
        read2 = Read("id2", "ACGTTC")
        match = compareDNAReads(read1, read2, matchAmbiguous=False)

        self.assertEqual(
            """\
Exact matches: 5/6 (83.33%)
Ambiguous matches: 0
Mismatches: 1/6 (16.67%)
  Not involving gaps (i.e., conflicts or ambiguities): 1/6 (16.67%)
  Involving a gap in one sequence: 0
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 6
    Gaps: 0
    No coverage: 0
    Ambiguous: 1/6 (16.67%)
  Id: id2
    Length: 6
    Gaps: 0
    No coverage: 0
    Ambiguous: 0""",
            matchToString(match, read1, read2, matchAmbiguous=False),
        )

    def testMatchWithAmbiguityAndNotStrict(self):
        """
        Two sequences that match exactly, apart from one ambiguity in the first
        sequence, must compare as expected when we specify matchAmbiguous=True
        to allow ambiguous matching.
        """
        read1 = Read("id1", "ACGTTS")
        read2 = Read("id2", "ACGTTC")
        match = compareDNAReads(read1, read2, matchAmbiguous=True)

        self.assertEqual(
            """\
Exact matches: 5/6 (83.33%)
Ambiguous matches: 1/6 (16.67%)
Exact or ambiguous matches: 6/6 (100.00%)
Mismatches: 0
  Not involving gaps (i.e., conflicts): 0
  Involving a gap in one sequence: 0
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 6
    Gaps: 0
    No coverage: 0
    Ambiguous: 1/6 (16.67%)
  Id: id2
    Length: 6
    Gaps: 0
    No coverage: 0
    Ambiguous: 0""",
            matchToString(match, read1, read2, matchAmbiguous=True),
        )

    def testGapLocations(self):
        """
        Gap locations must be returned correctly.
        """
        read1 = Read("id1", "TTTTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT------GCGCG")
        match = compareDNAReads(read1, read2)
        self.assertEqual(
            """\
Exact matches: 10/16 (62.50%)
Ambiguous matches: 0
Mismatches: 6/16 (37.50%)
  Not involving gaps (i.e., conflicts): 0
  Involving a gap in one sequence: 6/16 (37.50%)
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 0
  Id: id2
    Length: 16
    Gaps: 6/16 (37.50%)
    Gap locations (1-based): 6, 7, 8, 9, 10, 11
    No coverage: 0
    Ambiguous: 0""",
            matchToString(match, read1, read2),
        )

    def testAmbiguousMatchLocations(self):
        """
        Ambiguous match locations must be returned correctly.
        """
        read1 = Read("id1", "TWTTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT------GCGCK")
        match = compareDNAReads(read1, read2)
        self.assertEqual(
            """\
Exact matches: 8/16 (50.00%)
Ambiguous matches: 2/16 (12.50%)
Exact or ambiguous matches: 10/16 (62.50%)
Mismatches: 6/16 (37.50%)
  Not involving gaps (i.e., conflicts): 0
  Involving a gap in one sequence: 6/16 (37.50%)
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 1/16 (6.25%)
  Id: id2
    Length: 16
    Gaps: 6/16 (37.50%)
    Gap locations (1-based): 6, 7, 8, 9, 10, 11
    No coverage: 0
    Ambiguous: 1/16 (6.25%)
Ambiguous matches:
    2 W T
    16 G K""",
            matchToString(match, read1, read2, includeAmbiguousMatches=True),
        )

    def testNonGapMismatchLocations(self):
        """
        Non-gap mismatch locations must be returned correctly.
        """
        read1 = Read("id1", "TATTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT------GCGCC")
        match = compareDNAReads(read1, read2)
        self.assertEqual(
            """\
Exact matches: 8/16 (50.00%)
Ambiguous matches: 0
Mismatches: 8/16 (50.00%)
  Not involving gaps (i.e., conflicts): 2/16 (12.50%)
  Involving a gap in one sequence: 6/16 (37.50%)
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 0
  Id: id2
    Length: 16
    Gaps: 6/16 (37.50%)
    Gap locations (1-based): 6, 7, 8, 9, 10, 11
    No coverage: 0
    Ambiguous: 0
Non-gap mismatches:
    2 A T
    16 G C""",
            matchToString(match, read1, read2, includeNonGapMismatches=True),
        )

    def testNoCoverageLocations(self):
        """
        No coverage locations must be returned correctly.
        """
        read1 = Read("id1", "TTTTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT??????GCGCG")
        match = compareDNAReads(read1, read2, noCoverageChars="?")
        self.assertEqual(
            """\
Exact matches: 10/16 (62.50%)
Ambiguous matches: 0
Exact matches (ignoring no coverage sites): 10/10 (100.00%)
Ambiguous matches (ignoring no coverage sites): 0
Mismatches: 0
  Not involving gaps (i.e., conflicts): 0
  Involving a gap in one sequence: 0
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 6/16 (37.50%)
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 0
  Id: id2
    Length: 16
    Gaps: 0
    No coverage: 6/16 (37.50%)
    No coverage locations (1-based): 6, 7, 8, 9, 10, 11
    Ambiguous: 0""",
            matchToString(match, read1, read2),
        )

    def testExcludeGapLocations(self):
        """
        If gap locations are not wanted, they should not appear in the result
        of a call to matchToString.
        """
        read1 = Read("id1", "TTTTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT------GCGCG")
        match = compareDNAReads(read1, read2)
        self.assertEqual(
            """\
Exact matches: 10/16 (62.50%)
Ambiguous matches: 0
Mismatches: 6/16 (37.50%)
  Not involving gaps (i.e., conflicts): 0
  Involving a gap in one sequence: 6/16 (37.50%)
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 0
  Id: id2
    Length: 16
    Gaps: 6/16 (37.50%)
    No coverage: 0
    Ambiguous: 0""",
            matchToString(match, read1, read2, includeGapLocations=False),
        )

    def testExcludeNoCoverageLocations(self):
        """
        If no coverage locations are not wanted, they should not appear in the
        result of a call to matchToString.
        """
        read1 = Read("id1", "TTTTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT??????GCGCG")
        match = compareDNAReads(read1, read2, noCoverageChars="?")
        self.assertEqual(
            """\
Exact matches: 10/16 (62.50%)
Ambiguous matches: 0
Exact matches (ignoring no coverage sites): 10/10 (100.00%)
Ambiguous matches (ignoring no coverage sites): 0
Mismatches: 0
  Not involving gaps (i.e., conflicts): 0
  Involving a gap in one sequence: 0
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 6/16 (37.50%)
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 0
  Id: id2
    Length: 16
    Gaps: 0
    No coverage: 6/16 (37.50%)
    Ambiguous: 0""",
            matchToString(match, read1, read2, includeNoCoverageLocations=False),
        )

    def testDifferenceCounts(self):
        """
        Difference counts must be returned correctly.
        """
        read1 = Read("id1", "GACTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT------GCGCC")
        match = compareDNAReads(read1, read2)
        self.assertEqual(
            """\
Exact matches: 6/16 (37.50%)
Ambiguous matches: 0
Mismatches: 10/16 (62.50%)
  Not involving gaps (i.e., conflicts): 4/16 (25.00%)
  Involving a gap in one sequence: 6/16 (37.50%)
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 0
  Id: id2
    Length: 16
    Gaps: 6/16 (37.50%)
    Gap locations (1-based): 6, 7, 8, 9, 10, 11
    No coverage: 0
    Ambiguous: 0
Difference counts:
    A->-: 6
    A->T: 1
    C->T: 1
    G->C: 1
    G->T: 1""",
            matchToString(match, read1, read2, includeDifferenceCounts=True),
        )

    def testDifferenceLocations(self):
        """
        Difference locations (and counts) must be returned correctly.
        """
        read1 = Read("id1", "GACTTAAAAAAGCGCG")
        read2 = Read("id2", "TTTTT------GCGCC")
        match = compareDNAReads(read1, read2)
        self.maxDiff = None
        self.assertEqual(
            """\
Exact matches: 6/16 (37.50%)
Ambiguous matches: 0
Mismatches: 10/16 (62.50%)
  Not involving gaps (i.e., conflicts): 4/16 (25.00%)
  Involving a gap in one sequence: 6/16 (37.50%)
  Involving a gap in both sequences: 0
  Involving no coverage in one sequence: 0
  Involving no coverage in both sequences: 0
  Id: id1
    Length: 16
    Gaps: 0
    No coverage: 0
    Ambiguous: 0
  Id: id2
    Length: 16
    Gaps: 6/16 (37.50%)
    Gap locations (1-based): 6, 7, 8, 9, 10, 11
    No coverage: 0
    Ambiguous: 0
Difference counts and locations:
    A->- (6): 6, 7, 8, 9, 10, 11
    A->T (1): 2
    C->T (1): 3
    G->C (1): 16
    G->T (1): 1""",
            matchToString(match, read1, read2, includeDifferenceLocations=True),
        )


class TestFindKozakConsensus(TestCase):
    """
    Test the findKozakConsensus function.
    """

    def testNoSequence(self):
        """
        If no sequence is given, no Kozak sequence should be found.
        """
        read = DNARead("id", "")
        self.assertEqual([], list(findKozakConsensus(read)))

    def testShortSequence(self):
        """
        If a 4 nt long sequence is given, no Kozak sequence should be found.
        """
        read = DNARead("id", "ATTG")
        self.assertEqual([], list(findKozakConsensus(read)))

    def testOneKozakConsensus(self):
        """
        In a given sequence with an exact Kozak consensus sequence, the offset
        and quality percentage should be as expected.
        """
        read = DNARead("id", "ATTGCCGCCATGGGGG")
        expectedKozakRead = DNAKozakRead(read, 3, 13, 100.0)
        (result,) = list(findKozakConsensus(read))
        self.assertEqual(expectedKozakRead, result)

    def testNoKozakConsensus(self):
        """
        In a given sequence without a Kozak consensus, the output should be
        as expected.
        """
        read = DNARead("id", "ATTGCCTCCATGGGGG")
        self.assertEqual([], list(findKozakConsensus(read)))

    def testFindTwoKozakConsensi(self):
        """
        In a given sequence with two Kozak consensuses with different offsets
        and qualities, the output should be as expected.
        """
        read = DNARead("id", "ATTGCCGCCATGGGGGGCCATGG")
        expectedRead1 = DNARead("id", "ATTGCCGCCATGGGGGGCCATGG")
        expectedRead2 = DNARead("id", "ATTGCCGCCATGGGGGGCCATGG")
        expectedKozakRead1 = DNAKozakRead(expectedRead1, 3, 13, 100.0)
        expectedKozakRead2 = DNAKozakRead(expectedRead2, 13, 23, 60.0)

        self.assertEqual(
            [expectedKozakRead1, expectedKozakRead2], list(findKozakConsensus(read))
        )

    def testNoKozakConsensusAtEnd(self):
        """
        In a given sequence without a Kozak consensus, the output should be
        as expected.
        """
        read = DNARead("id", "ATTGCCTCCATGGGGATG")
        self.assertEqual([], list(findKozakConsensus(read)))

    def testKozakConsensusAtEnd(self):
        """
        In a given sequence without a Kozak consensus, the output should be
        as expected.
        """
        read = DNARead("id", "AAAAAAATTGCCGCCATGG")
        expectedKozakRead = DNAKozakRead(read, 9, 19, 100.0)
        (result,) = list(findKozakConsensus(read))
        self.assertEqual(expectedKozakRead, result)

    def testKozakConsensusATGStart(self):
        """
        In a given sequence without a Kozak consensus, the output should be
        as expected.
        """
        read = DNARead("id", "AAAATGGAAAAAAATTGCCGCC")
        self.assertEqual([], list(findKozakConsensus(read)))

    def testKozakConsensusAtEndPart(self):
        """
        In a given sequence without a Kozak consensus, the output should be
        as expected.
        """
        read = DNARead("id", "AAAAAAATTGCCGCCATG")
        self.assertEqual([], list(findKozakConsensus(read)))


class TestFloatBaseCounts(TestCase):
    """
    Test the FloatBaseCounts class.
    """

    def testOneUnambiguousHomogeneous(self):
        """
        If one unambiguous code is passed to FloatBaseCounts, it must be
        considered homogeneous correctly, depending on the passed level.
        """
        counts = FloatBaseCounts("A")
        self.assertTrue(counts.homogeneous(1.0))
        self.assertTrue(counts.homogeneous(0.75))
        self.assertTrue(counts.homogeneous(0.0))

    def testTwoIdenticalUnambiguoussHomogeneous(self):
        """
        If an unambiguous code is passed to FloatBaseCounts twice, they must be
        considered homogeneous correctly, depending on the passed level.
        """
        counts = FloatBaseCounts("AA")
        self.assertTrue(counts.homogeneous(1.0))
        self.assertTrue(counts.homogeneous(0.75))
        self.assertTrue(counts.homogeneous(0.0))

    def testTwoDifferentUnambiguoussHomogeneous(self):
        """
        If two different unambiguous codes are passed to FloatBaseCounts,
        they must be considered homogeneous correctly, depending on the passed
        level.
        """
        counts = FloatBaseCounts("AG")
        self.assertFalse(counts.homogeneous(1.0))
        self.assertTrue(counts.homogeneous(0.5))
        self.assertTrue(counts.homogeneous(0.2))

    def testOneAmbiguousHomogeneousM(self):
        """
        If one ambiguous code (M = A, C) is passed to FloatBaseCounts, it must
        be considered homogeneous correctly, depending on the passed level.
        """
        counts = FloatBaseCounts("M")
        self.assertFalse(counts.homogeneous(1.0))
        self.assertFalse(counts.homogeneous(0.75))
        self.assertTrue(counts.homogeneous(0.0))

    def testOneAmbiguousHomogeneousV(self):
        """
        If one ambiguous code (V = A, C, G) is passed to FloatBaseCounts, it
        must be considered homogeneous correctly, depending on the passed
        level.
        """
        counts = FloatBaseCounts("V")
        self.assertFalse(counts.homogeneous(1.0))
        self.assertFalse(counts.homogeneous(0.75))
        self.assertTrue(counts.homogeneous(0.2))
        self.assertTrue(counts.homogeneous(0.0))

    def testTwoIdenticalUnambiguoussVariable(self):
        """
        If an unambiguous code is passed to FloatBaseCounts twice, they must be
        considered non-variable.
        """
        counts = FloatBaseCounts("AA")
        self.assertFalse(counts.variable())

    def testTwoDifferentUnambiguoussVariable(self):
        """
        If two different unambiguous codes are passed to FloatBaseCounts,
        they must be considered variable.
        """
        counts = FloatBaseCounts("AT")
        self.assertTrue(counts.variable())

    def testOneUnambiguousOneAmbiguousVariable(self):
        """
        If one unambiguous code and one incompatible ambiguous code are passed
        to FloatBaseCounts, they must be considered confirm variable.
        """
        counts = FloatBaseCounts("AS")
        self.assertTrue(counts.variable(confirm=True))

    def testOneUnambiguousOneAmbiguousNonVariable(self):
        """
        If one unambiguous code and one compatible ambiguous code are passed
        to FloatBaseCounts, they must not be considered confirm variable
        if confirm is True.
        """
        counts = FloatBaseCounts("AM")
        self.assertFalse(counts.variable(confirm=True))

    def testOneUnambiguousOneAmbiguousVariableUnconfirm(self):
        """
        If one unambiguous code and one compatible ambiguous code are passed
        to FloatBaseCounts, they must be considered variable if confirm
        is False.
        """
        counts = FloatBaseCounts("AM")
        self.assertTrue(counts.variable(confirm=False))

    def testOneUnambiguousOneAmbiguousVariableConfirm(self):
        """
        If one unambiguous code and one incompatible ambiguous code are passed
        to FloatBaseCounts, they must be considered variable if confirm
        is True.
        """
        counts = FloatBaseCounts("AY")
        self.assertTrue(counts.variable(confirm=True))

    def testTwoAmbiguousVariableConfirmFalse(self):
        """
        If two compatible but different ambiguous codes are passed to
        FloatBaseCounts, they must be considered variable if confirm
        is False.
        """
        counts = FloatBaseCounts("MR")
        self.assertTrue(counts.variable(confirm=False))

    def testTwoAmbiguousVariableConfirmTrue(self):
        """
        If two compatible but different ambiguous codes are passed to
        FloatBaseCounts, they must not be considered variable if
        confirm is True.
        """
        counts = FloatBaseCounts("MR")
        self.assertFalse(counts.variable(confirm=True))

    def testOneGapVariableUnconfirm(self):
        """
        If one gap and one unambiguous code are passed to FloatBaseCounts,
        they must be considered variable if confirm is False.
        """
        counts = FloatBaseCounts("A-")
        self.assertTrue(counts.variable(confirm=False))

    def testOneGapVariableConfirm(self):
        """
        If one gap and one unambiguous code are passed to FloatBaseCounts,
        they must be considered variable if confirm is True.
        """
        counts = FloatBaseCounts("A-")
        self.assertTrue(counts.variable(confirm=True))

    def testTwoAmbiguousStr(self):
        """
        If two compatible but different ambiguous codes are passed to
        FloatBaseCounts, they must be converted into a string correctly.
        """
        counts = FloatBaseCounts("MR")
        self.assertEqual("A:1.00 C:0.50 G:0.50 (0.500)", str(counts))

    def testTwoAmbiguousStrWithIntegerTotals(self):
        """
        If two 2-way ambiguous codes are passed to
        FloatBaseCounts, they must be converted into a string correctly
        (i.e., with integer counts).
        """
        counts = FloatBaseCounts("MMA")
        self.assertEqual("A:2 C:1 (0.667)", str(counts))

    def testLowerCase(self):
        """
        If two 2-way ambiguous codes are passed to FloatBaseCounts as lower
        case, they must be converted into a string correctly.
        """
        counts = FloatBaseCounts("mm")
        self.assertEqual("A:1 C:1 (0.500)", str(counts))

    def testMixedCase(self):
        """
        If two 2-way ambiguous codes are passed to FloatBaseCounts in mixed
        case, they must be converted into a string correctly.
        """
        counts = FloatBaseCounts("mM")
        self.assertEqual("A:1 C:1 (0.500)", str(counts))

    def testMostFrequentUnambiguous(self):
        """
        If one unambiguous code passed to FloatBaseCounts is most frequent, the
        mostFrequent method must give the expected result.
        """
        counts = FloatBaseCounts("AAACCTGG")
        self.assertEqual({"A"}, counts.mostFrequent())

    def testEquallyFrequent(self):
        """
        If two codes are passed to FloatBaseCounts in equal numbers, the
        mostFrequent method must give the expected result.
        """
        counts = FloatBaseCounts("AAACCCTGG")
        self.assertEqual(set("AC"), counts.mostFrequent())

    def testMostFrequentWithTwoAmbiguousStr(self):
        """
        If two overlapping ambiguous codes are passed to FloatBaseCounts, the
        mostFrequent method must give the expected result.
        """
        counts = FloatBaseCounts("MR")
        self.assertEqual({"A"}, counts.mostFrequent())

    def testHighestFrequencyUnambiguous(self):
        """
        If one unambiguous code passed to FloatBaseCounts is most frequent, the
        highestFrequency method must give the expected result.
        """
        counts = FloatBaseCounts("AAACCTGG")
        self.assertEqual(0.375, counts.highestFrequency())

    def testEqualFrequency(self):
        """
        If two codes are passed to FloatBaseCounts in equal numbers, the
        highestFrequency method must give the expected result.
        """
        counts = FloatBaseCounts("AAAACCCTGG")
        self.assertEqual(0.4, counts.highestFrequency())

    def testHighestFrequencyWithTwoAmbiguousStr(self):
        """
        If two overlapping ambiguous codes are passed to FloatBaseCounts, the
        highestFrequency method must give the expected result.
        """
        counts = FloatBaseCounts("MR")
        self.assertEqual(0.5, counts.highestFrequency())

    def testLength4Unambiguous(self):
        """
        If all unambiguous bases are given to FloatBaseCounts, its length
        must be 4.
        """
        counts = FloatBaseCounts("AAAACCCTGG")
        self.assertEqual(4, len(counts))

    def testLength3Ambiguous(self):
        """
        If overlapping ambiguous codes are passed to FloatBaseCounts, the
        length must give the expected result.
        """
        counts = FloatBaseCounts("MRC")
        self.assertEqual(3, len(counts))


class TestSequenceToRegex(TestCase):
    """
    Test the sequenceToRegex function.
    """

    def testEmpty(self):
        """
        The empty string should result in an empty regex.
        """
        self.assertEqual("", sequenceToRegex(""))

    def testUnambiguous(self):
        """
        An unambiguous string should result in an identical regex.
        """
        self.assertEqual("ACGT", sequenceToRegex("ACGT"))

    def testOneAmbiguous(self):
        """
        One ambiguous characters should result in the expected regex.
        """
        self.assertEqual("[AG]", sequenceToRegex("R"))

    def testTwoAmbiguous(self):
        """
        Two ambiguous characters should result in the expected regex.
        """
        self.assertEqual("[AG][ACG]", sequenceToRegex("RV"))

    def testMixed(self):
        """
        Mixed ambiguous and non-ambiguous characters should result in the
        expected regex.
        """
        self.assertEqual("A[AG]C[ACG]T", sequenceToRegex("ARCVT"))

    def testN(self):
        """
        An 'N' should result in an ACGT regex.
        """
        self.assertEqual("[ACGT]", sequenceToRegex("N"))

    def testQuestionMark(self):
        """
        A '?' should result in an ACGT regex.
        """
        self.assertEqual("[ACGT]", sequenceToRegex("?"))

    def testGap(self):
        """
        A '-' should result in an ACGT regex.
        """
        self.assertEqual("[ACGT]", sequenceToRegex("-"))

    def testWildcard(self):
        """
        An explicit wildcard should result in an ACGT regex.
        """
        self.assertEqual("[ACGT][ACGT][ACGT]", sequenceToRegex("*#!", wildcards="#*!"))

    def testUnknown(self):
        """
        An unknown character should result in a KeyError.
        """
        error = "^'5'$"
        self.assertRaisesRegex(KeyError, error, sequenceToRegex, "5")


class TestLeastAmbiguous(TestCase):
    """
    Test the leastAmbiguous function.
    """

    def testEmpty(self):
        """
        The empty string should result in a KeyError.
        """
        self.assertRaisesRegex(KeyError, "^''$", leastAmbiguous, "")

    def testUnknownNucleotides(self):
        """
        Unknown nucleotides should result in a KeyError.
        """
        self.assertRaisesRegex(KeyError, "^'123'$", leastAmbiguous, "123")

    def testDuplicationsIgnored(self):
        """
        If nucleotides are duplicated, there should be no problem.
        """
        self.assertEqual("A", leastAmbiguous("AAA"))

    def testDuplicationsDifferentCaseIgnored(self):
        """
        If nucleotides are duplicated in different cases, there should be no
        problem.
        """
        self.assertEqual("A", leastAmbiguous("AaaA"))

    def testSingleNucleotides(self):
        """
        Single nucleotides should be returned as themselves.
        """
        for base in "ACGT":
            self.assertEqual(base, leastAmbiguous(base))

    def testTwoNucleotides(self):
        """
        Two nucleotides should be handled correctly.
        """
        for bases, ambiguous in AMBIGUOUS_PAIRS:
            self.assertEqual(ambiguous, leastAmbiguous(bases))

    def testThreeNucleotides(self):
        """
        Three nucleotides should be handled correctly.
        """
        for bases, ambiguous in AMBIGUOUS_TRIPLES:
            self.assertEqual(ambiguous, leastAmbiguous(bases))

    def testFourNucleotides(self):
        """
        All four nucleotides should be handled correctly.
        """
        self.assertEqual("N", leastAmbiguous("AGCT"))

    def testFourNucleotidesOtherOrder(self):
        """
        All four nucleotides should be handled correctly when given in a
        different order.
        """
        self.assertEqual("N", leastAmbiguous("CGTA"))

    def testTuple(self):
        """
        All four nucleotides passed as a tuple should be handled correctly.
        """
        self.assertEqual("N", leastAmbiguous(tuple("AGCT")))

    def testList(self):
        """
        All four nucleotides passed as a list should be handled correctly.
        """
        self.assertEqual("N", leastAmbiguous(list("AGCT")))


class TestLeastAmbiguousFromBases(TestCase):
    """
    Test the leastAmbiguousFromCounts function.
    """

    def testNegativeCount(self):
        """
        A negative count must result in a ValueError.
        """
        self.assertRaisesRegex(
            ValueError,
            r"^Count for base 'A' is negative \(-1\)\.$",
            leastAmbiguousFromCounts,
            {"A": -1},
            0.9,
        )

    def testNegativeThreshold(self):
        """
        A negative threshold must result in a ValueError.
        """
        self.assertRaisesRegex(
            ValueError,
            r"^Threshold cannot be negative \(-0\.9\)\.$",
            leastAmbiguousFromCounts,
            {"A": 3},
            -0.9,
        )

    def testNoCounts(self):
        """
        If an empty dictionary of counts is passed, 'N' must result.
        """
        self.assertEqual("N", leastAmbiguousFromCounts({}, 0.9))

    def testAllCountsZero(self):
        """
        If the counts are all zero, 'N' must result.
        """
        self.assertEqual("N", leastAmbiguousFromCounts({"A": 0, "G": 0}, 0.9))

    def testOneBase(self):
        """
        If there is a single base, it must be returned.
        """
        for base in "ACGT":
            self.assertEqual(base, leastAmbiguousFromCounts({base: 3}, 0.9))

    def testTwoEqual(self):
        """
        If there are two bases with equal counts, the expected ambiguous code
        must be returned.
        """
        for bases, ambiguous in AMBIGUOUS_PAIRS:
            counts = dict.fromkeys(bases, 1)
            self.assertEqual(ambiguous, leastAmbiguousFromCounts(counts, 0.9))

    def testThreeEqual(self):
        """
        If there are three bases with equal counts, the expected ambiguous code
        must be returned.
        """
        for bases, ambiguous in AMBIGUOUS_TRIPLES:
            counts = dict.fromkeys(bases, 1)
            self.assertEqual(ambiguous, leastAmbiguousFromCounts(counts, 0.9))

    def testFourNucleotides(self):
        """
        If all four nucleotides have equal counts, 'N' must be returned.
        """
        counts = dict.fromkeys("AGCT", 1)
        self.assertEqual("N", leastAmbiguousFromCounts(counts, 0.9))

    def testOneOfTwoOverThreshold(self):
        """
        If one base of two is over the threshold, it must be returned.
        """
        for bases, _ in AMBIGUOUS_PAIRS:
            counts = {bases[0]: 3, bases[1]: 1}
            self.assertEqual(bases[0], leastAmbiguousFromCounts(counts, 0.5))

    def testOneOfTwoBelowThreshold(self):
        """
        If one base is dominant but not over the threshold, the ambiguous code
        must be returned.
        """
        for bases, ambiguous in AMBIGUOUS_PAIRS:
            counts = {bases[0]: 3, bases[1]: 1}
            self.assertEqual(ambiguous, leastAmbiguousFromCounts(counts, 0.9))

    def testOneOfThreeOverThreshold(self):
        """
        If one base of three is over the threshold, it must be returned.
        """
        for bases, _ in AMBIGUOUS_TRIPLES:
            counts = {bases[0]: 3, bases[1]: 1, bases[2]: 1}
            self.assertEqual(bases[0], leastAmbiguousFromCounts(counts, 0.5))

    def testTwoOfThreeOverThreshold(self):
        """
        If two bases of three are over the threshold, the code for those two
        must be returned.
        """
        for bases, _ in AMBIGUOUS_TRIPLES:
            counts = {bases[0]: 4, bases[1]: 4, bases[2]: 2}
            self.assertEqual(
                leastAmbiguous(bases[:2]), leastAmbiguousFromCounts(counts, 0.8)
            )

    def testGeneiousExamplesNoTie(self):
        """
        Test the no-tied counts example from
        https://assets.geneious.com/manual/2020.1/static/GeneiousManualse43.html
        """
        counts = {"A": 6, "G": 3, "T": 1}
        self.assertEqual("A", leastAmbiguousFromCounts(counts, 0.4))
        self.assertEqual("R", leastAmbiguousFromCounts(counts, 0.7))
        self.assertEqual("D", leastAmbiguousFromCounts(counts, 0.95))

    def testGeneiousExamplesTie(self):
        """
        Test the tied counts example from
        https://assets.geneious.com/manual/2020.1/static/GeneiousManualse43.html
        """
        counts = {"A": 6, "G": 2, "T": 2}
        self.assertEqual("A", leastAmbiguousFromCounts(counts, 0.4))
        self.assertEqual("D", leastAmbiguousFromCounts(counts, 0.7))
        self.assertEqual("D", leastAmbiguousFromCounts(counts, 0.95))


class TestBases(TestCase):
    """
    Test the Bases class.
    """

    def testEmptyIsFalse(self):
        """
        An empty Bases instance must be considered False.
        """
        self.assertFalse(Bases())

    def testEmptyHasLengthZero(self):
        """
        An empty Bases instance must have length zero.
        """
        self.assertEqual(0, len(Bases()))

    def testLengthOneIsTrue(self):
        """
        If we append a (base, quality) pair, the instance must be considered
        True.
        """
        b = Bases().append("G", 30)
        self.assertTrue(b)

    def testLengthOne(self):
        """
        If we append a (base, quality) pair, the length must be one.
        """
        b = Bases().append("G", 30)
        self.assertEqual(1, len(b))

    def testGetitem(self):
        """
        __getitem__ must work as expected.
        """
        b = Bases().append("G", 30)
        self.assertEqual(30, b["G"])

    def testAdd(self):
        """
        Addition must work as expected.
        """
        b1 = Bases().append("G", 30)
        b2 = Bases().append("G", 20)
        b3 = b1 + b2

        self.assertEqual(1, len(b1))
        self.assertEqual(1, len(b2))
        self.assertEqual(2, len(b3))
        self.assertEqual(50, b3.counts["G"])

    def testIAdd(self):
        """
        In-place addition must work as expected.
        """
        b1 = Bases().append("G", 30)
        b2 = Bases().append("G", 20)
        b2 += b1

        self.assertEqual(1, len(b1))
        self.assertEqual(2, len(b2))
        self.assertEqual(50, b2.counts["G"])

    def testConsensusNoReads(self):
        """
        The consensus method must return the no coverage string if there are
        no reads.
        """
        b = Bases()
        self.assertEqual("Z", b.consensus(0.8, 1, "L", "Z"))

    def testConsensusLowReads(self):
        """
        The consensus method must return the low coverage string if there are
        no reads.
        """
        b = Bases().append("G", 20)
        self.assertEqual("L", b.consensus(0.8, 2, "L", "Z"))

    def testConsensusOneBase(self):
        """
        If there is a single base, it must be returned.
        """
        for base in "ACGT":
            b = Bases().append(base, 20)
            self.assertEqual(base, b.consensus(0.8, 1, "L", "Z"))

    def testConsensusTwoEqual(self):
        """
        If there are two bases with equal counts, the expected ambiguous code
        must be returned.
        """
        for bases, ambiguous in AMBIGUOUS_PAIRS:
            b = Bases().append(bases[0], 20).append(bases[1], 20)
            self.assertEqual(ambiguous, b.consensus(0.8, 1, "L", "Z"))

    def testConsensusThreeEqual(self):
        """
        If there are three bases with equal counts, the expected ambiguous code
        must be returned.
        """
        for bases, ambiguous in AMBIGUOUS_TRIPLES:
            b = Bases().append(bases[0], 20).append(bases[1], 20).append(bases[2], 20)
            self.assertEqual(ambiguous, b.consensus(0.8, 1, "L", "Z"))

    def testConsensusFourNucleotides(self):
        """
        If all four nucleotides have equal counts, 'N' must be returned.
        """
        b = Bases()
        for base in "ACGT":
            b.append(base, 20)
        self.assertEqual("N", b.consensus(0.8, 1, "L", "Z"))


class TestAACompatible(TestCase):
    """
    Test the aaCompatible function.
    """

    def testUnambiguous(self):
        """
        When given an unambiguous string equal to a codon of the passed
        amino acid, True must be returned.
        """
        for aa, codons in CODONS.items():
            for codon in codons:
                self.assertTrue(aaCompatible(codon, aa))

    def testAll(self):
        """
        Test that all amino acids are compatible with "NNN".
        """
        for aa in CODONS:
            self.assertTrue(aaCompatible("NNN", aa))

    def testGap(self):
        """
        Test that False is returned if we pass a codon with a gap.
        """
        self.assertFalse(aaCompatible("NN-", "V"))

    def testFirstAmbiguousAndCompatible(self):
        """
        When given an string whose first character is ambiguous
        but consitent with the first nucleotide of one of the codons
        of the passed amino acid and equal in the second and third,
        True must be returned.
        """
        for ambiguous in "RSKVDBN":
            self.assertTrue(aaCompatible(f"{ambiguous}AC", "D"))

    def testFirstAmbiguousAndIncompatible(self):
        """
        When given an string whose first character is ambiguous
        but inconsitent with the first nucleotide of all of the codons
        of the passed amino acid, False must be returned.
        """
        for ambiguous in "MWYH":
            self.assertFalse(aaCompatible(f"{ambiguous}AC", "D"))

    def testSecondAmbiguousAndCompatible(self):
        """
        When given an string whose second character is ambiguous
        but consitent with the second nucleotide of one of the codons
        of the passed amino acid and equal in the first and third,
        True must be returned.
        """
        for ambiguous in "RSKVDBN":
            self.assertTrue(aaCompatible(f"A{ambiguous}T", "S"))

    def testSecondAmbiguousAndIncompatible(self):
        """
        When given an string whose second character is ambiguous
        but inconsitent with the second nucleotide of all of the codons
        of the passed amino acid, False must be returned.
        """
        for ambiguous in "MWYH":
            self.assertFalse(aaCompatible(f"A{ambiguous}T", "S"))

    def testThirdAmbiguousAndCompatible(self):
        """
        When given an string whose third character is ambiguous
        but consitent with the third nucleotide of one of the codons
        of the passed amino acid and equal in the first and second,
        True must be returned.
        """
        for ambiguous in "MWSKYVHDBN":
            self.assertTrue(aaCompatible(f"AA{ambiguous}", "N"))

    def testThirdAmbiguousAndIncompatible(self):
        """
        When given an string whose third character is ambiguous
        but inconsitent with the third nucleotide of all of the codons
        of the passed amino acid, False must be returned.
        """
        for ambiguous in "R":
            self.assertFalse(aaCompatible(f"AA{ambiguous}", "N"))


class TestCompatibleAAs(TestCase):
    """
    Test the compatibleAAs function.
    """

    def testUnambiguous(self):
        """
        When given an unambiguous string equal to a codon of an AA, that
        AA must be returned.
        """
        self.assertEqual({"D"}, compatibleAAs("GAC"))

    def testAll(self):
        """
        When given "NNN", all AA (and "*") must be returned.
        """
        self.assertEqual(set(CODONS) | {"*"}, compatibleAAs("NNN"))

    def testAlanine(self):
        """
        When given a string compatible with all the codons of alanine,
        "A" must be returned.
        """
        self.assertEqual({"A"}, compatibleAAs("GCN"))

    def testCytosineAndTryptophan(self):
        """
        When given a string compatible with Cytosine and Tryptophan
        (but not with the TGA stop codon), "C" and "W" must be returned.
        """
        self.assertEqual({"C", "W"}, compatibleAAs("TGB"))

    def testCytosineTryptophanAndStop(self):
        """
        When given a string compatible with Cytosine and Tryptophan
        and with the TGA stop codon, "C", "W" and "*" must be returned.
        """
        self.assertEqual({"C", "W", "*"}, compatibleAAs("TGN"))

    def testNoInitialT(self):
        """
        When given a string compatible with all amino acids except those
        whose codons start with "A" or "C", ... must be returned.
        """
        self.assertEqual(set("ACDEFGLSVWY*"), compatibleAAs("KNN"))


class TestTranslatable(TestCase):
    """
    Test the translatable function.
    """

    def test_translatable_valid_single_codon(self):
        """Test that a single valid codon returns True."""
        assert translatable("ATG") is True

    def test_translatable_valid_multiple_codons(self):
        """Test that multiple valid codons return True."""
        assert translatable("ATGATGATG") is True
        assert translatable("ATGCGATAA") is True

    def test_translatable_not_divisible_by_three(self):
        """Test that sequences not divisible by 3 return False."""
        assert translatable("AT") is False
        assert translatable("ATGA") is False
        assert translatable("ATGATGAT") is False

    def test_translatable_with_gap(self):
        """Test that sequences containing gaps return False."""
        assert translatable("ATG-TG") is False
        assert translatable("---") is False
        assert translatable("ATGATG---") is False

    def test_translatable_invalid_nucleotide(self):
        """Test that sequences with invalid nucleotides return False."""
        assert translatable("ATGATGATX") is False
        assert translatable("ATGNTGATG") is False
        assert translatable("XXX") is False

    def test_translatable_empty_string(self):
        """Test that an empty string returns True (0 codons)."""
        assert translatable("") is True

    def test_translatable_lowercase(self):
        """Test that lowercase sequences work correctly."""
        assert translatable("atgatgatg") is True
        assert translatable("atga") is False
