Files
gimp/plug-ins/python/tests/test-file-plug-ins/gimptestframework.py
Jacob Boerema 3478e1c00f plug-ins: add file-plug-in testing framework
Copied over from the original separate work in:
https://gitlab.gnome.org/Wormnest/gimp-file-plugin-tests
After that further improved and changed and added more file format
tests.

Added meson.build files to integrate it in our build, but we do not
install it for releases.
2024-04-26 12:28:47 -04:00

574 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# GIMP - The GNU Image Manipulation Program
# Copyright (C) 1995 Spencer Kimball and Peter Mattis
#
# gimptestframework.py
# Copyright (C) 2021-2024 Jacob Boerema
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""GIMP file plug-ins testing framework."""
import os
import configparser
import gi
gi.require_version('Gimp', '3.0')
from gi.repository import Gimp
from gi.repository import Gio
VERSION = "0.5"
AUTHORS = "Jacob Boerema"
YEARS = "2021-2024"
EXPECTED_FAIL = 0
EXPECTED_OK = 1
EXPECTED_TODO = 2
RESULT_FAIL = 0
RESULT_OK = 1
RESULT_CRASH = 2
class PluginTestConfig(object):
def __init__(self, log, path, testconfig):
self.valid_config = False
self.enabled = False
self.plugin = testconfig.get('plugin-import')
if self.plugin is None:
self.plugin = testconfig.get('plugin')
if self.plugin is not None:
log.warning("Test is using deprecated 'plugin' parameter. Use 'plugin-import' instead!")
else:
log.error("Missing required 'plugin-import' parameter!")
return
tests = testconfig.get('tests')
if tests is None:
log.error("Missing required 'tests' parameter!")
return
self.tests = path + tests
enabled = testconfig.get('enabled')
if enabled == 'True':
self.enabled = True
self.extension = testconfig.get('extension','<missing>')
self.valid_config = True
# preliminary export testing support
self.export_plugin = testconfig.get('plugin-export')
self.export_tests = testconfig.get('tests-export')
self.export_enabled = testconfig.get('enabled-export')
class ConfigLoader(object):
def __init__(self, log, config_path, config_file):
self.config_path = config_path
self.config_file = config_file
log.debug(f"Using config file: {self.config_path}{self.config_file}")
self.tests = []
self.export_tests = []
self.problems = 0
self.disabled = 0
self.config = configparser.ConfigParser()
self.config.read(self.config_path + self.config_file)
msg = "Available tests:"
for key in self.config.sections():
if key == 'main':
pass
else:
# Add test config
test = PluginTestConfig(log, self.config_path, self.config[key])
if test.valid_config:
self.tests.append(test)
msg += f"\nPlugin: {test.plugin}, test config: {test.tests}, enabled: {test.enabled}"
if not test.enabled:
self.disabled += 1
else:
log.warning(f"\nSkipping invalid config for {key}!")
self.problems += 1
if test.export_plugin is not None and test.export_tests is not None:
log.info(f"Export test found for {key}")
self.export_tests.append(test)
log.gimp_verbose_message(msg)
class FileLoadTest(object):
def __init__(self, file_load_plugin_name, image_type, data_path, log):
self.log = log
self.data_root = data_path
self.plugin_name = file_load_plugin_name
self.image_type = image_type
self.unexpected_success_images = []
self.unexpected_failure_images = []
self.total_tests = 0
self.total_failed = 0
self.total_todo = 0
self.total_ok = 0
self.total_crash = 0
def run_file_load(self, image_file, expected):
if not os.path.exists(image_file):
msg = "Regression loading " + image_file + ". File does not exist!"
self.log.error("--> " + msg)
return RESULT_FAIL
pdb_proc = Gimp.get_pdb().lookup_procedure(self.plugin_name)
if pdb_proc is None:
msg = "Plug-in procedure '" + self.plugin_name + "' not found!"
self.log.error("--> " + msg)
return RESULT_FAIL
pdb_config = pdb_proc.create_config()
pdb_config.set_property('run-mode', Gimp.RunMode.NONINTERACTIVE)
pdb_config.set_property('file', Gio.File.new_for_path(image_file))
result = pdb_proc.run(pdb_config)
status = result.index(0)
img = result.index(1)
if (status == Gimp.PDBStatusType.SUCCESS and img is not None and img.is_valid()):
self.log.info ("Loading succeeded for " + image_file)
img.delete()
if expected == EXPECTED_FAIL:
self.unexpected_success_images.append(image_file)
msg = "Regression loading " + image_file + ". Loading unexpectedly succeeded."
self.log.error("--> " + msg)
return RESULT_FAIL
elif expected == EXPECTED_TODO:
self.unexpected_success_images.append(image_file)
msg = "Loading unexpectedly succeeded for test marked TODO. Image: " + image_file
self.log.error("--> " + msg)
return RESULT_FAIL
return RESULT_OK
else:
self.log.info ("Loading failed for " + image_file)
if status == Gimp.PDBStatusType.CALLING_ERROR:
# A calling error indicates the plug-in crashed, which should
# always be considered failure!
if expected == EXPECTED_OK:
self.unexpected_failure_images.append(image_file)
elif expected == EXPECTED_TODO:
self.unexpected_success_images.append(image_file)
msg = "Plug-in crashed while loading " + image_file + "."
self.log.error("--> " + msg)
return RESULT_CRASH
elif expected == EXPECTED_OK:
self.unexpected_failure_images.append(image_file)
msg = "Regression loading " + image_file + ". Loading unexpectedly failed."
self.log.error("--> " + msg)
return RESULT_FAIL
return RESULT_OK
def load_test_images(self, test_images):
test_list = []
if not os.path.exists(test_images):
msg = "Path does not exist: " + test_images
self.log.error(msg)
return None
with open(test_images, encoding='UTF-8') as f:
try:
content = f.readlines()
except UnicodeDecodeError as err:
self.log.error(f"Invalid encoding for {test_images}: {err}")
return None
# strip whitespace
content = [x.strip() for x in content]
for line in content:
data = line.split(",")
data[0] = data[0].strip()
if len(data) > 1:
expected_str = data[1].strip()
else:
expected_str = 'EXPECTED_OK'
data.append(expected_str)
if expected_str == 'EXPECTED_FAIL':
data[1] = EXPECTED_FAIL
elif expected_str == 'EXPECTED_TODO':
data[1] = EXPECTED_TODO
elif expected_str == 'SKIP':
continue
else:
# Assume that anything else is supposed to load OK
data[1] = EXPECTED_OK
test_list.append(data)
return test_list
def run_tests(self, image_folder, test_images, test_description):
test_fail = 0
test_ok = 0
test_todo = 0
test_crash = 0
test_total = 0
test_images_list = self.load_test_images(test_images)
if not test_images_list:
# Maybe we should create another type of test failure (test_filesystem?)
test_fail += 1
test_crash += 1
self.total_failed += test_fail
self.total_crash += test_crash
msg = f"No images found for '{test_description}'.'"
self.log.error(msg)
return
test_total = len(test_images_list)
for imgfile, expected in test_images_list:
test_result = self.run_file_load(self.data_root + image_folder + imgfile, expected)
if test_result == RESULT_FAIL:
test_fail += 1
elif test_result == RESULT_OK:
test_ok += 1
elif test_result == RESULT_CRASH:
test_crash += 1
test_fail += 1
else:
test_crash += 1
test_fail += 1
msg = "Invalid test result value: " + str(test_result)
self.log.error(msg)
if expected == EXPECTED_TODO:
test_todo += 1
result_msg = "Test: " + test_description + "\n"
if test_crash > 0:
result_msg += "Number of plug-in crashes: " + str(test_crash) + "\n"
if test_fail > 0:
result_msg += "Failed " + str(test_fail) + " of " + str(test_total) + " tests"
if test_todo > 0:
result_msg += ", and " + str(test_todo) + " known test failures."
elif test_todo > 0:
result_msg += "All " + str(test_total) + " tests succeeded, but there are " + str(test_todo) + " known test failures."
else:
result_msg += "All " + str(test_total) + " tests succeeded."
self.log.info("\n--- results ---")
self.log.info(result_msg)
if ((test_fail > 0 or test_todo > 0) and self.log.interactive):
Gimp.message (result_msg)
self.total_tests += test_total
self.total_todo += test_todo
self.total_failed += test_fail
self.total_ok += test_ok
self.total_crash += test_crash
def show_results_total(self):
msg = "\n----- Test result totals -----"
msg += f"\nTotal {self.image_type} tests: {self.total_tests}"
if self.total_crash > 0:
msg += f"\nTests crashed: {self.total_crash}"
msg += f"\nTests failed: {self.total_failed}"
if self.total_crash > 0:
msg += f" (including crashes)"
msg += f"\nTests todo: {self.total_todo}"
self.log.message(msg)
msg = ""
for img in self.unexpected_success_images:
msg += f"{img}\n"
if len(msg) > 0:
msg = "\nImages that unexpectedly loaded:\n" + msg
self.log.message(msg)
msg = ""
for img in self.unexpected_failure_images:
msg += f"{img}\n"
if len(msg) > 0:
msg = "\nImages that unexpectedly failed to load:\n" + msg
self.log.message(msg)
class RunTests(object):
def __init__(self, test, config_path, data_path, log):
self.test_count = 0
self.regression_count = 0
if os.path.exists(test.tests):
self.plugin_test = FileLoadTest(test.plugin, test.extension, data_path, log)
cfg = configparser.ConfigParser()
cfg.read(test.tests)
for subtest in cfg.sections():
if not 'description' in cfg[subtest]:
description = "Missing description for " + test.extension
else:
description = cfg[subtest]['description']
enabled = True
if 'enabled' in cfg[subtest]:
if cfg[subtest]['enabled'] == 'False':
enabled = False
folder = cfg[subtest]['folder']
files = self.plugin_test.data_root + folder + cfg[subtest]['files']
if enabled:
self.plugin_test.run_tests(folder, files, description)
else:
log.info(f"Testing is disabled for: {description}.")
self.plugin_test.show_results_total()
self.test_count = self.plugin_test.total_tests
self.regression_count = self.plugin_test.total_failed
else:
self.plugin_test = None
log.error("Test path " + test.tests + " does not exist!")
self.regression_count = 1
def get_unexpected_success_regressions(self):
return self.plugin_test.unexpected_success_images
def get_unexpected_failure_regressions(self):
return self.plugin_test.unexpected_failure_images
def get_todo_count(self):
return self.plugin_test.total_todo
def get_crash_count(self):
return self.plugin_test.total_crash
class GimpTestRunner(object):
def __init__(self, log, test_type, test_cfg):
self.log = log
self.test_type = test_type
self.test_cfg = test_cfg
self.tests_total = 0
self.error_total = 0
self.todo_total = 0
self.crash_total = 0
self.unexpected_success_images = []
self.unexpected_failure_images = []
def print_header(self, test_type):
divider = "\n--------------------------------"
msg = f"\nGIMP file plug-in tests version {VERSION}\n"
#msg += f"\n{AUTHORS}, {YEARS}\n"
msg += f"\n--- Starting {test_type} test run ---"
msg += divider
if self.log.verbose:
msg += f"\nConfig: {self.test_cfg.config_folder + self.test_cfg.config_file}"
msg += f"\nLog: {self.test_cfg.log_file}"
msg += f"\nData path: {self.test_cfg.data_folder}"
self.log.message(msg)
def print_footer(self, msg):
msg += "\n--------------------------------"
self.log.message(msg)
def list_regressions(self):
regression_list = ""
msg = ""
extra = "\n"
for img in self.unexpected_success_images:
msg += f"\n{img}"
if len(msg) > 0:
regression_list = "\nImages that unexpectedly loaded:" + msg + extra
extra = ""
msg = ""
for img in self.unexpected_failure_images:
msg += f"\n{img}"
if len(msg) > 0:
regression_list += "\nImages that unexpectedly failed to load:" + msg + extra
return regression_list
def run_tests(self):
self.print_header(self.test_type)
# Load test config
cfg = ConfigLoader(self.log, self.test_cfg.config_folder, self.test_cfg.config_file)
# Actual tests
for test in cfg.tests:
if test.enabled:
self.log.consoleinfo(f"\nTesting {test.extension} import using {test.plugin}...\n")
plugin_tests = RunTests(test, cfg.config_path, self.test_cfg.data_folder, self.log)
self.tests_total += plugin_tests.test_count
self.error_total += plugin_tests.regression_count
if plugin_tests.plugin_test is not None:
self.todo_total += plugin_tests.get_todo_count()
self.crash_total += plugin_tests.get_crash_count()
if plugin_tests.regression_count > 0:
temp = plugin_tests.get_unexpected_success_regressions()
if len(temp) > 0:
self.unexpected_success_images.extend(temp)
temp = plugin_tests.get_unexpected_failure_regressions()
if len(temp) > 0:
self.unexpected_failure_images.extend(temp)
else:
self.crash_total += 1
self.log.consoleinfo(f"\nFinished testing {test.extension}\n")
else:
self.log.consoleinfo(f"Testing {test.extension} import using {test.plugin} is disabled.")
msg = "\n--------- Test results ---------"
if cfg.disabled > 0:
msg += f"\nNumber of test sections disabled: {cfg.disabled}"
if cfg.problems > 0:
msg += f"\nNumber of test sections skipped due to configuration errors: {cfg.problems}"
msg += f"\nTotal number of tests executed: {self.tests_total}"
msg += f"\nTotal number of regressions: {self.error_total}"
if self.crash_total > 0:
msg += f"\nTotal number of crashes: {self.crash_total}"
if self.todo_total > 0:
msg += f"\nTotal number of todo tests: {self.todo_total}"
if self.error_total > 0:
msg += "\n" + self.list_regressions()
self.print_footer(msg)
class GimpExportTestGroup(object):
def __init__(self, group_test_cfg, group_name, group_description,
input_folder, output_folder):
self.group_test_cfg = group_test_cfg
self.group_name = group_name
self.group_description = group_description
self.input_folder = input_folder
self.output_folder = output_folder
class GimpExportTestSections(object):
def __init__(self, test, config_path, data_path, log):
self.test_groups = []
self.test_count = 0
self.regression_count = 0
self.export_test_path = config_path + test.export_tests
if os.path.exists(self.export_test_path):
cfg = configparser.ConfigParser()
cfg.read(self.export_test_path)
for subtest in cfg.sections():
if not 'description' in cfg[subtest]:
description = "Missing description for " + test.extension
log.warning(f"Missing description for {test.extension}")
else:
description = cfg[subtest]['description']
log.info(f"Test: {description}")
enabled = True
if 'enabled' in cfg[subtest]:
if cfg[subtest]['enabled'] == 'False':
enabled = False
folder_input = cfg[subtest]['folder-input']
folder_output = cfg[subtest]['folder-output']
if enabled:
group = GimpExportTestGroup(test, test.extension, description,
folder_input, folder_output)
self.test_groups.append(group)
else:
log.info(f"Testing is disabled for: {description}.")
else:
log.error("Test path " + test.export_tests + " does not exist!")
self.regression_count = 1
class GimpExportTest(object):
def __init__(self, group_name, log):
self.group_name = group_name
self.log = log
self.base_path = None
self.input_path = None
self.output_path = None
self.file_import = None
self.file_export = None
def _setup(self, group_config, test_data_path):
self.base_path = test_data_path
self.input_path = self.base_path + group_config.input_folder
self.output_path = self.base_path + group_config.output_folder
if self.base_path is None or self.input_path is None or self.output_path is None:
return False
self.file_import = group_config.group_test_cfg.plugin
self.file_export = group_config.group_test_cfg.export_plugin
if self.file_import is None or self.file_export is None:
return False
return True
def setup(self, group_config):
pass
def teardown(self, group_config):
pass
def run_test(self, group_config):
pass
def execute(self, group_config, test_data_path):
if not self._setup(group_config, test_data_path):
self.log.error("Invalid config, can't execute test!")
return
self.setup(group_config)
self.run_test(group_config)
self.teardown(group_config)
class GimpExportTestRunner(GimpTestRunner):
def __init__(self, log, test_type, test_cfg):
self.cfg = None
self.log = log
self.test_type = test_type
self.test_cfg = test_cfg
self.sections = None
self.tests = []
super().__init__(log, test_type, test_cfg)
def load_test_configs(self):
self.cfg = ConfigLoader(self.log, self.test_cfg.config_folder,
self.test_cfg.config_file)
for test in self.cfg.export_tests:
if test.export_enabled:
self.sections = GimpExportTestSections(test, self.cfg.config_path,
self.test_cfg.data_folder,
self.log)
else:
pass
def get_test_group_config(self, test_name):
#FIXME maybe have this sorted and use a search...
for test in self.sections.test_groups:
if test.group_name == test_name:
return test
return None
def add_test(self, test_class):
self.log.info(f"Adding tests for {test_class.group_name}")
self.tests.append(test_class)
def run_tests(self):
self.print_header(self.test_type)
for test in self.tests:
self.log.message(f"Running tests for {test.group_name}")
test_config = self.get_test_group_config(test.group_name)
if test_config is None:
self.log.error(f"Could not find configuration for {test.group_name}!")
else:
test.execute(test_config, self.test_cfg.data_folder)
self.print_footer("Export tests finished")
def show_results(self):
#FIXME export results are not implemented yet
pass