Merge branch 'ipc4761-qa-tests' into 'master'

Draft: Add qa test for ODB++ output, including IPC-4761 features

See merge request kicad/code/kicad!2180
This commit is contained in:
Daniel Treffenstädt 2025-09-12 08:32:33 +02:00
commit 6733920996

399
qa/tests/cli/odbpp.py Normal file
View File

@ -0,0 +1,399 @@
#
# This program source code file is part of KiCad, a free EDA CAD application.
#
# Copyright (C) 2025 KiCad Developers
#
# 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 2 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
import logging
import re
from pathlib import Path
logger = logging.getLogger("cli_odbpp")
def is_date(string):
pattern = r"^# \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$"
return bool(re.match(pattern, string))
def contains_version_header(line):
return line.startswith("HDR KiCad EDA")
def is_float(value):
try:
float_value = float(value)
return not float_value.is_integer()
except ValueError:
return False
def compare_lines(line1, line2, allowed_deviation):
"""Compare two lines word by word.
Ignore lines with a date.
Ignore lines with a KiCad version header.
Allow certain deviation for floating point values.
"""
if is_date(line1) and is_date(line2):
logger.debug(f"ignoring line '{line1}'")
return True
if contains_version_header( line1 ) and contains_version_header( line2 ):
logger.debug( f"ignoring line '{line1}'" )
return True
words1 = line1.split()
words2 = line2.split()
if len(words1) != len(words2):
return False
for word1, word2 in zip(words1, words2):
if is_float(word1) and is_float(word2):
difference = abs( float( word1 ) - float( word2 ))
if difference > allowed_deviation:
return False
elif word1 != word2:
return False
return True
def compare_files(file1_path, file2_path, allowed_deviation, allowed_swaps = 2):
""" Compare the content of two files.
Dates and KiCad version headers are ignored.
floating values can have a certain deviation.
lines can be swapped in the features section, but not the header section.
"""
logger.debug( file1_path )
unmatched_lines_a = []
unmatched_lines_b = []
in_header = True
content_begin = ["#Feature attribute text strings",
"#Layer features"]
with open(file1_path, 'r') as file1, open(file2_path, 'r') as file2:
while True:
line1 = file1.readline()
line2 = file2.readline()
if not line1 and not line2:
break
line1 = line1.strip() if line1 else None
line2 = line2.strip() if line2 else None
if line1 is None:
logger.error(f"unexpected EOF in file {file1}")
return False
if line2 is None:
logger.error(f"unexpected EOF in file {file2}")
return False
if not compare_lines(line1, line2, allowed_deviation):
if in_header:
logger.error(f"{line1} != {line2}")
return False
# reordering of lines is allowed in content section.
unmatched_lines_a.append( line1 )
unmatched_lines_b.append( line2 )
if line1 in content_begin:
in_header = False
if len(unmatched_lines_a) == 0:
return True
num_swaps = 0
for line1, line2 in zip(sorted(unmatched_lines_a), sorted(unmatched_lines_b)):
if not compare_lines(line1, line2, allowed_deviation):
logger.error(f"{line1} != {line2}")
return False
num_swaps += 1
logger.warning( f"{line1} was swapped." )
return (num_swaps / 2) < allowed_swaps
class ODBPP_Matrix:
def __init__(self):
self.step = {}
self.layers = {}
def clear(self):
self.step = {}
self.layers = {}
def add_layer(self, data):
"""Adds a layer to the matrix.
"""
layer_name = data["NAME"]
if self.layers is not None and layer_name in self.layers:
logger.error(f"Duplicate layer name {layer_name}")
return False
self.layers[layer_name] = data
return True
def is_equal(self, other):
"""Checks this matrix for equality with a different matrix file.
The order of layers does not matter.
"""
if self.step != other.step:
return False
for layer_ours,layer_theirs in zip(sorted(self.layers), sorted(other.layers)):
if layer_ours != layer_theirs:
return False
return True
class ODBPP_Files:
def __init__(self, basepath):
self.reset(basepath)
def reset(self, basepath):
self.basepath = Path(basepath)
self.features = []
self.components = []
self.profile = self.basepath / "steps" / "pcb" / "profile"
self.stephdr = self.basepath / "steps" / "pcb" / "stephdr"
self.matrix = self.basepath / "matrix" / "matrix"
self.eda_data = self.basepath / "steps" / "pcb" / "eda" / "data"
self.layers_dir = self.basepath / "steps" / "pcb" / "layers"
def all(self):
"""Get all files as one list.
"""
return self.features + self.components + [self.profile, self.stephdr, self.matrix, self.eda_data]
def add_feature(self, layer_name):
"""Add a features layer files.
Some lines can be swapped in the features section, since this would generally not alter outputs.
"""
self.features.append(self.layers_dir / layer_name.lower() / "features")
def add_component(self, layer_name):
"""Add a component layer file.
They are distinct from feature files, since they should not allow any swapped lines.
"""
self.features.append(self.layers_dir / layer_name.lower() / "components")
def is_equal(self, other, allowed_deviation = 0.01):
"""Checks whether the set of files is equal to another set of files.
Checks for the correct number of files, and the contents of feature files.
A certain deviation is allowed, some files can have a certain number of swapped lines in the features section
to still be counted as equal.
"""
if len(self.features) != len(other.features):
return False
if len(self.components) != len(other.components):
return False
if not compare_files( self.profile, other.profile, allowed_deviation, 4 ):
return False
if not compare_files( self.stephdr, other.stephdr, allowed_deviation, 0 ):
return False
if not compare_files( self.eda_data, other.eda_data, allowed_deviation, 0 ):
return False
for ours, theirs in zip( self.components, other.components ):
if not compare_files( ours, theirs, allowed_deviation, 0 ):
return False
for ours, theirs in zip( self.features, other.features ):
if not compare_files( ours, theirs, allowed_deviation, 4 ):
return False
return True
class ODBPP:
def __init__(self, basepath):
self.basepath = Path(basepath)
self.matrix = ODBPP_Matrix()
self.files = ODBPP_Files( basepath )
def read_matrix(self):
"""Read the matrix file.
This function does rudimentary checks on the matrix file, like double layers etc.
"""
if not self.files.matrix.exists():
logger.error(f"Matrix file does not exist: {self.files.matrix}")
return False
self.matrix.clear()
current_type = None
current_fields = {}
step_at_line = -1
with open(self.files.matrix, "r") as f:
current_line = 0
for line in f:
current_line = current_line + 1
line = line.strip()
if current_type is None:
if line.startswith("STEP {"):
if step_at_line > 0:
logger.error(f"Unexpected STEP section at {current_line}. First STEP section at {step_at_line}.")
return False
current_type = "STEP"
elif line.startswith("LAYER {"):
current_type = "LAYER"
elif line.startswith("}"):
if current_type == "STEP":
self.matrix.step = current_fields
else:
if "NAME" not in current_fields:
logger.error(f"No Name defined for layer in matrix on line {current_line}")
return False
if not self.matrix.add_layer( current_fields ):
logger.error(f"Duplicate layer name found on line {current_line}")
return False
current_type = None
current_fields = {}
else:
key, value = map(str.strip, line.split("=", 1))
current_fields[key] = value
if current_type is not None:
logger.error("Unexpected EOF.")
return False
def build_file_list(self):
"""Build the list of files included in the ODB++ export.
"""
self.files.reset(self.basepath)
if self.matrix.step is None or self.matrix.layers is None:
if not self.files.matrix.exists():
logger.error(f"Matrix file does not exist: {self.matrix_file}")
return False
self.read_matrix()
for layer_name, layer_data in self.matrix.layers.items():
self.files.add_feature( layer_name )
if layer_data["TYPE"] == "COMPONENT":
self.files.add_component( layer_name )
def check_files_exist(self):
"""Checks if all files in the ODB++ directory structure exist.
"""
missing_files = []
for file_path in self.files.all():
if not file_path.exists():
missing_files.append(file_path)
if missing_files:
logger.warning("The following files are missing: %s", ", ".join(str(f) for f in missing_files))
return False
logger.info("All required files exist.")
return True
def is_equal(self, other):
"""Checks if this ODB++ export is equal to a differen ODB++ export.
"""
if not self.matrix.is_equal( other.matrix ):
return False
if not self.files.is_equal( other.files ):
return False
return True
def compare_exports(location_under_test, reference_location):
"""Compare an ODB++ export to a reference ODB++ export.
This does not validate the reference ODB++ export.
"""
reference = ODBPP( reference_location )
reference.read_matrix()
reference.build_file_list()
reference.check_files_exist()
lut = ODBPP( location_under_test )
if not lut.read_matrix():
return False
if not lut.build_file_list():
return False
if not lut.check_files_exist():
return False
return reference.is_equal( lut )