Files
lvgl/tests/main.py
T

516 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import os
import platform
import shutil
import subprocess
import sys
from itertools import chain
from pathlib import Path
lvgl_test_dir = os.path.dirname(os.path.realpath(__file__))
lvgl_dir = os.path.dirname(lvgl_test_dir)
lvgl_script_path = os.path.join(lvgl_test_dir, "../scripts")
sys.path.append(lvgl_script_path)
from LVGLImage import ColorFormat, CompressMethod, LVGLImage
from perf import perf_test_options
lvgl_configs_dir = os.path.join(lvgl_test_dir, "configs")
lvgl_presets_dir = os.path.join(lvgl_dir, "configs", "defconfigs")
# Placeholder for the defconfig fragment that depends on the host OS, expanded
# by host_defconfigs().
HOST = "@host"
# Prefix for a fragment that is one of the presets in configs/defconfigs
PRESET = "preset:"
# Every build configuration is a list of defconfig fragments from tests/configs,
# merged in the given order so that a later fragment overrides an earlier one.
build_only_options = {
"OPTIONS_MINIMAL": {
"description": "Empty config preset",
"defconfigs": [PRESET + "empty", "minimal", "depth_16"],
},
}
if platform.system() == "Windows":
build_only_options["OPTIONS_WINDOWS_SW"] = {
"description": "Every Windows driver on its software backend, 32 bit color depth",
"defconfigs": ["full", "depth_32", HOST, "windows_sw", "builtin_heap"],
}
if platform.system() == "Linux":
build_only_options["OPTIONS_LINUX_SW"] = {
"description": "Every Linux driver on its software backend, 32 bit color depth",
"defconfigs": ["full", "depth_32", HOST, "linux_sw", "builtin_heap"],
}
build_only_options["OPTIONS_LINUX_OPENGLES"] = {
"description": "The Linux drivers that have an EGL backend, 32 bit color depth",
"defconfigs": [
"full",
"depth_32",
HOST,
"linux_sw",
"linux_egl",
"builtin_heap",
],
}
build_only_options["OPTIONS_NUTTX"] = {
"description": "NuttX port with full config, 32 bit color depth",
"defconfigs": ["full", "depth_32", HOST, "nuttx", "builtin_heap"],
}
test_options = {
"OPTIONS_TEST_SYSHEAP": {
"description": "Test config, system heap, 32 bit color depth",
"defconfigs": ["full", "depth_32", HOST, "sys_heap", "run_tests"],
},
"OPTIONS_TEST_DEFHEAP": {
"description": "Test config, LVGL heap, 32 bit color depth",
"defconfigs": [
"full",
"depth_32",
HOST,
"builtin_heap",
"defheap",
"run_tests",
],
},
"OPTIONS_TEST_VG_LITE": {
"description": "VG-Lite simulator with full config, 32 bit color depth",
"defconfigs": ["full", "depth_32", HOST, "sys_heap", "run_tests", "vg_lite"],
# Cover the other side of a VG-Lite configuration switch on 32 bit
"defconfigs_non_amd64": ["vg_lite_32bit"],
},
"OPTIONS_TEST_RISCV_V": {
"description": "RISC-V Vector emulation with full config, 32 bit color depth",
"defconfigs": ["full", "depth_32", HOST, "sys_heap", "run_tests", "riscv_v"],
},
"OPTIONS_TEST_NANOVG": {
"description": "NanoVG headless rendering with EGL, 32 bit color depth",
"defconfigs": ["full", "depth_32", HOST, "sys_heap", "run_tests", "nanovg"],
},
}
def get_build_config(options_name):
if options_name in build_only_options:
return build_only_options[options_name]
return test_options[options_name]
def get_option_description(option_name):
return get_build_config(option_name)["description"]
def non_amd64_build():
"""Return True when building for a non-amd64 (32 bit) target."""
return bool(os.environ.get("NON_AMD64_BUILD"))
def host_defconfigs():
"""Return the defconfig fragment for the host OS.
Only the platform neutral part: the file system driver, the libraries that
are not built on every platform, and the OS. Display and input drivers are
opted into per configuration, see linux_sw.defconfig."""
if platform.system() == "Windows":
return ["windows"]
if non_amd64_build():
return ["unix"]
return ["unix", "gstreamer"]
def target_cmake_args():
"""Return the cmake arguments that select the target architecture and
toolchain.
These have to be given on the command line rather than set from a listfile,
because the compiler and library checks LVGL runs while resolving its
dependencies have to see them."""
args = []
if platform.system() == "Windows":
vcpkg_root = os.environ.get("VCPKG_INSTALLATION_ROOT")
if vcpkg_root:
args += [
"-DVCPKG_TARGET_TRIPLET=x64-windows",
"-DCMAKE_TOOLCHAIN_FILE=%s"
% os.path.join(vcpkg_root, "scripts", "buildsystems", "vcpkg.cmake"),
]
if non_amd64_build():
print("Non AMD64 target is specified")
for flags in (
"CMAKE_C_FLAGS",
"CMAKE_CXX_FLAGS",
"CMAKE_EXE_LINKER_FLAGS",
"CMAKE_SHARED_LINKER_FLAGS",
):
args.append("-D%s=-m32" % flags)
# Search the i386 library paths first
args.append("-DCMAKE_LIBRARY_PATH=/usr/lib/i386-linux-gnu")
os.environ["PKG_CONFIG_PATH"] = os.pathsep.join(
filter(
None,
[
"/usr/lib/i386-linux-gnu/pkgconfig",
os.environ.get("PKG_CONFIG_PATH"),
],
)
)
return args
def get_defconfig_paths(options_name):
"""Return the defconfig paths for the given build options name."""
config = get_build_config(options_name)
fragments = ["common"]
for fragment in config["defconfigs"]:
if fragment == HOST:
fragments.extend(host_defconfigs())
else:
fragments.append(fragment)
if non_amd64_build():
fragments.extend(config.get("defconfigs_non_amd64", []))
paths = []
for fragment in fragments:
if fragment.startswith(PRESET):
directory, name = lvgl_presets_dir, fragment[len(PRESET) :]
else:
directory, name = lvgl_configs_dir, fragment
path = os.path.join(directory, "%s.defconfig" % name)
if not os.path.isfile(path):
raise SystemExit("defconfig fragment not found: %s" % path)
paths.append(path)
return paths
def delete_dir_ignore_missing(dir_path):
"""Recursively delete a directory and ignore if missing."""
try:
shutil.rmtree(dir_path)
except FileNotFoundError:
pass
def options_abbrev(options_name):
"""Return an abbreviated version of the option name."""
prefix = "OPTIONS_"
assert options_name.startswith(prefix)
return options_name[len(prefix) :].lower()
def get_base_build_dir(options_name):
"""Given the build options name, return the build directory name.
Does not return the full path to the directory - just the base name."""
return "build_%s" % options_abbrev(options_name)
def get_build_dir(options_name):
"""Given the build options name, return the build directory name.
Returns absolute path to the build directory."""
global lvgl_test_dir
return os.path.join(lvgl_test_dir, get_base_build_dir(options_name))
def build_tests(options_name, build_type, clean):
"""Build all tests for the specified options name."""
global lvgl_test_dir
print()
print()
label = "Building: %s: %s" % (
options_abbrev(options_name),
get_option_description(options_name),
)
print("=" * len(label))
print(label)
print("=" * len(label))
print(flush=True)
build_dir = get_build_dir(options_name)
if clean:
delete_dir_ignore_missing(build_dir)
os.chdir(lvgl_test_dir)
if not os.path.isdir(build_dir):
os.mkdir(build_dir)
# The tests are built as part of the LVGL project, which pulls in
# tests/CMakeLists.txt when LV_BUILD_TESTS is set.
cmake_args = [
"cmake",
"-GNinja",
"-S",
lvgl_dir,
"-B",
build_dir,
"-DCMAKE_BUILD_TYPE=%s" % build_type,
"-DLV_BUILD_TESTS=ON",
"-DLV_BUILD_DEFCONFIG_PATH=%s"
% ";".join(get_defconfig_paths(options_name)),
"-DLVGL_TEST_ENABLE=%s" % ("ON" if options_name in test_options else "OFF"),
]
cmake_args += target_cmake_args()
# Use ccache as a compiler launcher when available. This dramatically
# speeds up rebuilds across the build matrix and repeated invocations
# (notably under emulation), and is a no-op when ccache is absent.
if shutil.which("ccache"):
cmake_args += [
"-DCMAKE_C_COMPILER_LAUNCHER=ccache",
"-DCMAKE_CXX_COMPILER_LAUNCHER=ccache",
]
subprocess.check_call(cmake_args)
subprocess.check_call(["cmake", "--build", build_dir])
def run_tests(options_name, test_suite):
"""Run the tests for the given options name."""
print()
print()
label = "Running tests for %s" % options_abbrev(options_name)
print("=" * len(label))
print(label)
print("=" * len(label), flush=True)
os.chdir(get_build_dir(options_name))
args = [
"ctest",
"--timeout",
"300",
"--parallel",
str(os.cpu_count()),
"--output-on-failure",
]
if test_suite is not None:
args.extend(["--tests-regex", test_suite])
subprocess.check_call(args)
def generate_code_coverage_report():
"""Produce code coverage test reports for the test execution."""
global lvgl_test_dir
print()
print()
label = "Generating code coverage reports"
print("=" * len(label))
print(label)
print("=" * len(label))
print(flush=True)
os.chdir(lvgl_test_dir)
delete_dir_ignore_missing("report")
os.mkdir("report")
root_dir = os.path.dirname(
lvgl_test_dir
) # Get parent directory of tests (lvgl directory)
html_report_file = "report/index.html"
cmd = [
"gcovr",
"--gcov-ignore-parse-errors",
"--root",
root_dir,
"--html-details",
"--output",
html_report_file,
"--xml",
"report/coverage.xml",
"-j",
str(os.cpu_count()),
"--print-summary",
"--merge-mode-functions=merge-use-line-min",
"--html-title",
"LVGL Test Coverage",
]
subprocess.check_call(cmd)
print("Done: See %s" % html_report_file, flush=True)
def generate_test_images():
invalids = (ColorFormat.UNKNOWN, ColorFormat.RAW, ColorFormat.RAW_ALPHA)
formats = [i for i in ColorFormat if i not in invalids]
png_path = os.path.join(lvgl_test_dir, "test_images/pngs")
pngs = list(Path(png_path).rglob("*.[pP][nN][gG]"))
print(f"png files: {pngs}")
align_options = [1, 64]
for align in align_options:
for compress in CompressMethod:
compress_name = (
compress.name if compress != CompressMethod.NONE else "UNCOMPRESSED"
)
outputs = os.path.join(
lvgl_test_dir, f"test_images/stride_align{align}/{compress_name}/"
)
os.makedirs(outputs, exist_ok=True)
for fmt in formats:
for png in pngs:
img = LVGLImage().from_png(png, cf=fmt, background=0xFFFFFF)
img.adjust_stride(align=16)
output = os.path.join(outputs, f"{Path(png).stem}_{fmt.name}.bin")
img.to_bin(output, compress=compress)
output = os.path.join(
outputs,
f"{Path(png).stem}_{fmt.name}_{compress.name}_align{align}.c",
)
img.to_c_array(output, compress=compress)
print(
f"converting {os.path.basename(png)}, format: {fmt.name}, compress: {compress_name}"
)
def clean_dir_with_filter(directory, clean_filters):
for entry in os.listdir(directory):
entry_path = os.path.join(directory, entry)
if any(entry.endswith(filter) for filter in clean_filters):
continue
if os.path.isfile(entry_path):
os.remove(entry_path)
elif os.path.isdir(entry_path):
shutil.rmtree(entry_path)
def clean_build_dirs_with_filter(build_dir, clean_filters):
# The test executables are built in a "tests" subdirectory of the LVGL build
# directory, so the coverage data is spread over both. Clean the nested one
# first, then keep it while cleaning the top level.
tests_dir = os.path.join(build_dir, "tests")
if os.path.isdir(tests_dir):
clean_dir_with_filter(tests_dir, clean_filters)
clean_dir_with_filter(build_dir, clean_filters + ["tests"])
if __name__ == "__main__":
epilog = """This program builds and optionally runs the LVGL test programs.
There are two types of LVGL tests: "build", and "test". The build-only
tests, as their name suggests, only verify that the program successfully
compiles and links (with various build options). There are also a set of
tests that execute to verify correct LVGL library behavior.
"""
parser = argparse.ArgumentParser(
description="Build and/or run LVGL tests.", epilog=epilog
)
parser.add_argument(
"--build-options",
nargs=1,
choices=list(chain(build_only_options, test_options, perf_test_options)),
help="""the build option name to build or run. When
omitted all build configurations are used.
""",
)
parser.add_argument(
"--clean",
action="store_true",
default=False,
help="clean existing build artifacts before operation.",
)
parser.add_argument(
"--report", action="store_true", help="generate code coverage report for tests."
)
parser.add_argument(
"actions",
nargs="*",
choices=["build", "test"],
help="build: compile build tests, test: compile/run executable tests.",
)
parser.add_argument("--test-suite", default=None, help="select test suite to run")
parser.add_argument(
"--update-image",
action="store_true",
default=False,
help="Update test image using LVGLImage.py script",
)
parser.add_argument(
"--auto-clean",
action="store_true",
default=False,
help="Automatically clean build directories",
)
parser.add_argument(
"--keep-report",
action="store_true",
default=False,
help="Skip cleaning gcov report files when --auto-clean and --report are enabled",
)
args = parser.parse_args()
if args.update_image:
generate_test_images()
if args.build_options:
options_to_build = args.build_options
else:
if "build" in args.actions:
if "test" in args.actions:
options_to_build = {**build_only_options, **test_options}
else:
options_to_build = build_only_options
else:
options_to_build = test_options
clean_build_dirs = []
for options_name in options_to_build:
is_test = options_name in test_options
is_perf_test = options_name in perf_test_options
if is_perf_test:
perf_test_script = os.path.join(lvgl_test_dir, "perf.py")
try:
subprocess.check_call([perf_test_script, *(sys.argv[1:])])
except subprocess.CalledProcessError as e:
sys.exit(e.returncode)
continue
build_type = "Debug"
build_tests(options_name, build_type, args.clean)
if is_test:
try:
run_tests(options_name, args.test_suite)
except subprocess.CalledProcessError as e:
sys.exit(e.returncode)
if args.auto_clean:
build_dir = get_build_dir(options_name)
if args.report:
# Keep the files that gcovr analysis depends on and delete
# the rest to solve the storage capacity limit of GitHub CI
clean_filters = ["CMakeFiles", ".c"]
clean_build_dirs_with_filter(build_dir, clean_filters)
if args.keep_report:
# Retain the gcov report files for subsequent automated coverage analysis.
print(f"Keeping {build_dir} for report")
else:
print(f"Append {build_dir} to clean list")
clean_build_dirs.append(build_dir)
else:
# Remove all build directories directly
print(f"Removing {build_dir}")
shutil.rmtree(build_dir)
if args.report:
generate_code_coverage_report()
# Clean all build directories after report
for build_dir in clean_build_dirs:
print(f"Removing {build_dir}")
shutil.rmtree(build_dir)