/ devlog / devscripts / check_tests.sh
#!/bin/sh
# Checks that every test file names a class the project actually has, and
# that its cases are named the way the process asks.
#
#   check_tests.sh tests/FooTest.php ...   check the named test files
#   check_tests.sh --test                  run the self-test
#
# WHY THIS EXISTS. A test file belongs to a class: tests/FooTest.php
# tests Foo. Asked for cases covering how a search clamps its limit, the
# easy thing is to invent a test file named for the behavior rather than
# for a class, and to copy the arithmetic into the case instead of
# calling the class. Both happened: a SearchLimitTest was delivered for a
# class that does not exist, holding a copy of the clamp rather than a
# call to it. This refuses a test file whose class is not in src, and
# reports a case that never calls the class it is supposed to be testing.
#
# The second half is a warning rather than a refusal, since a case may
# reasonably work through a helper it built. The first half refuses.
#
# Exits non-zero when a test file names no class, so cut_patch.sh can
# gate on it.
SOURCE_DIR=${SOURCE_DIR:-src}

# Reports what is wrong with the named test files.
check_files()
{
    python3 - "$SOURCE_DIR" "$@" <<'PYEOF'
import os
import re
import sys

source_dir = sys.argv[1]
faults = 0
for path in sys.argv[2:]:
    name = os.path.basename(path)
    if not name.endswith("Test.php"):
        continue
    stands_for = name[:-len("Test.php")]
    found = []
    for here, folders, files in os.walk(source_dir):
        if stands_for + ".php" in files:
            found.append(os.path.join(here, stands_for + ".php"))
    # a class kept under a locale is named for its language in the test
    # file, since every locale has a class of the same short name: the
    # test of zh_CN's Tokenizer is ZhTokenizerTest
    if not found:
        for here, folders, files in os.walk(source_dir):
            for one in files:
                if not one.endswith(".php"):
                    continue
                short = one[:-len(".php")]
                if not stands_for.lower().endswith(short.lower()):
                    continue
                marker = stands_for[:-len(short)].lower()
                if marker and marker in here.lower().replace("_", ""):
                    found.append(os.path.join(here, one))
    # a test of a script the browser runs is named for that file with
    # Javascript on the end: BasicJavascriptTest checks basic.js
    if not found and stands_for.endswith("Javascript"):
        wanted = stands_for[:-len("Javascript")]
        spelled = re.sub(r'(?<!^)(?=[A-Z])', '_', wanted).lower()
        for here, folders, files in os.walk(source_dir):
            for one in files:
                if one in (spelled + ".js", wanted.lower() + ".js"):
                    found.append(os.path.join(here, one))
    # a class the project ships alongside its own, such as an atto
    # server's, is named in the test file's use lines rather than
    # sitting under src with that file name
    if not found:
        try:
            said = open(path, encoding="utf-8", errors="replace").read()
        except OSError:
            said = ""
        if re.search(r'^use\s+[\w\\]*\\' + re.escape(stands_for) +
            r'\s*;', said, re.M):
            found.append("named in a use line")
    if not found:
        print(f"{path}: there is no class named {stands_for}; a test file "
            f"belongs to a class, so name it for the class whose work it "
            f"checks")
        faults += 1
        continue
    try:
        text = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        continue
    # a case that never names the class or a property holding it is
    # probably checking a copy of the logic rather than the logic
    lower = stands_for[:1].lower() + stands_for[1:]
    for case in re.finditer(r'function\s+(\w+TestCase)\s*\([^)]*\)\s*\{',
        text):
        start = case.end()
        depth = 1
        at = start
        while at < len(text) and depth > 0:
            if text[at] == "{":
                depth += 1
            elif text[at] == "}":
                depth -= 1
            at += 1
        body = text[start:at]
        if stands_for in body or lower in body:
            continue
        if re.search(r'\$this->\w+->\w+\(', body):
            continue
        # A file may hold plain functions rather than a class, and a case
        # reaches those by calling one through the namespace they live
        # in. Utility.php is the one that does this.
        if re.search(r'\b[A-Z]\\\w+\(', body):
            continue
        # A case may work through a helper of its own in the same test
        # file, which reaches the class for it.
        if re.search(r'\$this->\w+\(', body):
            continue
        print(f"{path}: {case.group(1)} never reaches {stands_for}; a "
            f"case that repeats the class's arithmetic passes while the "
            f"class itself is wrong")
if faults:
    print("check: a test file above names no class")
    sys.exit(1)
print("check: every test file names a class")
sys.exit(0)
PYEOF
}

# Stands up a small tree of source and test files covering each shape and
# reports any the check gets wrong.
run_self_test()
{
    where=`mktemp -d`
    missed=0
    mkdir -p "$where/src/library" "$where/tests"
    cat > "$where/src/library/Adder.php" <<'PHPEOF'
<?php
class Adder
{
    public function add($one, $two)
    {
        return $one + $two;
    }
}
PHPEOF
    cat > "$where/tests/AdderTest.php" <<'PHPEOF'
<?php
class AdderTest extends UnitTest
{
    public function twoAndTwoTestCase()
    {
        $adder = new Adder();
        $this->assertEqual(4, $adder->add(2, 2));
    }
}
PHPEOF
    cat > "$where/tests/AddingTest.php" <<'PHPEOF'
<?php
class AddingTest extends UnitTest
{
    public function twoAndTwoTestCase()
    {
        $this->assertEqual(4, 2 + 2);
    }
}
PHPEOF
    if ! (cd "$where" && SOURCE_DIR=src sh "$OLDPWD/devlog/devscripts/check_tests.sh" \
        tests/AdderTest.php > /dev/null 2>&1); then
        echo "self-test: a test file naming a real class was refused"
        missed=`expr $missed + 1`
    fi
    if (cd "$where" && SOURCE_DIR=src sh "$OLDPWD/devlog/devscripts/check_tests.sh" \
        tests/AddingTest.php > /dev/null 2>&1); then
        echo "self-test: a test file naming no class was let through"
        missed=`expr $missed + 1`
    fi
    rm -rf "$where"
    if [ "$missed" -eq 0 ]; then
        echo "self-test: two cases tried, none went wrong"
        return 0
    fi
    echo "self-test: $missed of two cases went wrong"
    return 1
}

if [ "$1" = "--test" ]; then
    run_self_test
    exit $?
fi
if [ -z "${1:-}" ]; then
    echo "check: name the test files to check"
    exit 2
fi
check_files "$@"
X