#!/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 "$@"