#!/usr/bin/env python3

# Unit tests for decopy
# Copyright: 2016, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

import json
import unittest
from pathlib import Path

try:
    import regex as rex
except ImportError:
    import re as rex  # type: ignore[no-redef]

from decopy.licensing import license_parsers, licenses
from decopy.licensing.holders import (
    CopyrightHolders,
    iter_lines,
    parse_copyright,
    parse_holders,
)


class TestMatchers(unittest.TestCase):
    def test_clean_comments(self):
        test = (
            "\n"
            "c Fortran\n"
            "C Idem\n"
            "  /* C // C++ */\n"
            ";; Lisp\n"
            "  <!-- HTML -->\n"
            "dnl m4\n"
            "  * continuation *\n"
            "\n"
        )

        result = licenses.clean_comments(test)
        self.assertEqual(result, " Fortran Idem C C++ Lisp HTML m4 continuation ")

    def test_parse_copyright(self):
        line = "Copyright: Foo Bar <foo@example.com>"
        continuation = None
        copyrights, continuation = parse_copyright(line, continuation)
        assert copyrights is not None
        self.assertEqual(len(copyrights), 1)
        self.assertEqual(copyrights[0], "Foo Bar <foo@example.com>")
        line = "           2050- Baz <baz@example.com>"
        copyrights, continuation = parse_copyright(line, continuation)
        assert copyrights is not None
        self.assertEqual(len(copyrights), 1)
        self.assertEqual(copyrights[0], "2050- Baz <baz@example.com>")
        line = "Copyright (c) William Boo, (c) La Momia"
        copyrights, _continuation = parse_copyright(line, continuation)
        assert copyrights is not None
        self.assertEqual(len(copyrights), 2)
        self.assertEqual(copyrights[0], "William Boo")
        self.assertEqual(copyrights[1], "La Momia")

    def test_iter_lines_leading_newline(self):
        # Regression: end <= 0 broke when text starts with '\n' (end == 0 is a
        # valid position, not a sentinel; only end == -1 means "not found").
        lines = list(iter_lines("\nhello\nworld"))
        self.assertEqual(lines[0][0], "")  # empty line before first \n
        self.assertEqual(lines[1][0], "hello")
        self.assertEqual(lines[2][0], "world")

    def test_no_false_positive_from_unlicense_text(self):
        # Regression: "copyright laws, the author or authors of this" must not
        # produce a holder - the indicator fires on "copyright laws" because a
        # space satisfies [:\s]+, and the plural "laws" slipped past the
        # r"^\s*law[.:]?\b" post-ignore (word boundary fails between 'w' and 's').
        text = (
            " * In jurisdictions that recognize copyright laws, the author or authors of\n"  # noqa: E501
            " * this software dedicate any and all copyright and related and neighboring\n"  # noqa: E501
            " * rights to this software to the public domain worldwide.\n"
        )
        holders = parse_holders(text)
        self.assertEqual(holders, [])

    def test_python_unicode_string_assignment_in_copyright(self):
        # Regression: patch hunk header "@@ ... @@ copyright = u'YEAR NAME'"
        # must not produce "= u" as a copyright holder.
        holders = parse_holders(
            "@@ -54,7 +54,7 @@ copyright = u'2013, 2014, 2015 CERN'"
        )
        names = [h.name for h in holders]
        self.assertNotIn("= u", names)
        self.assertEqual(len(holders), 1)
        self.assertEqual(holders[0].name, "CERN")
        self.assertEqual(str(holders[0].years), "2013-2015")

    def test_markdown_reference_link_in_copyright(self):
        # Regression: [Aptible][aptible] must produce holder "Aptible", not
        # "Aptible][aptible" (strip() removes outer brackets but not the inner ][).
        holders = parse_holders(
            "Copyright (c) 2019 [Aptible][aptible]. All rights reserved."
        )
        self.assertEqual(len(holders), 1)
        self.assertEqual(holders[0].name, "Aptible")
        self.assertEqual(str(holders[0].years), "2019")

    def test_markdown_inline_link_in_copyright(self):
        # [Aptible](https://aptible.com) should produce holder "Aptible".
        holders = parse_holders(
            "Copyright (c) 2019 [Aptible](https://aptible.com). All rights reserved."
        )
        self.assertEqual(len(holders), 1)
        self.assertEqual(holders[0].name, "Aptible")
        self.assertEqual(str(holders[0].years), "2019")

    def test_code_fragment_with_punctuation_not_a_holder(self):
        # ruff flake8_copyright/settings.rs: a Rust statement mentioning the word
        # COPYRIGHT must not yield a code-fragment holder like "LazyLock<Regex> =".
        holders = parse_holders("pub static COPYRIGHT: LazyLock<Regex> =")
        self.assertEqual([h.name for h in holders], [])

    def test_assignment_fragment_not_a_holder(self):
        # ruff A001 snapshot fixture line "copyright: 'annotation' = 2".
        holders = parse_holders("copyright: 'annotation' = 2")
        self.assertFalse([h.name for h in holders if "=" in h.name])

    def test_format_string_fragment_not_a_holder(self):
        # ruff F504 snapshot fixture; code punctuation must disqualify it.
        holders = parse_holders(
            '"%(a)s %(c)s" % {a: "?", "b": "!"}  # Copyright F504 ("b" not used)'
        )
        self.assertFalse([h.name for h in holders if set('{}"#') & set(h.name)])

    def test_lambda_with_copyright_param_not_a_holder(self):
        # ruff A006 fixture: "lambda print, copyright: print" is code; the
        # lambda-with-copyright-parameter line is ignored, not parsed as a holder.
        holders = parse_holders("lambda print, copyright: print")
        self.assertEqual(holders, [])

    def test_same_name_holders_merge_years(self):
        # The two typeshed PSF notices (one with a trailing ", and were" clause
        # that F3 trims) collapse to one holder with a merged year range.
        text = (
            "Copyright 2003 Python Software Foundation\n"
            "so portions are Copyright (C) 2001 Python Software Foundation, and were"
        )
        holders = CopyrightHolders()
        holders.extend(parse_holders(text))
        self.assertEqual(
            list(holders.sorted_members()),
            ["2001-2003, Python Software Foundation"],
        )

    def test_all_rights_reserved_ends_holder(self):
        # ruff typeshed .pyi: "(c) Copyright CNRI, All Rights Reserved. NO
        # WARRANTY." - "All Rights Reserved" ends the holder; the trailing
        # boilerplate ("NO WARRANTY") is not part of the name.
        holders = parse_holders("(c) Copyright CNRI, All Rights Reserved. NO WARRANTY.")
        self.assertEqual([h.name for h in holders], ["CNRI"])

    def test_lowercase_nickname_holder_is_kept(self):
        # Case is not a signal: a lowercase nickname is a valid holder, with or
        # without a year.
        self.assertEqual(
            [h.name for h in parse_holders("Copyright 2020 redis")], ["redis"]
        )
        self.assertEqual([h.name for h in parse_holders("Copyright dhh")], ["dhh"])

    def test_present_in_comma_year_range_dropped(self):
        # ruff jemalloc COPYING: "2009, present, Facebook, Inc" - "present" is
        # not a year and must not enter the holder name.
        holders = parse_holders("Copyright 2009, present, Facebook, Inc.")
        self.assertEqual(
            [(h.name, str(h.years)) for h in holders], [("Facebook, Inc", "2009")]
        )

    def test_present_in_dash_year_range_dropped(self):
        # ruff jemalloc COPYING: "2002-present Jason Evans".
        holders = parse_holders(
            "Copyright (C) 2002-present Jason Evans <jasone@canonware.com>"
        )
        self.assertEqual(
            [(h.name, str(h.years)) for h in holders], [("Jason Evans", "2002")]
        )

    def test_normal_year_range_unchanged(self):
        # Guard: a real numeric range must be untouched by the present/current rule.
        holders = parse_holders("Copyright 2009-2014 Florent Xicluna")
        self.assertEqual(
            [(h.name, str(h.years)) for h in holders],
            [("Florent Xicluna", "2009-2014")],
        )

    def test_trailing_prose_clause_trimmed(self):
        # ruff typeshed wsgiref/headers.pyi: the copyright line runs into prose
        # "..., and were [contributed by ...]"; the dangling clause must be cut.
        holders = parse_holders(
            "so portions are Copyright (C) 2001 Python Software Foundation, and were"
        )
        self.assertEqual(
            [(h.name, str(h.years)) for h in holders],
            [("Python Software Foundation", "2001")],
        )

    def test_and_affiliates_holder_is_kept(self):
        # Guard: "and affiliates" is a legitimate org tail (no verb), keep it.
        holders = parse_holders("Copyright 2024 Meta Platforms, Inc. and affiliates")
        self.assertEqual(
            [h.name for h in holders], ["Meta Platforms, Inc. and affiliates"]
        )

    def test_coholders_joined_by_comma_and_are_split(self):
        # "X, and Y Corp" is two holders sharing the year, not one combined name.
        holders = parse_holders("Copyright (c) 2019 Foo, and Bar Corp")
        self.assertEqual(
            sorted((h.name, str(h.years)) for h in holders),
            [("Bar Corp", "2019"), ("Foo", "2019")],
        )

    def test_no_comma_and_is_not_split(self):
        # Guard: " and " without a comma must not over-split into a lone
        # trailing name; only a comma-led "X, and Y" is split.
        holders = parse_holders(
            "Copyright 2006 Mutsuo Saito, Makoto Matsumoto and Hiroshima"
        )
        self.assertNotIn("Hiroshima", [h.name for h in holders])

    def test_parse_holders(self):
        text = " /* Copyright Foo Bar\n  *  Baz */\n // Copyright (c) Lorem, (c) Ipsum"
        holders = parse_holders(text)
        self.assertEqual(
            [holder.person for holder in holders],
            ["Foo Bar", "Baz", "Lorem", "Ipsum"],
        )

    def test_parse_holders_multiline(self):
        # Positive
        text = (
            " /* This software is Copyright:                 *\n"
            "  *  2018, Testy McTestface <testy@example.com> *\n"
            "  *  2017, Boaty McBoatface <boaty@example.com> */"
        )
        holders = parse_holders(text)
        self.assertEqual(
            [(str(h.years), h.name, h.email) for h in holders],
            [
                ("2018", "Testy McTestface", "testy@example.com"),
                ("2017", "Boaty McBoatface", "boaty@example.com"),
            ],
        )
        # Negative
        text = (
            " /* This software is free software:             *\n"
            "  *  2018, Testy McTestface <testy@example.com> *\n"
            "  *  2017, Boaty McBoatface <boaty@example.com> */"
        )
        holders = parse_holders(text)
        self.assertEqual([(str(h.years), h.name, h.email) for h in holders], [])

    def test_parse_holders_multiline_years(self):
        # Positive
        text = (
            " /* The software and docs is Copyright 1984, 1985, 1986 *\n"
            "  * 1987 Testy McTestface <testy@example.com>           *\n"
            "  * 1988 Boaty McBoatface <boaty@example.com>           */"
        )
        holders = parse_holders(text)
        self.assertEqual(
            [(str(h.years), h.name, h.email) for h in holders],
            [
                ("1984-1987", "Testy McTestface", "testy@example.com"),
                ("1988", "Boaty McBoatface", "boaty@example.com"),
            ],
        )

        # Negative
        text = (
            " /* The software and docs is Copyright 1984, 1985, 1986 *\n"
            "  *                                                     *\n"
            "  * 1987 Testy McTestface <testy@example.com>           *\n"
            "  * 1988 Boaty McBoatface <boaty@example.com>           */"
        )
        holders = parse_holders(text)
        self.assertEqual(
            [(str(h.years), h.name, h.email) for h in holders],
            [
                ("1987", "Testy McTestface", "testy@example.com"),
                ("1988", "Boaty McBoatface", "boaty@example.com"),
            ],
        )

    def _check_license_match(self, license_, matcher, expected, ignore=None):
        for key in ("licenseText", "standardLicenseHeader", "licenseExceptionText"):
            if key not in license_ or not license_[key]:
                continue
            if ignore and key in ignore:
                continue
            text = license_[key]
            result = matcher(text)
            self.assertEqual(result, expected, msg=f"with key={key}")

    def _check_license_match_re(self, license_, matcher, expected):
        for key in ("licenseText", "standardLicenseHeader", "licenseExceptionText"):
            if key not in license_ or not license_[key]:
                continue
            text = license_[key]
            result = matcher(text)
            matches = rex.match(expected, result)
            self.assertTrue(
                matches,
                msg=f"with key={key}, result ({result}) doesn't match with "
                f"the expected value ({expected})\n{text}",
            )

    @staticmethod
    def _find_licenses_matcher(text):
        found = licenses.find_licenses(licenses.clean_comments(text))
        return " or ".join(sorted(found))

    def _open_license_and_check(self, filename, matcher, expected, ignore=None):
        with (Path("tests/testdata/licenses") / filename).open() as fp:
            license_ = json.load(fp)
        return self._check_license_match(license_, matcher, expected, ignore)

    def _open_exception_and_check(self, filename, matcher, expected, ignore=None):
        with (Path("tests/testdata/exceptions") / filename).open() as fp:
            license_ = json.load(fp)
        return self._check_license_match(license_, matcher, expected, ignore)

    def _open_license_and_check_re(self, filename, matcher, expected):
        with (Path("tests/testdata/licenses") / filename).open() as fp:
            license_ = json.load(fp)
        return self._check_license_match_re(license_, matcher, expected)

    def test_spdx_a(self):
        """find_licenses() on every reviewed section-A licence (coverage table)."""
        self._open_license_and_check(
            "AAL.json",
            self._find_licenses_matcher,
            "AAL",
        )
        self._open_license_and_check(
            "ADSL.json",
            self._find_licenses_matcher,
            "ADSL",
        )
        self._open_license_and_check(
            "AFL-1.1.json",
            self._find_licenses_matcher,
            "Academic-1.1",
        )
        self._open_license_and_check(
            "AFL-1.2.json",
            self._find_licenses_matcher,
            "Academic-1.2",
        )
        self._open_license_and_check(
            "AFL-2.0.json",
            self._find_licenses_matcher,
            "Academic-2",
        )
        self._open_license_and_check(
            "AFL-2.1.json",
            self._find_licenses_matcher,
            "Academic-2.1",
        )
        self._open_license_and_check(
            "AFL-3.0.json",
            self._find_licenses_matcher,
            "Academic-3",
        )
        self._open_license_and_check(
            "AGPL-1.0-only.json",
            self._find_licenses_matcher,
            "AGPL-1",
        )
        self._open_license_and_check(
            "AGPL-1.0-or-later.json",
            self._find_licenses_matcher,
            "AGPL-1",
        )
        self._open_license_and_check(
            "AGPL-1.0.json",
            self._find_licenses_matcher,
            "AGPL-1",
        )
        self._open_license_and_check(
            "AGPL-3.0-only.json",
            self._find_licenses_matcher,
            "AGPL-3",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "AGPL-3.0-or-later.json",
            self._find_licenses_matcher,
            "AGPL-3+",
        )
        self._open_license_and_check(
            "AGPL-3.0.json",
            self._find_licenses_matcher,
            "AGPL-3",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "AMDPLPA.json",
            self._find_licenses_matcher,
            "AMDPLPA",
        )
        self._open_license_and_check(
            "AML.json",
            self._find_licenses_matcher,
            "AML",
        )
        self._open_license_and_check(
            "AMPAS.json",
            self._find_licenses_matcher,
            "BSD-2-clause-AMPAS",
        )
        self._open_license_and_check(
            "ANTLR-PD-fallback.json",
            self._find_licenses_matcher,
            "ANTLR-PD",
        )
        self._open_license_and_check(
            "ANTLR-PD.json",
            self._find_licenses_matcher,
            "ANTLR-PD",
        )
        self._open_license_and_check(
            "APAFML.json",
            self._find_licenses_matcher,
            "APAFML",
        )
        self._open_license_and_check(
            "APL-1.0.json",
            self._find_licenses_matcher,
            "APL-1",
        )
        self._open_license_and_check(
            "APSL-1.0.json",
            self._find_licenses_matcher,
            "APSL-1",
        )
        self._open_license_and_check(
            "APSL-1.1.json",
            self._find_licenses_matcher,
            "APSL-1.1",
        )
        self._open_license_and_check(
            "APSL-1.2.json",
            self._find_licenses_matcher,
            "APSL-1.2",
        )
        self._open_license_and_check(
            "APSL-2.0.json",
            self._find_licenses_matcher,
            "APSL-2",
        )
        self._open_license_and_check(
            "Abstyles.json",
            self._find_licenses_matcher,
            "Abstyles",
        )
        self._open_license_and_check(
            "Adobe-2006.json",
            self._find_licenses_matcher,
            "Adobe-2006",
        )
        self._open_license_and_check(
            "Adobe-Glyph.json",
            self._find_licenses_matcher,
            "Adobe-Glyph",
        )
        self._open_license_and_check(
            "Afmparse.json",
            self._find_licenses_matcher,
            "Afmparse",
        )
        self._open_license_and_check(
            "Aladdin.json",
            self._find_licenses_matcher,
            "Aladdin",
        )
        self._open_license_and_check(
            "Apache-1.0.json",
            self._find_licenses_matcher,
            "Apache-1",
        )
        self._open_license_and_check(
            "Apache-1.1.json",
            self._find_licenses_matcher,
            "Apache-1.1",
        )
        self._open_license_and_check(
            "Apache-2.0.json",
            self._find_licenses_matcher,
            "Apache-2",
        )
        self._open_license_and_check(
            "Artistic-1.0-Perl.json",
            self._find_licenses_matcher,
            "Artistic-1-Perl",
        )
        self._open_license_and_check(
            "Artistic-1.0-cl8.json",
            self._find_licenses_matcher,
            "Artistic-1-cl8",
        )
        self._open_license_and_check(
            "Artistic-1.0.json",
            self._find_licenses_matcher,
            "Artistic-1",
        )
        self._open_license_and_check(
            "Artistic-2.0.json",
            self._find_licenses_matcher,
            "Artistic-2",
        )
        self._open_license_and_check(
            "Artistic-dist.json",
            self._find_licenses_matcher,
            "Artistic-dist",
        )

    def test_spdx_b(self):
        """find_licenses() on every reviewed section-B licence (coverage table)."""
        self._open_license_and_check(
            "BSD-1-Clause.json",
            self._find_licenses_matcher,
            "BSD-1-clause",
        )
        self._open_license_and_check(
            "BSD-2-Clause-Darwin.json",
            self._find_licenses_matcher,
            "BSD-2-clause-Darwin",
        )
        self._open_license_and_check(
            "BSD-2-Clause-FreeBSD.json",
            self._find_licenses_matcher,
            "BSD-2-clause-FreeBSD",
        )
        self._open_license_and_check(
            "BSD-2-Clause-NetBSD.json",
            self._find_licenses_matcher,
            "BSD-2-clause-NetBSD",
        )
        self._open_license_and_check(
            "BSD-2-Clause-Patent.json",
            self._find_licenses_matcher,
            "BSD-2-clause-patent",
        )
        self._open_license_and_check(
            "BSD-2-Clause-Views.json",
            self._find_licenses_matcher,
            "BSD-2-clause-views",
        )
        self._open_license_and_check(
            "BSD-2-Clause-first-lines.json",
            self._find_licenses_matcher,
            "BSD-2-clause-first-lines",
        )
        self._open_license_and_check(
            "BSD-2-Clause.json",
            self._find_licenses_matcher,
            "BSD-2-clause",
        )
        self._open_license_and_check(
            "BSD-3-Clause-Attribution.json",
            self._find_licenses_matcher,
            "BSD-3-clause-attribution",
        )
        self._open_license_and_check(
            "BSD-3-Clause-Clear.json",
            self._find_licenses_matcher,
            "BSD-3-clause-clear",
        )
        self._open_license_and_check(
            "BSD-3-Clause-HP.json",
            self._find_licenses_matcher,
            "BSD-3-clause-HP",
        )
        self._open_license_and_check(
            "BSD-3-Clause-LBNL.json",
            self._find_licenses_matcher,
            "BSD-3-clause-LBNL",
        )
        self._open_license_and_check(
            "BSD-3-Clause-No-Military-License.json",
            self._find_licenses_matcher,
            "BSD-3-clause-no-military",
        )
        self._open_license_and_check(
            "BSD-3-Clause-No-Nuclear-License-2014.json",
            self._find_licenses_matcher,
            "BSD-3-clause-no-nuclear",
        )
        self._open_license_and_check(
            "BSD-3-Clause-Open-MPI.json",
            self._find_licenses_matcher,
            "BSD-3-clause-Open-MPI",
        )
        self._open_license_and_check(
            "BSD-3-Clause-Tso.json",
            self._find_licenses_matcher,
            "BSD-3-clause-Tso",
        )
        self._open_license_and_check(
            "BSD-3-Clause-acpica.json",
            self._find_licenses_matcher,
            "BSD-3-clause-acpica",
        )
        self._open_license_and_check(
            "BSD-3-Clause-flex.json",
            self._find_licenses_matcher,
            "BSD-3-clause-flex",
        )
        self._open_license_and_check(
            "BSD-3-Clause.json",
            self._find_licenses_matcher,
            "BSD-3-clause",
        )
        self._open_license_and_check(
            "BSD-4-Clause-Shortened.json",
            self._find_licenses_matcher,
            "BSD-4-clause-shortened",
        )
        self._open_license_and_check(
            "BSD-4-Clause-UC.json",
            self._find_licenses_matcher,
            "BSD-4-clause-UC",
        )
        self._open_license_and_check(
            "BSD-4-Clause.json",
            self._find_licenses_matcher,
            "BSD-4-clause",
        )
        self._open_license_and_check(
            "BSD-Attribution-HPND-disclaimer.json",
            self._find_licenses_matcher,
            "BSD-Attribution-HPND-disclaimer",
        )
        self._open_license_and_check(
            "BSD-Inferno-Nettverk.json",
            self._find_licenses_matcher,
            "BSD-Inferno-Nettverk",
        )
        self._open_license_and_check(
            "BSD-Mark-Modifications.json",
            self._find_licenses_matcher,
            "BSD-Mark-Modifications",
        )
        self._open_license_and_check(
            "BSD-Protection.json",
            self._find_licenses_matcher,
            "BSD-protection",
        )
        self._open_license_and_check(
            "BSD-Source-Code.json",
            self._find_licenses_matcher,
            "BSD-source-code",
        )
        self._open_license_and_check(
            "BSD-Source-beginning-file.json",
            self._find_licenses_matcher,
            "BSD-source-beginning-file",
        )
        self._open_license_and_check(
            "BSD-Systemics-W3Works.json",
            self._find_licenses_matcher,
            "BSD-Systemics",
        )
        self._open_license_and_check(
            "BSD-Systemics.json",
            self._find_licenses_matcher,
            "BSD-Systemics",
        )
        self._open_license_and_check(
            "BSL-1.0.json",
            self._find_licenses_matcher,
            "BSL-1",
        )
        self._open_license_and_check(
            "Bahyph.json",
            self._find_licenses_matcher,
            "Bahyph",
        )
        self._open_license_and_check(
            "Barr.json",
            self._find_licenses_matcher,
            "Barr",
        )
        self._open_license_and_check(
            "Beerware.json",
            self._find_licenses_matcher,
            "Beerware",
        )
        self._open_license_and_check(
            "BitTorrent-1.0.json",
            self._find_licenses_matcher,
            "BitTorrent-1",
        )
        self._open_license_and_check(
            "BitTorrent-1.1.json",
            self._find_licenses_matcher,
            "BitTorrent-1.1",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "Borceux.json",
            self._find_licenses_matcher,
            "Borceux",
        )

    def test_spdx_c(self):
        """find_licenses() on every reviewed section-C licence (coverage table)."""
        self._open_license_and_check(
            "CAL-1.0-Combined-Work-Exception.json",
            self._find_licenses_matcher,
            "CAL-1",
        )
        self._open_license_and_check(
            "CAL-1.0.json",
            self._find_licenses_matcher,
            "CAL-1",
        )
        self._open_license_and_check(
            "CATOSL-1.1.json",
            self._find_licenses_matcher,
            "CATOSL-1.1",
        )
        self._open_license_and_check(
            "CC-BY-2.0.json",
            self._find_licenses_matcher,
            "CC-BY-2",
        )
        self._open_license_and_check(
            "CC-BY-2.5.json",
            self._find_licenses_matcher,
            "CC-BY-2.5",
        )
        self._open_license_and_check(
            "CC-BY-3.0-US.json",
            self._find_licenses_matcher,
            "CC-BY-3-US",
        )
        self._open_license_and_check(
            "CC-BY-3.0.json",
            self._find_licenses_matcher,
            "CC-BY-3",
        )
        self._open_license_and_check(
            "CC-BY-NC-2.0.json",
            self._find_licenses_matcher,
            "CC-BY-NC-2",
        )
        self._open_license_and_check(
            "CC-BY-NC-2.5.json",
            self._find_licenses_matcher,
            "CC-BY-NC-2.5",
        )
        self._open_license_and_check(
            "CC-BY-NC-3.0.json",
            self._find_licenses_matcher,
            "CC-BY-NC-3",
        )
        self._open_license_and_check(
            "CC-BY-NC-ND-2.0.json",
            self._find_licenses_matcher,
            "CC-BY-NC-ND-2",
        )
        self._open_license_and_check(
            "CC-BY-NC-ND-2.5.json",
            self._find_licenses_matcher,
            "CC-BY-NC-ND-2.5",
        )
        self._open_license_and_check(
            "CC-BY-NC-ND-3.0.json",
            self._find_licenses_matcher,
            "CC-BY-NC-ND-3",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-2.0.json",
            self._find_licenses_matcher,
            "CC-BY-NC-SA-2",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-2.5.json",
            self._find_licenses_matcher,
            "CC-BY-NC-SA-2.5",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-3.0.json",
            self._find_licenses_matcher,
            "CC-BY-NC-SA-3",
        )
        self._open_license_and_check(
            "CC-BY-ND-2.0.json",
            self._find_licenses_matcher,
            "CC-BY-ND-2",
        )
        self._open_license_and_check(
            "CC-BY-ND-2.5.json",
            self._find_licenses_matcher,
            "CC-BY-ND-2.5",
        )
        self._open_license_and_check(
            "CC-BY-ND-3.0.json",
            self._find_licenses_matcher,
            "CC-BY-ND-3",
        )
        self._open_license_and_check(
            "CC-BY-SA-2.0.json",
            self._find_licenses_matcher,
            "CC-BY-SA-2",
        )
        self._open_license_and_check(
            "CC-BY-SA-2.5.json",
            self._find_licenses_matcher,
            "CC-BY-SA-2.5",
        )
        self._open_license_and_check(
            "CC-BY-SA-3.0.json",
            self._find_licenses_matcher,
            "CC-BY-SA-3",
        )
        self._open_license_and_check(
            "CC0-1.0.json",
            self._find_licenses_matcher,
            "CC0",
        )
        self._open_license_and_check(
            "CDDL-1.0.json",
            self._find_licenses_matcher,
            "CDDL-1",
        )
        self._open_license_and_check(
            "CDDL-1.1.json",
            self._find_licenses_matcher,
            "CDDL-1.1",
        )
        self._open_license_and_check(
            "CECILL-1.0.json",
            self._find_licenses_matcher,
            "CeCILL-1",
        )
        self._open_license_and_check(
            "CECILL-1.1.json",
            self._find_licenses_matcher,
            "CeCILL-1.1",
        )
        self._open_license_and_check(
            "CECILL-2.0.json",
            self._find_licenses_matcher,
            "CeCILL-2",
        )
        self._open_license_and_check(
            "CECILL-2.1.json",
            self._find_licenses_matcher,
            "CeCILL-2.1",
        )
        self._open_license_and_check(
            "CECILL-B.json",
            self._find_licenses_matcher,
            "CeCILL-B",
        )
        self._open_license_and_check(
            "CECILL-C.json",
            self._find_licenses_matcher,
            "CeCILL-C",
        )
        self._open_license_and_check(
            "CNRI-Jython.json",
            self._find_licenses_matcher,
            "CNRI-Jython",
        )
        self._open_license_and_check(
            "CNRI-Python-GPL-Compatible.json",
            self._find_licenses_matcher,
            "CNRI-Python-GPL-Compatible",
        )
        self._open_license_and_check(
            "CNRI-Python.json",
            self._find_licenses_matcher,
            "CNRI-Python",
        )
        self._open_license_and_check(
            "CPAL-1.0.json",
            self._find_licenses_matcher,
            "CPAL-1",
        )
        self._open_license_and_check(
            "CPL-1.0.json",
            self._find_licenses_matcher,
            "CPL-1",
        )
        self._open_license_and_check(
            "CPOL-1.02.json",
            self._find_licenses_matcher,
            "CPOL-1.02",
        )
        self._open_license_and_check(
            "CUA-OPL-1.0.json",
            self._find_licenses_matcher,
            "CUA-OPL-1",
        )
        self._open_license_and_check(
            "Caldera.json",
            self._find_licenses_matcher,
            "Caldera",
        )
        self._open_license_and_check(
            "ClArtistic.json",
            self._find_licenses_matcher,
            "ClArtistic",
        )
        self._open_license_and_check(
            "Clips.json",
            self._find_licenses_matcher,
            "MIT-like-clips",
        )
        self._open_license_and_check(
            "Community-Spec-1.0.json",
            self._find_licenses_matcher,
            # Self-published under CC-BY-4.0, so the CC-BY matcher and the
            # embedded SPDX tag both fire CC-BY-4; find_licenses dedups it.
            "CC-BY-4 or Community-Spec-1",
        )
        self._open_license_and_check(
            "Condor-1.1.json",
            self._find_licenses_matcher,
            "Condor-1.1",
        )
        self._open_license_and_check(
            "Crossword.json",
            self._find_licenses_matcher,
            "Crossword",
        )
        self._open_license_and_check(
            "CrystalStacker.json",
            self._find_licenses_matcher,
            "CrystalStacker",
        )
        self._open_license_and_check(
            "Cube.json",
            self._find_licenses_matcher,
            "Cube",
        )

    def test_spdx_d(self):
        """find_licenses() on every reviewed section-D licence (coverage table)."""
        self._open_license_and_check(
            "D-FSL-1.0.json",
            self._find_licenses_matcher,
            "D-FSL",
        )
        self._open_license_and_check(
            "DOC.json",
            self._find_licenses_matcher,
            "DOC",
        )
        self._open_license_and_check(
            "DSDP.json",
            self._find_licenses_matcher,
            "DSDP",
        )
        self._open_license_and_check(
            "DocBook-Stylesheet.json",
            self._find_licenses_matcher,
            "MIT-like-Docbook-Stylesheet",
        )
        self._open_license_and_check(
            "Dotseqn.json",
            self._find_licenses_matcher,
            "Dotseqn",
        )

    def test_spdx_f(self):
        """find_licenses() on every reviewed section-F licence (coverage table)."""
        self._open_license_and_check(
            "FSFAP-no-warranty-disclaimer.json",
            self._find_licenses_matcher,
            "FSFAP-no-warranty-disclaimer",
        )
        self._open_license_and_check(
            "FSFAP.json",
            self._find_licenses_matcher,
            "FSFAP",
        )
        self._open_license_and_check(
            "FSFUL.json",
            self._find_licenses_matcher,
            "FSFUL",
        )
        self._open_license_and_check(
            "FSFULLR.json",
            self._find_licenses_matcher,
            "FSFULLR",
        )
        self._open_license_and_check(
            "FSFULLRSD.json",
            self._find_licenses_matcher,
            "FSFULLR",
        )
        self._open_license_and_check(
            "FSFULLRWD.json",
            self._find_licenses_matcher,
            "FSFULLR",
        )
        self._open_license_and_check(
            "FSL-1.1-ALv2.json",
            self._find_licenses_matcher,
            "Apache-2 or FSL-1.1-ALv2",
        )
        self._open_license_and_check(
            "FSL-1.1-MIT.json",
            self._find_licenses_matcher,
            "Expat or FSL-1.1-MIT",
        )
        self._open_license_and_check(
            "FTL.json",
            self._find_licenses_matcher,
            "FTL",
        )
        self._open_license_and_check(
            "Fair.json",
            self._find_licenses_matcher,
            "Fair",
        )
        self._open_license_and_check(
            "Frameworx-1.0.json",
            self._find_licenses_matcher,
            "Frameworx-1",
        )
        self._open_license_and_check(
            "FreeImage.json",
            self._find_licenses_matcher,
            "FreeImage-1",
        )

    def test_spdx_g(self):
        """find_licenses() on every reviewed section-G licence (coverage table)."""
        self._open_license_and_check(
            "GFDL-1.1-invariants-only.json",
            self._find_licenses_matcher,
            "GFDL-1.1",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.1-invariants-or-later.json",
            self._find_licenses_matcher,
            "GFDL-1.1+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.1-no-invariants-only.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.1",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.1-no-invariants-or-later.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.1+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.1-only.json",
            self._find_licenses_matcher,
            "GFDL-1.1",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.1-or-later.json",
            self._find_licenses_matcher,
            "GFDL-1.1+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.1.json",
            self._find_licenses_matcher,
            "GFDL-1.1+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2-invariants-only.json",
            self._find_licenses_matcher,
            "GFDL-1.2",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2-invariants-or-later.json",
            self._find_licenses_matcher,
            "GFDL-1.2+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2-no-invariants-only.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.2",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2-no-invariants-or-later.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.2+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2-only.json",
            self._find_licenses_matcher,
            "GFDL-1.2",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2-or-later.json",
            self._find_licenses_matcher,
            "GFDL-1.2+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.2+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.3-invariants-only.json",
            self._find_licenses_matcher,
            "GFDL-1.3",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.3-invariants-or-later.json",
            self._find_licenses_matcher,
            "GFDL-1.3+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.3-no-invariants-only.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.3",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.3-no-invariants-or-later.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.3+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.3-only.json",
            self._find_licenses_matcher,
            "GFDL-1.3",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.3-or-later.json",
            self._find_licenses_matcher,
            "GFDL-1.3+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.3.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.3+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GPL-1.0+.json",
            self._find_licenses_matcher,
            "GPL-1+",
        )
        self._open_license_and_check(
            "GPL-1.0-only.json",
            self._find_licenses_matcher,
            "GPL-1",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GPL-1.0-or-later.json",
            self._find_licenses_matcher,
            "GPL-1+",
        )
        self._open_license_and_check(
            "GPL-1.0.json",
            self._find_licenses_matcher,
            "GPL-1+",
            ignore={"standardLicenseHeader"},
        )
        self._open_license_and_check(
            "GPL-2.0+.json",
            self._find_licenses_matcher,
            "GPL-2+",
        )
        self._open_license_and_check(
            "GPL-2.0-only.json",
            self._find_licenses_matcher,
            "GPL-2",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GPL-2.0-or-later.json",
            self._find_licenses_matcher,
            "GPL-2+",
        )
        self._open_license_and_check(
            "GPL-2.0.json",
            self._find_licenses_matcher,
            "GPL-2+",
            ignore={"standardLicenseHeader"},
        )
        self._open_license_and_check(
            "GPL-3.0+.json",
            self._find_licenses_matcher,
            "GPL-3+",
        )
        self._open_license_and_check(
            "GPL-3.0-only.json",
            self._find_licenses_matcher,
            "GPL-3",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GPL-3.0-or-later.json",
            self._find_licenses_matcher,
            "GPL-3+",
        )
        self._open_license_and_check(
            "GPL-3.0.json",
            self._find_licenses_matcher,
            "GPL-3+",
            ignore={"standardLicenseHeader"},
        )

    def test_spdx_h(self):
        """find_licenses() on every reviewed section-H licence (coverage table)."""
        self._open_license_and_check(
            "HPND-DEC.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-DEC",
        )
        self._open_license_and_check(
            "HPND-Fenneberg-Livingston.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-Fenneberg-Livingston",
        )
        self._open_license_and_check(
            "HPND-MIT-disclaimer.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-MIT-disclaimer",
        )
        self._open_license_and_check(
            "HPND-Markus-Kuhn.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-Markus-Kuhn",
        )
        self._open_license_and_check(
            "HPND-Netrek.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-Netrek",
        )
        self._open_license_and_check(
            "HPND-Pbmplus.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-Pbmplus",
        )
        self._open_license_and_check(
            "HPND-UC-export-US.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-UC-export-US",
        )
        self._open_license_and_check(
            "HPND-UC.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-UC",
        )
        self._open_license_and_check(
            "HPND-doc.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-doc",
        )
        self._open_license_and_check(
            "HPND-export-US-modify.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-export-US-modify",
        )
        self._open_license_and_check(
            "HPND-export-US.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-export-US",
        )
        self._open_license_and_check(
            "HPND-export2-US.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-export2-US",
        )
        self._open_license_and_check(
            "HPND-sell-variant-MIT-disclaimer-rev.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-sell-variant-MIT-disclaimer-rev",
        )
        self._open_license_and_check(
            "HPND-sell-variant-MIT-disclaimer.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-sell-variant-MIT-disclaimer",
        )
        self._open_license_and_check(
            "HPND-sell-variant-critical-systems.json",
            self._find_licenses_matcher,
            "MIT-like-HPND-sell-variant-critical-systems",
        )
        self._open_license_and_check(
            "HPND.json",
            self._find_licenses_matcher,
            "MIT-like-HPND",
        )
        self._open_license_and_check(
            "HaskellReport.json",
            self._find_licenses_matcher,
            "HaskellReport",
        )

    def test_spdx_m(self):
        """find_licenses() on the section-M licences fixed in review."""
        self._open_license_and_check(
            "MIT-0.json",
            self._find_licenses_matcher,
            "MIT-0",
        )
        self._open_license_and_check(
            "MIT-Click.json",
            self._find_licenses_matcher,
            "MIT-Click",
        )
        self._open_license_and_check(
            "MIT-Khronos-old.json",
            self._find_licenses_matcher,
            "MIT-Khronos-old",
        )
        self._open_license_and_check(
            "MIT-Modern-Variant.json",
            self._find_licenses_matcher,
            "MIT-Modern-Variant",
        )
        self._open_license_and_check(
            "MIT-STK.json",
            self._find_licenses_matcher,
            "MIT-STK",
        )
        self._open_license_and_check(
            "MIT-Wu.json",
            self._find_licenses_matcher,
            "MIT-Wu",
        )
        self._open_license_and_check(
            "MIT-open-group.json",
            self._find_licenses_matcher,
            "MIT-open-group",
        )
        self._open_license_and_check(
            "MPL-2.0-no-copyleft-exception.json",
            self._find_licenses_matcher,
            "MPL-2 with no-copyleft exception",
            ignore={"licenseText"},
        )

    def test_parse_bsd(self):
        def _bsd_matcher(text):
            return license_parsers.parse_bsd(licenses.clean_comments(text), None, "BSD")

        self._open_license_and_check("BSD-2-Clause.json", _bsd_matcher, "BSD-2-clause")
        self._open_license_and_check(
            "BSD-2-Clause-FreeBSD.json",
            _bsd_matcher,
            "BSD-2-clause-FreeBSD",
        )
        self._open_license_and_check(
            "BSD-2-Clause-NetBSD.json",
            _bsd_matcher,
            "BSD-2-clause-NetBSD",
        )
        self._open_license_and_check("BSD-3-Clause.json", _bsd_matcher, "BSD-3-clause")
        self._open_license_and_check(
            "BSD-3-Clause-LBNL.json",
            _bsd_matcher,
            "BSD-3-clause-LBNL",
        )
        self._open_license_and_check(
            "BSD-3-Clause-Clear.json",
            _bsd_matcher,
            "BSD-3-clause-clear",
        )
        # The Sun "... ARE HEREBY EXCLUDED" disclaimer separates the License and
        # Warranty forms; the 2014 form carries the standard BSD disclaimer and
        # so stays on the generic no-nuclear name.
        self._open_license_and_check(
            "BSD-3-Clause-No-Nuclear-License.json",
            _bsd_matcher,
            "BSD-3-clause-no-nuclear-license",
        )
        self._open_license_and_check(
            "BSD-3-Clause-No-Nuclear-License-2014.json",
            _bsd_matcher,
            "BSD-3-clause-no-nuclear",
        )
        self._open_license_and_check(
            "BSD-3-Clause-No-Nuclear-Warranty.json",
            _bsd_matcher,
            "BSD-3-clause-no-nuclear-warranty",
        )
        self._open_license_and_check("BSD-4-Clause.json", _bsd_matcher, "BSD-4-clause")
        self._open_license_and_check(
            "BSD-4-Clause-UC.json",
            _bsd_matcher,
            "BSD-4-clause-UC",
        )
        self._open_license_and_check(
            "BSD-Protection.json",
            _bsd_matcher,
            "BSD-protection",
        )
        self._open_license_and_check(
            "BSD-Source-Code.json",
            _bsd_matcher,
            "BSD-source-code",
        )
        self._open_license_and_check(
            "BSD-3-Clause-Attribution.json",
            _bsd_matcher,
            "BSD-3-clause-attribution",
        )
        self._open_license_and_check("0BSD.json", _bsd_matcher, "0BSD")
        # A recognised BSD-family variant with a non-standard redistribution
        # clause still classifies via its own BSD_SUBS rule.
        self._open_license_and_check("Inner-Net-2.0.json", _bsd_matcher, "Inner-Net-2")
        # A bare BSD warranty disclaimer borrowed by a non-BSD licence must not
        # be reported as BSD (parse_bsd returns "" so find_licenses drops it).
        self._open_license_and_check("ISC-Veillard.json", _bsd_matcher, "")
        self._open_license_and_check("MIT-testregex.json", _bsd_matcher, "")
        # End to end, the ISC-style licence resolves to its own name, not BSD.
        self._open_license_and_check(
            "ISC-Veillard.json",
            self._find_licenses_matcher,
            "ISC-Veillard",
        )

    def test_parse_ccby(self):
        def _cc_by_matcher(text):
            return license_parsers.parse_cc_by(
                licenses.clean_comments(text), None, "CC-BY"
            )

        self._open_license_and_check("CC-BY-1.0.json", _cc_by_matcher, "CC-BY-1")
        self._open_license_and_check("CC-BY-2.0.json", _cc_by_matcher, "CC-BY-2")
        self._open_license_and_check("CC-BY-2.5.json", _cc_by_matcher, "CC-BY-2.5")
        self._open_license_and_check("CC-BY-3.0.json", _cc_by_matcher, "CC-BY-3")
        self._open_license_and_check("CC-BY-4.0.json", _cc_by_matcher, "CC-BY-4")
        self._open_license_and_check("CC-BY-NC-1.0.json", _cc_by_matcher, "CC-BY-NC-1")
        self._open_license_and_check("CC-BY-NC-2.0.json", _cc_by_matcher, "CC-BY-NC-2")
        self._open_license_and_check(
            "CC-BY-NC-2.5.json",
            _cc_by_matcher,
            "CC-BY-NC-2.5",
        )
        self._open_license_and_check("CC-BY-NC-3.0.json", _cc_by_matcher, "CC-BY-NC-3")
        self._open_license_and_check("CC-BY-NC-4.0.json", _cc_by_matcher, "CC-BY-NC-4")
        self._open_license_and_check(
            "CC-BY-NC-ND-1.0.json",
            _cc_by_matcher,
            "CC-BY-NC-ND-1",
        )
        self._open_license_and_check(
            "CC-BY-NC-ND-2.0.json",
            _cc_by_matcher,
            "CC-BY-NC-ND-2",
        )
        self._open_license_and_check(
            "CC-BY-NC-ND-2.5.json",
            _cc_by_matcher,
            "CC-BY-NC-ND-2.5",
        )
        self._open_license_and_check(
            "CC-BY-NC-ND-3.0.json",
            _cc_by_matcher,
            "CC-BY-NC-ND-3",
        )
        self._open_license_and_check(
            "CC-BY-NC-ND-4.0.json",
            _cc_by_matcher,
            "CC-BY-NC-ND-4",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-1.0.json",
            _cc_by_matcher,
            "CC-BY-NC-SA-1",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-2.0.json",
            _cc_by_matcher,
            "CC-BY-NC-SA-2",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-2.5.json",
            _cc_by_matcher,
            "CC-BY-NC-SA-2.5",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-3.0.json",
            _cc_by_matcher,
            "CC-BY-NC-SA-3",
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-4.0.json",
            _cc_by_matcher,
            "CC-BY-NC-SA-4",
        )
        self._open_license_and_check("CC-BY-ND-1.0.json", _cc_by_matcher, "CC-BY-ND-1")
        self._open_license_and_check("CC-BY-ND-2.0.json", _cc_by_matcher, "CC-BY-ND-2")
        self._open_license_and_check(
            "CC-BY-ND-2.5.json",
            _cc_by_matcher,
            "CC-BY-ND-2.5",
        )
        self._open_license_and_check("CC-BY-ND-3.0.json", _cc_by_matcher, "CC-BY-ND-3")
        self._open_license_and_check("CC-BY-ND-4.0.json", _cc_by_matcher, "CC-BY-ND-4")
        self._open_license_and_check("CC-BY-SA-1.0.json", _cc_by_matcher, "CC-BY-SA-1")
        self._open_license_and_check("CC-BY-SA-2.0.json", _cc_by_matcher, "CC-BY-SA-2")
        self._open_license_and_check(
            "CC-BY-SA-2.5.json",
            _cc_by_matcher,
            "CC-BY-SA-2.5",
        )
        self._open_license_and_check("CC-BY-SA-3.0.json", _cc_by_matcher, "CC-BY-SA-3")
        self._open_license_and_check("CC-BY-SA-4.0.json", _cc_by_matcher, "CC-BY-SA-4")

        self._open_license_and_check("CC0-1.0.json", self._find_licenses_matcher, "CC0")

    def test_cc_family_detection(self):
        """End-to-end: the Creative Commons legal-code family is detected.

        Covers the long English legal codes (which lack the short license-URL /
        CCPL preamble that the v2/v3 deeds carry), the localized non-English
        jurisdiction ports, and the non-Attribution CC ids. A jurisdiction port
        keeps its version (trailing ``.0`` stripped) plus a ``-<JURISDICTION>``
        suffix, e.g. ``CC-BY-3.0-IGO`` -> ``CC-BY-3-IGO``.
        """
        # Base English versions that previously went undetected.
        self._open_license_and_check(
            "CC-BY-4.0.json", self._find_licenses_matcher, "CC-BY-4"
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-4.0.json", self._find_licenses_matcher, "CC-BY-NC-SA-4"
        )
        # English jurisdiction ports: collapsed version + jurisdiction suffix.
        self._open_license_and_check(
            "CC-BY-3.0-IGO.json", self._find_licenses_matcher, "CC-BY-3-IGO"
        )
        self._open_license_and_check(
            "CC-BY-SA-2.0-UK.json", self._find_licenses_matcher, "CC-BY-SA-2-UK"
        )
        # Localized (non-English) ports read their translated title for the
        # version and NC/SA/ND modifiers; the jurisdiction comes from a country /
        # language marker (German "Deutschland", Dutch/French language word,
        # Japanese "ジャパン").
        self._open_license_and_check(
            "CC-BY-NC-SA-2.0-DE.json",
            self._find_licenses_matcher,
            "CC-BY-NC-SA-2-DE",
        )
        self._open_license_and_check(
            "CC-BY-3.0-NL.json", self._find_licenses_matcher, "CC-BY-3-NL"
        )
        self._open_license_and_check(
            "CC-BY-NC-SA-2.0-FR.json",
            self._find_licenses_matcher,
            "CC-BY-NC-SA-2-FR",
        )
        self._open_license_and_check(
            "CC-BY-SA-2.1-JP.json", self._find_licenses_matcher, "CC-BY-SA-2.1-JP"
        )
        # The two Austrian 3.0 ports have no deed title (CC-BY-3.0-AT carries no
        # version string at all), so a dedicated rule emits their full id.
        self._open_license_and_check(
            "CC-BY-3.0-AT.json", self._find_licenses_matcher, "CC-BY-3-AT"
        )
        self._open_license_and_check(
            "CC-BY-SA-3.0-AT.json", self._find_licenses_matcher, "CC-BY-SA-3-AT"
        )
        # Non-Attribution CC ids.
        self._open_license_and_check(
            "CC-SA-1.0.json", self._find_licenses_matcher, "CC-SA-1"
        )
        self._open_license_and_check(
            "CC-PDDC.json", self._find_licenses_matcher, "CC-PDDC"
        )
        self._open_license_and_check(
            "CC-PDM-1.0.json", self._find_licenses_matcher, "CC-PDM-1.0"
        )

    def test_bsd_permissive_cluster(self):
        """End-to-end detection for the BSD / permissive notice cluster.

        These licences carry non-standard warranty disclaimers, so most are not
        reached by the generic BSD/ISC family rules and instead anchor on a
        distinctive clause. The three Sun/Oracle 3-clause forms share one
        disclaimer and are told apart inside the BSD sub-rules.
        """
        cases = {
            "0BSD.json": "0BSD",
            "BSD-2-Clause-pkgconf-disclaimer.json": "BSD-2-clause-pkgconf-disclaimer",
            "BSD-3-Clause-Modification.json": "BSD-3-clause-modification",
            "BSD-3-Clause-No-Nuclear-License.json": "BSD-3-clause-no-nuclear-license",
            "BSD-3-Clause-No-Nuclear-Warranty.json": "BSD-3-clause-no-nuclear-warranty",
            "BSD-3-Clause-Sun.json": "BSD-3-clause-Sun",
            "BSD-4.3RENO.json": "BSD-4.3RENO",
            "BSD-4.3TAHOE.json": "BSD-4.3TAHOE",
            "BSD-Advertising-Acknowledgement.json": "BSD-advertising-acknowledgement",
            "Brian-Gladman-2-Clause.json": "Brian-Gladman-2-clause",
            "Brian-Gladman-3-Clause.json": "Brian-Gladman-3-clause",
            "Brian-Gladman-3-Clause-no-conversion.json": (
                "Brian-Gladman-3-clause-no-conversion"
            ),
            "Cronyx.json": "Cronyx",
            "FreeBSD-DOC.json": "FreeBSD-DOC",
            "NTP-0.json": "NTP-0",
            "Sun-PPP.json": "Sun-PPP",
            "Sun-PPP-2000.json": "Sun-PPP-2000",
            "SunPro.json": "SunPro",
            "UCAR.json": "UCAR",
            "Vixie-Cron.json": "Vixie-Cron",
            "checkmk.json": "checkmk",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_hpnd_mit_cluster(self):
        """End-to-end detection for the HPND / MIT-like permissive cluster.

        These are "historical permission notice" grants close to MIT/X11. Most
        differ from the already-covered ones only in fee wording or verb list,
        so a broadened family grant routes them to parse_mit, which names the
        specific variant via MIT_SUBS. The base "sell" form is told apart from
        the plain X11 licence by its advertising clause.
        """
        cases = {
            "HP-1986.json": "MIT-like-HP-1986",
            "HP-1989.json": "MIT-like-HP-1989",
            "HPND-INRIA-IMAG.json": "MIT-like-HPND-INRIA-IMAG",
            "HPND-Intel.json": "MIT-like-HPND-Intel",
            "HPND-Kevlin-Henney.json": "MIT-like-HPND-Kevlin-Henney",
            "HPND-SMC.json": "MIT-like-HPND-SMC",
            "HPND-doc-sell.json": "MIT-like-HPND-doc-sell",
            "HPND-export-US-acknowledgement.json": (
                "MIT-like-HPND-export-US-acknowledgement"
            ),
            "HPND-merchantability-variant.json": (
                "MIT-like-HPND-merchantability-variant"
            ),
            "HPND-sell-MIT-disclaimer-xserver.json": (
                "MIT-like-HPND-sell-MIT-disclaimer-xserver"
            ),
            "HPND-sell-regexpr.json": "MIT-like-HPND-sell-regexpr",
            "HPND-sell-variant.json": "MIT-like-HPND-sell-variant",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_doc_data_gov_cluster(self):
        """End-to-end detection for documentation / open-government / data licences.

        These are standalone notices identified by their distinctive title line
        (the DocBook DTD/XML forms reuse the MIT-like family naming).
        """
        cases = {
            "OGL-UK-1.0.json": "OGL-UK-1",
            "OGL-UK-2.0.json": "OGL-UK-2",
            "OGL-UK-3.0.json": "OGL-UK-3",
            "OGL-Canada-2.0.json": "OGL-Canada-2",
            "OPL-UK-3.0.json": "OPL-UK-3",
            "NLOD-2.0.json": "NLOD-2",
            "NCGL-UK-2.0.json": "NCGL-UK-2",
            "etalab-2.0.json": "etalab-2",
            "CDL-1.0.json": "CDL-1",
            "CDLA-Permissive-1.0.json": "CDLA-Permissive-1",
            "CDLA-Permissive-2.0.json": "CDLA-Permissive-2",
            "CDLA-Sharing-1.0.json": "CDLA-Sharing-1",
            "ODC-By-1.0.json": "ODC-By-1",
            "DL-DE-BY-2.0.json": "DL-DE-BY-2",
            "DL-DE-ZERO-2.0.json": "DL-DE-Zero-2",
            "OPUBL-1.0.json": "OPUBL-1",
            "DocBook-DTD.json": "MIT-like-DocBook-DTD",
            "DocBook-XML.json": "MIT-like-DocBook-XML",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_font_cluster(self):
        """End-to-end detection for the font licences (the FontLicense group).

        Each anchors on a substantive grant clause in the licence body, not just
        the foundry/font name; OFL is detected separately and is unaffected.
        """
        cases = {
            "Arphic-1999.json": "Arphic-1999",
            "Baekmuk.json": "Baekmuk",
            "Bitstream-Charter.json": "Bitstream-Charter",
            "Bitstream-Vera.json": "Bitstream-Vera",
            "Kastrup.json": "Kastrup",
            "Lucida-Bitmap-Fonts.json": "Lucida-Bitmap-Fonts",
            "ParaType-Free-Font-1.3.json": "ParaType-Free-Font-1.3",
            "mplus.json": "mplus",
            "Ubuntu-font-1.0.json": "Ubuntu-font-1",
            "OFL-1.1.json": "OFL-1.1",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_gpl_exception_only_notices(self):
        """Deprecated `GPL-N.0-with-X-exception` ids ship only the exception
        notice (the GPL body is a "insert GPL ... here" placeholder), so they are
        not standalone licences. ``find_licenses`` must NOT report them as a
        licence; but ``parse_gpl`` must still recognise the exception clause -
        that is what fires when the notice accompanies the real GPL text.
        """

        def _exception_matcher(text):
            return license_parsers.parse_gpl(licenses.clean_comments(text), None, "GPL")

        exceptions = {
            "GPL-2.0-with-autoconf-exception.json": "GPL with AutoConf exception",
            "GPL-2.0-with-bison-exception.json": "GPL with Bison exception",
            "GPL-2.0-with-classpath-exception.json": "GPL with ClassPath exception",
            "GPL-2.0-with-font-exception.json": "GPL with Font exception",
            "GPL-2.0-with-GCC-exception.json": "GPL with GCC exception",
            "GPL-3.0-with-autoconf-exception.json": "GPL-3 with AutoConf-3 exception",
            "GPL-3.0-with-GCC-exception.json": "GPL-3 with GCC-3.1 exception",
        }
        for filename, exception in exceptions.items():
            # The exception clause is recognised by the GPL exception parser ...
            self._open_license_and_check(filename, _exception_matcher, exception)
            # ... but the placeholder notice is not reported as a licence alone.
            self._open_license_and_check(filename, self._find_licenses_matcher, "")

    def test_hardware_cluster(self):
        """End-to-end detection for the open-hardware licences (HardwareLicense
        group). The CERN v2 Permissive/Strongly/Weakly variants share a preamble
        and are told apart by the variant word in their title.
        """
        cases = {
            "CERN-OHL-1.1.json": "CERN-OHL-1.1",
            "CERN-OHL-1.2.json": "CERN-OHL-1.2",
            "CERN-OHL-P-2.0.json": "CERN-OHL-P-2",
            "CERN-OHL-S-2.0.json": "CERN-OHL-S-2",
            "CERN-OHL-W-2.0.json": "CERN-OHL-W-2",
            "SHL-0.5.json": "SHL-0.5",
            "SHL-0.51.json": "SHL-0.51",
            "TAPR-OHL-1.0.json": "TAPR-OHL-1",
        }
        for filename, expected in cases.items():
            # Checks every present key, incl. the SHL standardLicenseHeader,
            # which is also detected (its version-specific licence header).
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_usgov_public_domain_cluster(self):
        """End-to-end detection for the US-Gov / agency public-domain notices
        (USGovPD group). NIST-PD and NTIA-PD share the Section-105 clause and are
        told apart by the naming agency.
        """
        cases = {
            "NIST-PD.json": "NIST-PD",
            "NIST-PD-fallback.json": "NIST-PD-fallback",
            "NIST-PD-TNT.json": "NIST-PD-TNT",
            "NIST-Software.json": "NIST-Software",
            "NTIA-PD.json": "NTIA-PD",
            "NCBI-PD.json": "NCBI-PD",
            "libselinux-1.0.json": "libselinux-1",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_pd_misc_cluster(self):
        """End-to-end detection for unrelated public-domain notices (PdMisc
        group). The SAX v2 form must not collide with the v1 SAX-PD, which
        embeds the same "abandon ... SAX 2.0" sentence.
        """
        cases = {
            "blessing.json": "blessing",
            "bcrypt-Solar-Designer.json": "bcrypt-Solar-Designer",
            "SAX-PD-2.0.json": "SAX-PD-2",
            "SGP4.json": "SGP4",
            "Symlinks.json": "Symlinks",
            "WTFNMFPL.json": "WTFNMFPL",
            "Unlicense-libtelnet.json": "Unlicense-libtelnet",
            "LZMA-SDK-9.11-to-9.20.json": "LZMA-SDK-9.11-to-9.20",
            "LZMA-SDK-9.22.json": "LZMA-SDK-9.22",
            "SAX-PD.json": "SAX-PD",  # v1 unaffected
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_manpage_cluster(self):
        """End-to-end detection for the Linux man-pages licences (ManPage group)
        plus the standalone man2html / hyphen-bulgarian notices. The bare
        two-paragraph form must not capture the Latex2e family, which appends a
        "translations of this manual" paragraph to the same two paragraphs.
        """
        cases = {
            "Linux-man-pages-1-para.json": "Linux-man-pages-1-para",
            "Linux-man-pages-copyleft.json": "Linux-man-pages-copyleft",
            "Linux-man-pages-copyleft-2-para.json": "Linux-man-pages-copyleft-2-para",
            "Linux-man-pages-copyleft-var.json": "Linux-man-pages-copyleft-var",
            "man2html.json": "man2html",
            "hyphen-bulgarian.json": "hyphen-bulgarian",
            "Latex2e.json": "Latex2e",  # unaffected (has the translations clause)
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_ctan_tex_cluster(self):
        """End-to-end detection for the CTAN / TeX licences (CtanTex group).
        Latex2e-translated-notice is the Latex2e text with an extra "approved by
        the Foundation" translation clause; plain Latex2e is unaffected.
        """
        cases = {
            "Knuth-CTAN.json": "Knuth-CTAN",
            "MMIXware.json": "MMIXware",
            "Latex2e-translated-notice.json": "Latex2e-translated-notice",
            "magaz.json": "magaz",
            "swrule.json": "swrule",
            "threeparttable.json": "threeparttable",
            "ulem.json": "ulem",
            "TTYP0.json": "TTYP0",
            "Latex2e.json": "Latex2e",  # unaffected
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_standards_body_cluster(self):
        """End-to-end detection for the standards-body licences (StandardsBody
        group): Unicode + the W3C-derived family. W3C-20150513 is told apart by
        its "obtaining and/or copying" opener; the older W3C / W3C-19980720 (with
        "obtaining, using and/or copying") are unaffected.
        """
        cases = {
            "Unicode-3.0.json": "Unicode-3",
            "Unicode-DFS-2015.json": "Unicode-DFS-2015",
            "Unicode-DFS-2016.json": "Unicode-DFS-2016",
            "W3C-20150513.json": "W3C-20150513",
            "WordNet.json": "WordNet",
            "OGC-1.0.json": "OGC-1",
            "W3C.json": "W3C",  # unaffected
            "W3C-19980720.json": "W3C-19980720",  # unaffected
        }
        for filename, expected in cases.items():
            # Match on licenseText (as the coverage harness does); the W3C forms
            # carry a separate short-notice standardLicenseHeader that the
            # existing W3C rules also do not cover.
            self._open_license_and_check(
                filename,
                self._find_licenses_matcher,
                expected,
                ignore=("standardLicenseHeader",),
            )

    def test_source_available_cluster(self):
        """End-to-end detection for the source-available / business copyleft
        licences (SrcAvailable group). Each is anchored on its title-and-version
        line plus a substantive body clause, so a bare reference to the name does
        not match. Trailing zero version components are dropped from the reported
        name (SSPL-1.0 -> SSPL-1, Parity-6.0.0 -> Parity-6,
        PolyForm-Noncommercial-1.0.0 -> PolyForm-Noncommercial-1); the two
        copyleft-next releases differ only by their release date and the two
        Parity versions by their one-line summary.
        """
        cases = {
            "copyleft-next-0.3.0.json": "copyleft-next-0.3",
            "copyleft-next-0.3.1.json": "copyleft-next-0.3.1",
            "Parity-6.0.0.json": "Parity-6",
            "Parity-7.0.0.json": "Parity-7",
            "PolyForm-Noncommercial-1.0.0.json": "PolyForm-Noncommercial-1",
            "PolyForm-Small-Business-1.0.0.json": "PolyForm-Small-Business-1",
            "SSPL-1.0.json": "SSPL-1",
            "BUSL-1.1.json": "BUSL-1.1",
            "Elastic-2.0.json": "Elastic-2",
            "SUL-1.0.json": "SUL-1",
        }
        for filename, expected in cases.items():
            # SSPL/BUSL also ship a standardLicenseHeader application notice that
            # grants/governs by reference to the licence; those are covered too,
            # so every present field must detect.
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_european_copyleft_cluster(self):
        """End-to-end detection for the European / formal-language copyleft
        licences. The ESA-PL family (EsaPl group) splits by copyleft strength
        (permissive / weak / strong, all v2.4); TU-Berlin (TuBerlin group) tells
        v2.0 from v1.0 by the appended permissive grant (trailing ".0" stripped:
        TU-Berlin-1.0 -> TU-Berlin-1). EUPL-1.2 dropped the version from its
        appendix notice, so it is matched by the licence header instead, while
        EUPL-1.0/1.1 (already detected via the appendix) are unaffected. OLFL-1.3
        is a standalone rule. Each anchor pairs the title with substantive body
        text so a bare reference to the name does not match.
        """
        cases = {
            "ESA-PL-permissive-2.4.json": "ESA-PL-permissive-2.4",
            "ESA-PL-weak-copyleft-2.4.json": "ESA-PL-weak-copyleft-2.4",
            "ESA-PL-strong-copyleft-2.4.json": "ESA-PL-strong-copyleft-2.4",
            "EUPL-1.0.json": "EUPL-1",  # unaffected
            "EUPL-1.1.json": "EUPL-1.1",  # unaffected
            "EUPL-1.2.json": "EUPL-1.2",
            "OLFL-1.3.json": "OLFL-1.3",
            "TU-Berlin-1.0.json": "TU-Berlin-1",
            "TU-Berlin-2.0.json": "TU-Berlin-2",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_apache_mpl_derived_cluster(self):
        """End-to-end detection for the Apache/MPL-derived + Mulan + small
        copyleft licences. Two share an origin and get a family gate: MulanPSL
        (MulanPsl group, v1/v2 by the Chinese "第1版"/"第2版" marker) and the
        OSL-template derivatives TGPPL/UCL (OslDerived group, matched on their own
        titles so OSL/AFL/NPOSL stay untouched). The rest are standalone rules
        anchored on a distinctive title plus body: Pixar (modified Apache 2.0),
        OSC (German Expat translation), MVT-1.1 / TPL (MPL-derived), MMPL-1.0.1
        (Minecraft) and SMAIL-GPL (clarified GPL). Trailing ".0" is stripped
        (MulanPSL-1.0 -> MulanPSL-1, TGPPL-1.0 -> TGPPL-1, TPL-1.0 -> TPL-1,
        OSC-1.0 -> OSC-1); the derivatives do not trigger the generic
        Apache/Mozilla/GPL rules. The MulanPSL / TGPPL / UCL / MVT / TPL
        application-notice standardLicenseHeaders are also covered. Pixar belongs
        to the Apache family: it is Apache-2.0 with Section 6 (Trademarks) deleted
        and replaced, and that replaced clause appears in both its full text and
        its header, so both fields resolve to "Pixar". (The deprecated wxWindows
        id is only an exception notice, not a standalone licence; it stays
        undetected and is covered as WxWindows-3.1 by test_lgpl_wxwindows_exception.)
        """
        cases = {
            "MulanPSL-1.0.json": "MulanPSL-1",
            "MulanPSL-2.0.json": "MulanPSL-2",
            "TGPPL-1.0.json": "TGPPL-1",
            "UCL-1.0.json": "UCL-1",
            "Pixar.json": "Pixar",
            "OSC-1.0.json": "OSC-1",
            "MVT-1.1.json": "MVT-1.1",
            "TPL-1.0.json": "TPL-1",
            "MMPL-1.0.1.json": "MMPL-1.0.1",
            "SMAIL-GPL.json": "SMAIL-GPL",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_data_use_agreements_cluster(self):
        """End-to-end detection for the data-use agreement licences. Three share
        an origin and get a family gate: the Microsoft Use of Data Agreements
        (UdaData group, Computational/Open by title), the ASWF Digital Assets
        License (AswfAssets group, by version) and the MITRE terms-of-use grants
        (MitreTou group, CAPEC/CVE by dataset). The rest are standalone rules:
        OGDL-Taiwan (Chinese title + date), the UK Advanced Cryptics Dictionary
        restriction, and NCL (a BSD-3-style notice whose reordered
        endorsement-first opener keeps it off the generic BSD rules). Trailing
        ".0" is stripped (C-UDA-1.0 -> C-UDA-1, ASWF-Digital-Assets-1.0 ->
        ASWF-Digital-Assets-1, OGDL-Taiwan-1.0 -> OGDL-Taiwan-1).
        """
        cases = {
            "C-UDA-1.0.json": "C-UDA-1",
            "O-UDA-1.0.json": "O-UDA-1",
            "ASWF-Digital-Assets-1.0.json": "ASWF-Digital-Assets-1",
            "ASWF-Digital-Assets-1.1.json": "ASWF-Digital-Assets-1.1",
            "CAPEC-tou.json": "CAPEC-tou",
            "cve-tou.json": "cve-tou",
            "OGDL-Taiwan-1.0.json": "OGDL-Taiwan-1",
            "Advanced-Cryptics-Dictionary.json": "Advanced-Cryptics-Dictionary",
            "NCL.json": "NCL",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_small_permissive_families_cluster(self):
        """End-to-end detection for the Sun/CMU/Adobe/SSH small permissive
        notices. Three share an origin and get a family gate: the CMU/Mach
        notice (CmuMach group, documentation vs no-doc variant), Tatu Ylonen's
        SSH notice (Ssh group, full OpenSSH vs short form) and the Boehm GC
        notice (BoehmGc group, without-fee vs base). The rest are standalone
        rules anchored on a substantive clause: Adobe Display PostScript (its
        trademark-licence clause), Adobe Utopia (the typeface grant), ssh-keyscan
        (the OpenBSD due-credit clause) and OpenVision (its free-use grant).
        """
        cases = {
            "CMU-Mach.json": "CMU-Mach",
            "CMU-Mach-nodoc.json": "CMU-Mach-nodoc",
            "Adobe-Display-PostScript.json": "Adobe-Display-PostScript",
            "Adobe-Utopia.json": "Adobe-Utopia",
            "SSH-OpenSSH.json": "SSH-OpenSSH",
            "SSH-short.json": "SSH-short",
            "ssh-keyscan.json": "ssh-keyscan",
            "OpenVision.json": "OpenVision",
            "Boehm-GC.json": "Boehm-GC",
            "Boehm-GC-without-fee.json": "Boehm-GC-without-fee",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_documentation_book_code_cluster(self):
        """End-to-end detection for the documentation / book-code permission
        notices. These have no shared structure, so each is a standalone
        ``LICENSES_RES`` rule anchored on a substantive, mostly name-free clause.
        ALGLIB anchors on its "first (or last) lines of this file" retention
        clause rather than the bare "Redistribution and use of this document"
        opener, which collides with the Enthought docs licence.
        """
        cases = {
            "AdaCore-doc.json": "AdaCore-doc",
            "GCR-docs.json": "GCR-docs",
            "LPD-document.json": "LPD-document",
            "SchemeReport.json": "SchemeReport",
            "ALGLIB-Documentation.json": "ALGLIB-Documentation",
            "Graphics-Gems.json": "Graphics-Gems",
            "Game-Programming-Gems.json": "Game-Programming-Gems",
            "gtkbook.json": "gtkbook",
            "McPhee-slideshow.json": "McPhee-slideshow",
            "ISO-permission.json": "ISO-permission",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_eula_proprietary_cluster(self):
        """End-to-end detection for the EULA / proprietary / restricted +
        permissive-oddball licences. No shared structure, so each is a standalone
        ``LICENSES_RES`` rule anchored on its distinctive title or grant clause.
        Several are non-free EULAs, detected for non-free/contrib analysis.
        BlueOak anchors on the versioned title ("Blue Oak Model License Version
        1.0.0") because the bare name is also referenced by Parity-7. Trailing
        ".0"/".0.0" components are dropped from the names.
        """
        cases = {
            "3D-Slicer-1.0.json": "3D-Slicer-1",
            "FDK-AAC.json": "FDK-AAC",
            "IEC-Code-Components-EULA.json": "IEC-Code-Components-EULA",
            "JPL-image.json": "JPL-image",
            "UnRAR.json": "UnRAR",
            "Hippocratic-2.1.json": "Hippocratic-2.1",
            "OpenMDW-1.0.json": "OpenMDW-1",
            "MS-LPL.json": "MS-LPL",
            "GLWTPL.json": "GLWTPL",
            "SL.json": "SL",
            "BOLA-1.1.json": "BOLA-1.1",
            "COIL-1.0.json": "COIL-1",
            "BlueOak-1.0.0.json": "BlueOak-1",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk1_cluster(self):
        """End-to-end detection for a set of assorted single-file permissive
        notices, each a standalone rule on a distinctive grant/title clause.
        Caldera-no-preamble shares its BSD-4-clause body with the full Caldera
        licence; the Caldera-family rule returns "Caldera" when the "fee free
        license" preamble is present, else "Caldera-no-preamble" (verified by the
        Caldera regression below).
        """
        cases = {
            "AMD-newlib.json": "AMD-newlib",
            "AML-glslang.json": "AML-glslang",
            "any-OSI.json": "any-OSI",
            "any-OSI-perl-modules.json": "any-OSI-perl-modules",
            "App-s2p.json": "App-s2p",
            "Aspell-RU.json": "Aspell-RU",
            "Buddy.json": "Buddy",
            "Caldera-no-preamble.json": "Caldera-no-preamble",
            "Catharon.json": "Catharon",
            "CFITSIO.json": "CFITSIO",
            # regression: the full Caldera licence (preamble + same body) must
            # still resolve to Caldera, not Caldera-no-preamble.
            "Caldera.json": "Caldera",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk2_cluster(self):
        """End-to-end detection for a further set of assorted single-file
        permissive notices, each a standalone rule on a distinctive grant/title
        clause. The two Detection Rule License versions share one family rule that
        reads the version (1.0 -> DRL-1, 1.1 -> DRL-1.1); CryptoSwift is a zlib
        variant told apart by its "or binary" + acknowledgment clause.
        """
        cases = {
            "check-cvs.json": "check-cvs",
            "Cornell-Lossless-JPEG.json": "Cornell-Lossless-JPEG",
            "CryptoSwift.json": "CryptoSwift",
            "DEC-3-Clause.json": "DEC-3-Clause",
            "DRL-1.0.json": "DRL-1",
            "DRL-1.1.json": "DRL-1.1",
            "EPICS.json": "EPICS",
            "FBM.json": "FBM",
            "Ferguson-Twofish.json": "Ferguson-Twofish",
            "Furuseth.json": "Furuseth",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk3_cluster(self):
        """End-to-end detection for a further set of assorted single-file
        permissive notices. IJG and IJG-short share one family rule on their
        README/no-warranty clause; it returns IJG for the full licence (carries
        the "derived from or based on the IJG code" condition) and IJG-short for
        the brief form (regression on the full IJG below).
        """
        cases = {
            "fwlw.json": "fwlw",
            "GD.json": "GD",
            "generic-xts.json": "generic-xts",
            "Gutmann.json": "Gutmann",
            "HDF5.json": "HDF5",
            "hdparm.json": "hdparm",
            "HIDAPI.json": "HIDAPI",
            "HTMLTIDY.json": "HTMLTIDY",
            "IJG-short.json": "IJG-short",
            "IJG.json": "IJG",
            "InnoSetup.json": "InnoSetup",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk4_cluster(self):
        """End-to-end detection for a further set of assorted single-file
        permissive notices. Two pairs share a family rule: the PNG Reference
        Library License (v1 -> libpng-1.6.35, v2 -> libpng-2) and the Mackerras
        BSD-3-clause (plain vs the "developed by Paul Mackerras" acknowledgment
        variant).
        """
        cases = {
            "Jam.json": "Jam",
            "jove.json": "jove",
            "Kazlib.json": "Kazlib",
            "libpng-1.6.35.json": "libpng-1.6.35",
            "libpng-2.0.json": "libpng-2",
            "libutil-David-Nugent.json": "libutil-David-Nugent",
            "Linux-OpenIB.json": "Linux-OpenIB",
            "lsof.json": "lsof",
            "Mackerras-3-Clause.json": "Mackerras-3-Clause",
            "Mackerras-3-Clause-acknowledgment.json": (
                "Mackerras-3-Clause-acknowledgment"
            ),
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk5_cluster(self):
        """End-to-end detection for a further set of assorted single-file
        permissive notices. MIPS and mpi-permissive anchor on their
        organisation-specific clause (MIPS Computer Systems / Etnus) because the
        bare grant template is shared with ThirdEye and DSDP/mpich respectively.
        """
        cases = {
            "mailprio.json": "mailprio",
            "Martin-Birgmeier.json": "Martin-Birgmeier",
            "Minpack.json": "Minpack",
            "MIPS.json": "MIPS",
            "MIT-Festival.json": "MIT-Festival",
            "MIT-testregex.json": "MIT-testregex",
            "MPEG-SSG.json": "MPEG-SSG",
            "mpi-permissive.json": "mpi-permissive",
            "NAIST-2003.json": "NAIST-2003",
            "NICTA-1.0.json": "NICTA-1",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk6_cluster(self):
        """End-to-end detection for a further set of assorted single-file
        permissive notices. OAR anchors on its organisation name (the grant
        clause is shared with dtoa); Sendmail-Open-Source is the distinct
        "SENDMAIL OPEN SOURCE LICENSE", separate from the BSD-ish Sendmail.
        """
        cases = {
            "OAR.json": "OAR",
            "OFFIS.json": "OFFIS",
            "OpenPBS-2.3.json": "OpenPBS-2.3",
            "PADL.json": "PADL",
            "pnmstitch.json": "pnmstitch",
            "python-ldap.json": "python-ldap",
            "Ruby-pty.json": "Ruby-pty",
            "Sendmail-Open-Source-1.1.json": "Sendmail-Open-Source-1.1",
            "SGMLUG-PM.json": "SGMLUG-PM",
            "snprintf.json": "snprintf",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk7_cluster(self):
        """End-to-end detection for a further set of assorted single-file
        permissive notices. ThirdEye anchors on its organisation name because the
        bare grant template is shared with MIPS.
        """
        cases = {
            "SOFA.json": "SOFA",
            "softSurfer.json": "softSurfer",
            "Soundex.json": "Soundex",
            "TCP-wrappers.json": "TCP-wrappers",
            "TekHVC.json": "TekHVC",
            "TermReadKey.json": "TermReadKey",
            "ThirdEye.json": "ThirdEye",
            "TPDL.json": "TPDL",
            "TrustedQSL.json": "TrustedQSL",
            "TTWL.json": "TTWL",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_singletons_chunk8_cluster(self):
        """End-to-end detection for the final set of assorted single-file
        permissive notices, each a standalone rule on a distinctive grant/title
        clause.
        """
        cases = {
            "UnixCrypt.json": "UnixCrypt",
            "URT-RLE.json": "URT-RLE",
            "w3m.json": "w3m",
            "Widget-Workshop.json": "Widget-Workshop",
            "wwl.json": "wwl",
            "Xdebug-1.03.json": "Xdebug-1.03",
            "Xfig.json": "Xfig",
            "xkeyboard-config-Zinoviev.json": "xkeyboard-config-Zinoviev",
            "xzoom.json": "xzoom",
            "Zeeff.json": "Zeeff",
        }
        for filename, expected in cases.items():
            self._open_license_and_check(
                filename, self._find_licenses_matcher, expected
            )

    def test_parse_gpl(self):
        def _gpl_matcher(text):
            return license_parsers.parse_gpl(licenses.clean_comments(text), None, "GPL")

        self._open_license_and_check(
            "AGPL-1.0.json",
            self._find_licenses_matcher,
            "AGPL-1",
        )
        self._open_license_and_check_re(
            "AGPL-3.0.json",
            self._find_licenses_matcher,
            r"AGPL-3\+?$",
        )
        self._open_license_and_check_re(
            "GPL-1.0.json",
            self._find_licenses_matcher,
            r"GPL-1\+?$",
        )
        self._open_license_and_check(
            "GPL-1.0+.json",
            self._find_licenses_matcher,
            "GPL-1+",
        )
        self._open_license_and_check_re(
            "GPL-2.0.json",
            self._find_licenses_matcher,
            r"GPL-2\+?$",
        )
        self._open_license_and_check(
            "GPL-2.0+.json",
            self._find_licenses_matcher,
            "GPL-2+",
        )
        self._open_license_and_check_re(
            "GPL-3.0.json",
            self._find_licenses_matcher,
            r"GPL(?:-3\+?)?$",
        )
        self._open_license_and_check(
            "GPL-3.0+.json",
            self._find_licenses_matcher,
            "GPL-3+",
        )

        self._open_license_and_check(
            "GPL-2.0-with-autoconf-exception.json",
            _gpl_matcher,
            "GPL with AutoConf exception",
        )
        self._open_license_and_check(
            "GPL-2.0-with-bison-exception.json",
            _gpl_matcher,
            "GPL with Bison exception",
        )
        self._open_license_and_check(
            "GPL-2.0-with-classpath-exception.json",
            _gpl_matcher,
            "GPL with ClassPath exception",
        )
        self._open_license_and_check(
            "GPL-2.0-with-GCC-exception.json",
            _gpl_matcher,
            "GPL with GCC exception",
        )

        self._open_license_and_check(
            "GPL-3.0-with-autoconf-exception.json",
            _gpl_matcher,
            "GPL-3 with AutoConf-3 exception",
        )
        self._open_license_and_check(
            "GPL-3.0-with-GCC-exception.json",
            _gpl_matcher,
            "GPL-3 with GCC-3.1 exception",
        )

        self._open_exception_and_check(
            "389-exception.json",
            _gpl_matcher,
            "GPL-2 with 389 exception",
        )

        self._open_exception_and_check(
            "Autoconf-exception-2.0.json",
            _gpl_matcher,
            "GPL with AutoConf exception",
        )
        self._open_exception_and_check(
            "Autoconf-exception-3.0.json",
            _gpl_matcher,
            "GPL-3 with AutoConf-3 exception",
        )
        self._open_exception_and_check(
            "Bison-exception-2.2.json",
            _gpl_matcher,
            "GPL with Bison exception",
        )
        self._open_exception_and_check(
            "Classpath-exception-2.0.json",
            _gpl_matcher,
            "GPL with ClassPath exception",
        )
        self._open_exception_and_check(
            "CLISP-exception-2.0.json",
            _gpl_matcher,
            "GPL-2 with CLISP exception",
        )
        self._open_exception_and_check(
            "DigiRule-FOSS-exception.json",
            _gpl_matcher,
            "GPL with DigiRule-FOSS exception",
        )
        self._open_exception_and_check(
            "eCos-exception-2.0.json",
            _gpl_matcher,
            "GPL with eCos-2 exception",
        )
        self._open_exception_and_check(
            "Fawkes-Runtime-exception.json",
            _gpl_matcher,
            "GPL with ClassPath_MIF exception",
        )
        self._open_exception_and_check(
            "FLTK-exception.json",
            _gpl_matcher,
            "GPL with FLTK exception",
        )
        self._open_exception_and_check(
            "Font-exception-2.0.json",
            _gpl_matcher,
            "GPL with Font exception",
        )
        self._open_exception_and_check(
            "freertos-exception-2.0.json",
            _gpl_matcher,
            "GPL with FreeRTOS exception",
        )
        self._open_exception_and_check(
            "GCC-exception-2.0.json",
            _gpl_matcher,
            "GPL with GCC exception",
        )
        self._open_exception_and_check(
            "GCC-exception-3.1.json",
            _gpl_matcher,
            "GPL-3 with GCC-3.1 exception",
        )
        self._open_exception_and_check(
            "gnu-javamail-exception.json",
            _gpl_matcher,
            "GPL with GNU-JavaMail exception",
        )
        self._open_exception_and_check(
            "i2p-gpl-java-exception.json",
            _gpl_matcher,
            "GPL with i2p-Java exception",
        )
        self._open_exception_and_check(
            "Libtool-exception.json",
            _gpl_matcher,
            "GPL with LibTool exception",
        )
        self._open_exception_and_check(
            "LZMA-exception.json",
            _gpl_matcher,
            "GPL with LZMA exception",
        )
        self._open_exception_and_check(
            "mif-exception.json",
            _gpl_matcher,
            "GPL with MIF exception",
        )
        self._open_exception_and_check(
            "Nokia-Qt-exception-1.1.json",
            _gpl_matcher,
            "GPL-2.1 with Qt1.1 exception",
        )
        self._open_exception_and_check(
            "OCCT-exception-1.0.json",
            _gpl_matcher,
            "GPL-2.1 with OCCT-1 exception",
        )
        self._open_exception_and_check(
            "openvpn-openssl-exception.json",
            _gpl_matcher,
            "GPL with openvpn-openssl exception",
        )
        # handled in the find licenses part
        # TODO (maxy): fix me?
        self._open_exception_and_check(
            "Qwt-exception-1.0.json",
            self._find_licenses_matcher,
            "LGPL-2.1 with Qwt-1 exception",
        )
        self._open_exception_and_check(
            "u-boot-exception-2.0.json",
            _gpl_matcher,
            "GPL with u-boot exception",
        )
        self._open_exception_and_check(
            "WxWindows-exception-3.1.json",
            _gpl_matcher,
            "GPL with WxWindows-3.1+ exception",
        )

    def test_parse_lgpl(self):
        self._open_license_and_check_re(
            "LGPL-2.0.json",
            self._find_licenses_matcher,
            r"LGPL-2\+?",
        )
        self._open_license_and_check(
            "LGPL-2.0+.json",
            self._find_licenses_matcher,
            "LGPL-2+",
        )
        self._open_license_and_check_re(
            "LGPL-2.1.json",
            self._find_licenses_matcher,
            r"LGPL-2.1\+?",
        )
        self._open_license_and_check(
            "LGPL-2.1+.json",
            self._find_licenses_matcher,
            "LGPL-2.1+",
        )
        self._open_license_and_check(
            "LGPL-3.0.json",
            self._find_licenses_matcher,
            # LGPL-3 is formally GPL-3 + additional permissions; the SPDX
            # licenseText embeds the full GPL-3 text, so both groups fire.
            "GPL-3+ or LGPL-3+",
        )
        self._open_license_and_check(
            "LGPL-3.0+.json",
            self._find_licenses_matcher,
            "GPL-3+ or LGPL-3+",
        )
        self._open_license_and_check(
            "LGPLLR.json",
            self._find_licenses_matcher,
            "LGPLLR",
        )

    def test_lgpl_wxwindows_exception(self):
        # Regression: old WXwindows.json had combined LGPL+exception text.
        # The new SPDX data splits them; reconstruct the combination here.
        import json

        lgpl_text = json.loads(
            (Path("tests/testdata/licenses/LGPL-2.1.json")).read_text()
        )["licenseText"]
        wx_text = json.loads(
            (Path("tests/testdata/exceptions/WxWindows-exception-3.1.json")).read_text()
        )["licenseExceptionText"]
        combined = lgpl_text + "\n\n" + wx_text
        result = self._find_licenses_matcher(combined)
        self.assertEqual(result, "LGPL-2.1+ with WxWindows-3.1+ exception")

    def test_parse_gfdl(self):
        self._open_license_and_check(
            "GFDL-1.1.json",
            self._find_licenses_matcher,
            "GFDL-1.1+",
            ignore={"licenseText"},
        )
        self._open_license_and_check(
            "GFDL-1.2.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.2+",
        )
        self._open_license_and_check(
            "GFDL-1.3.json",
            self._find_licenses_matcher,
            "GFDL-NIV-1.3+",
        )

    def test_parse_lppl(self):
        self._open_license_and_check(
            "LPPL-1.0.json",
            self._find_licenses_matcher,
            "LPPL-1+",
        )
        self._open_license_and_check(
            "LPPL-1.1.json",
            self._find_licenses_matcher,
            "LPPL-1.1+",
        )
        self._open_license_and_check(
            "LPPL-1.2.json",
            self._find_licenses_matcher,
            "LPPL-1.2+",
        )
        # The titled licenseText carries the version letter (1.3a/1.3c); the
        # standardLicenseHeader only references "version 1.3", so it is ignored.
        self._open_license_and_check(
            "LPPL-1.3a.json",
            self._find_licenses_matcher,
            "LPPL-1.3a+",
            ignore={"standardLicenseHeader"},
        )
        self._open_license_and_check(
            "LPPL-1.3c.json",
            self._find_licenses_matcher,
            "LPPL-1.3c+",
            ignore={"standardLicenseHeader"},
        )

    def test_parse_mit(self):
        self._open_license_and_check("MIT.json", self._find_licenses_matcher, "Expat")
        self._open_license_and_check(
            "MIT-advertising.json",
            self._find_licenses_matcher,
            "MIT-advertising",
        )
        self._open_license_and_check(
            "MIT-CMU.json",
            self._find_licenses_matcher,
            "MIT-CMU",
        )
        self._open_license_and_check(
            "MIT-enna.json",
            self._find_licenses_matcher,
            "MIT-enna",
        )
        self._open_license_and_check(
            "MIT-feh.json",
            self._find_licenses_matcher,
            "MIT-feh",
        )
        self._open_license_and_check(
            "MITNFA.json",
            self._find_licenses_matcher,
            "MITNFA",
        )

    def test_parse_zpl(self):
        self._open_license_and_check(
            "ZPL-1.1.json",
            self._find_licenses_matcher,
            "ZPL-1.1",
        )
        self._open_license_and_check(
            "ZPL-2.0.json",
            self._find_licenses_matcher,
            "ZPL-2",
        )
        self._open_license_and_check(
            "ZPL-2.1.json",
            self._find_licenses_matcher,
            "ZPL-2.1",
        )


class TestDualLicenseHeader(unittest.TestCase):
    """Regression tests for dual-license header detection (e.g. cronexpr style).

    Files that list multiple licenses with a preamble like:
      License: pick the one which suits you :
        GPL v3 see <https://www.gnu.org/licenses/gpl.html>
        APL v2 see <http://www.apache.org/licenses/LICENSE-2.0>
    were not detected because the "License:? GPL" pattern requires GPL to
    follow "License:" immediately, which fails when preamble text intervenes.
    """

    @staticmethod
    def _find(text: str) -> set[str]:
        return set(licenses.find_licenses(licenses.clean_comments(text)))

    def test_gpl_url_with_version_in_text(self):
        # "GPL v3 see <gnu.org URL>" should detect GPL-3
        text = "/* License: GPL v3 see <https://www.gnu.org/licenses/gpl.html> */"
        self.assertIn("GPL-3", self._find(text))

    def test_apache_url_detects_version(self):
        # Apache URL with License: prefix should detect Apache-2
        text = "/* License: APL v2 see <http://www.apache.org/licenses/LICENSE-2.0> */"
        self.assertIn("Apache-2", self._find(text))

    def test_dual_license_preamble_detects_both(self):
        # Full dual-license header (cronexpr style) should detect both licenses
        text = (
            "/*!\n"
            " * License: pick the one which suits you :\n"
            " *   GPL v3 see <https://www.gnu.org/licenses/gpl.html>\n"
            " *   APL v2 see <http://www.apache.org/licenses/LICENSE-2.0>\n"
            " */"
        )
        found = self._find(text)
        self.assertIn("GPL-3", found)
        self.assertIn("Apache-2", found)

    def test_dual_license_best_variant(self):
        # "pick the one which suits you best:" variant (cronexpr_test.go)
        text = (
            "/*!\n"
            " * License: pick the one which suits you best:\n"
            " *   GPL v3 see <https://www.gnu.org/licenses/gpl.html>\n"
            " *   APL v2 see <http://www.apache.org/licenses/LICENSE-2.0>\n"
            " */"
        )
        found = self._find(text)
        self.assertIn("GPL-3", found)
        self.assertIn("Apache-2", found)

    def test_gpl_v2_url_variant(self):
        # GPL v2 referenced by versioned URL
        text = "/* License: GPL v2 see <https://www.gnu.org/licenses/gpl-2.0.html> */"
        self.assertIn("GPL-2", self._find(text))

    def test_apache_url_https(self):
        # HTTPS variant of Apache URL with License: prefix
        text = "/* License: see <https://www.apache.org/licenses/LICENSE-2.0> */"
        self.assertIn("Apache-2", self._find(text))


if __name__ == "__main__":
    unittest.main()
