From c4778e37bdbbbdf6918b2839f83767dbd1ab48ab Mon Sep 17 00:00:00 2001 From: John Beard Date: Mon, 4 Jun 2018 13:48:46 +0100 Subject: [PATCH] Add a basic plot test --- .gitignore | 4 +- README.md | 12 +- src/kiplot/plot_config.py | 23 +++ tests/board_samples/simple_2layer.kicad_pcb | 146 +++++++++++++++++++ tests/conftest.py | 9 ++ tests/test_plot/README.md | 13 ++ tests/test_plot/__init__.py | 0 tests/test_plot/plotting_test_utils.py | 114 +++++++++++++++ tests/test_plot/test_simple_2layer.py | 103 +++++++++++++ tests/yaml_samples/simple_2layer.kiplot.yaml | 36 +++++ 10 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 tests/board_samples/simple_2layer.kicad_pcb create mode 100644 tests/conftest.py create mode 100644 tests/test_plot/README.md create mode 100644 tests/test_plot/__init__.py create mode 100644 tests/test_plot/plotting_test_utils.py create mode 100644 tests/test_plot/test_simple_2layer.py create mode 100644 tests/yaml_samples/simple_2layer.kiplot.yaml diff --git a/.gitignore b/.gitignore index 58d36d85..c141dcc4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ wheels/ MANIFEST -.pytest_cache \ No newline at end of file +.pytest_cache + +*.kicad_pcb-bak diff --git a/README.md b/README.md index 5a8ecbf8..bbb6061f 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,20 @@ export PYTHONPATH=~/local/kicad/lib/python2.7/site-packages export LD_LIBRARY_PATH=~/local/kicad/lib64 ``` +If you've installed "normally", you should not need to do this. + +## Testing + +There are some tests. Run them with pytest: + +``` +pytest +``` + # TODO list There are some things that still need work: * DRC checking - that can't be done over the Python interface yet. If/when this is added to KiCad, KiPlot will be able to also be used for DRC - functional tests instead of a complex additonal test harness in C++. \ No newline at end of file + functional tests instead of a complex additonal test harness in C++. diff --git a/src/kiplot/plot_config.py b/src/kiplot/plot_config.py index 784fb293..8bbead09 100644 --- a/src/kiplot/plot_config.py +++ b/src/kiplot/plot_config.py @@ -1,4 +1,6 @@ +import os + import pcbnew from . import error @@ -446,6 +448,27 @@ class PlotConfig(object): def add_output(self, new_op): self._outputs.append(new_op) + def get_output_by_name(self, output_name): + """ + Gets an output with a given name. + + @param output_name the name of the output to find + """ + for o in self.outputs: + + if o.name == output_name: + return o + + return None + + def resolve_output_dir_for_name(self, output_name): + """ + Get the output dir for a given output name + """ + + o = self.get_output_by_name(output_name) + return os.path.join(self.outdir, o.outdir) if o else None + def validate(self): errs = [] diff --git a/tests/board_samples/simple_2layer.kicad_pcb b/tests/board_samples/simple_2layer.kicad_pcb new file mode 100644 index 00000000..7c94bf20 --- /dev/null +++ b/tests/board_samples/simple_2layer.kicad_pcb @@ -0,0 +1,146 @@ +(kicad_pcb (version 20171130) (host pcbnew "(5.0.0-rc2-76-gb5f63567d)") + + (general + (thickness 1.6) + (drawings 5) + (tracks 4) + (zones 0) + (modules 1) + (nets 1) + ) + + (page A4) + (title_block + (title "Simple Plotting Test") + (date 2018-06-04) + (rev A) + (company "KiPlot - KiCad Plotting Driver") + ) + + (layers + (0 F.Cu signal) + (31 B.Cu signal) + (32 B.Adhes user) + (33 F.Adhes user) + (34 B.Paste user) + (35 F.Paste user) + (36 B.SilkS user) + (37 F.SilkS user) + (38 B.Mask user) + (39 F.Mask user) + (40 Dwgs.User user) + (41 Cmts.User user) + (42 Eco1.User user) + (43 Eco2.User user) + (44 Edge.Cuts user) + (45 Margin user) + (46 B.CrtYd user) + (47 F.CrtYd user) + (48 B.Fab user) + (49 F.Fab user) + ) + + (setup + (last_trace_width 0.25) + (trace_clearance 0.2) + (zone_clearance 0.508) + (zone_45_only no) + (trace_min 0.2) + (segment_width 0.2) + (edge_width 0.15) + (via_size 0.8) + (via_drill 0.4) + (via_min_size 0.4) + (via_min_drill 0.3) + (uvia_size 0.3) + (uvia_drill 0.1) + (uvias_allowed no) + (uvia_min_size 0.2) + (uvia_min_drill 0.1) + (pcb_text_width 0.3) + (pcb_text_size 1.5 1.5) + (mod_edge_width 0.15) + (mod_text_size 1 1) + (mod_text_width 0.15) + (pad_size 1.524 1.524) + (pad_drill 0.762) + (pad_to_mask_clearance 0.2) + (aux_axis_origin 0 0) + (visible_elements FFFFFF7F) + (pcbplotparams + (layerselection 0x010fc_ffffffff) + (usegerberextensions false) + (usegerberattributes false) + (usegerberadvancedattributes false) + (creategerberjobfile false) + (excludeedgelayer true) + (linewidth 0.150000) + (plotframeref false) + (viasonmask false) + (mode 1) + (useauxorigin false) + (hpglpennumber 1) + (hpglpenspeed 20) + (hpglpendiameter 15.000000) + (psnegative false) + (psa4output false) + (plotreference true) + (plotvalue true) + (plotinvisibletext false) + (padsonsilk false) + (subtractmaskfromsilk false) + (outputformat 1) + (mirror false) + (drillshape 1) + (scaleselection 1) + (outputdirectory "")) + ) + + (net 0 "") + + (net_class Default "This is the default net class." + (clearance 0.2) + (trace_width 0.25) + (via_dia 0.8) + (via_drill 0.4) + (uvia_dia 0.3) + (uvia_drill 0.1) + ) + + (module TestPoint:TestPoint_THTPad_2.0x2.0mm_Drill1.0mm (layer F.Cu) (tedit 5B1533F4) (tstamp 5B15541F) + (at 140 100) + (descr "THT rectangular pad as test Point, square 2.0mm_Drill1.0mm side length, hole diameter 1.0mm") + (tags "test point THT pad rectangle square") + (attr virtual) + (fp_text reference TP1 (at 0 -2) (layer F.SilkS) + (effects (font (size 1 1) (thickness 0.15))) + ) + (fp_text value TestPoint2mm (at 0 2.05) (layer F.Fab) + (effects (font (size 1 1) (thickness 0.15))) + ) + (fp_line (start 1.5 1.5) (end -1.5 1.5) (layer F.CrtYd) (width 0.05)) + (fp_line (start 1.5 1.5) (end 1.5 -1.5) (layer F.CrtYd) (width 0.05)) + (fp_line (start -1.5 -1.5) (end -1.5 1.5) (layer F.CrtYd) (width 0.05)) + (fp_line (start -1.5 -1.5) (end 1.5 -1.5) (layer F.CrtYd) (width 0.05)) + (fp_line (start -1.2 1.2) (end -1.2 -1.2) (layer F.SilkS) (width 0.12)) + (fp_line (start 1.2 1.2) (end -1.2 1.2) (layer F.SilkS) (width 0.12)) + (fp_line (start 1.2 -1.2) (end 1.2 1.2) (layer F.SilkS) (width 0.12)) + (fp_line (start -1.2 -1.2) (end 1.2 -1.2) (layer F.SilkS) (width 0.12)) + (fp_text user %R (at 0 -2) (layer F.Fab) + (effects (font (size 1 1) (thickness 0.15))) + ) + (pad 1 thru_hole rect (at 0 0) (size 2 2) (drill 1) (layers *.Cu *.Mask)) + ) + + (gr_arc (start 145 98) (end 148 98) (angle -90) (layer Edge.Cuts) (width 0.2)) + (gr_line (start 145 95) (end 132 95) (layer Edge.Cuts) (width 0.2)) + (gr_line (start 148 106) (end 148 98) (layer Edge.Cuts) (width 0.2)) + (gr_line (start 132 106) (end 148 106) (layer Edge.Cuts) (width 0.2)) + (gr_line (start 132 95) (end 132 106) (layer Edge.Cuts) (width 0.2)) + + (segment (start 140 100) (end 143 100) (width 1) (layer F.Cu) (net 0)) + (segment (start 140 100) (end 142 102) (width 1) (layer B.Cu) (net 0)) + (segment (start 142 102) (end 143 102) (width 1) (layer B.Cu) (net 0)) + (segment (start 134 99) (end 134 101) (width 1) (layer F.Cu) (net 0)) + +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..549a7831 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +""" +Test configuration +""" + + +def pytest_addoption(parser): + parser.addoption("--plot_dir", action="store", default=None, + help="the plot dir to use (omit to use a temp dir). " + "If given, plots will _not_ be cleared after testing.") diff --git a/tests/test_plot/README.md b/tests/test_plot/README.md new file mode 100644 index 00000000..6e80e2dd --- /dev/null +++ b/tests/test_plot/README.md @@ -0,0 +1,13 @@ +This directory contains tests for full testing of KiCad plots. +This tests: + +* KiPlot's config parsing and driving of KiCad +* KiCad's own plotting code + +Generally, boards are drawn from `../board_samples` and configs from +`yaml_samples`. Sometimes, the YAML samples are modified by the test +runners to avoid having hundreds of them! + +Bug that should be tested for: + +* https://bugs.launchpad.net/kicad/+bug/1775037 \ No newline at end of file diff --git a/tests/test_plot/__init__.py b/tests/test_plot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_plot/plotting_test_utils.py b/tests/test_plot/plotting_test_utils.py new file mode 100644 index 00000000..8091fb5c --- /dev/null +++ b/tests/test_plot/plotting_test_utils.py @@ -0,0 +1,114 @@ + +import os +import shutil +import tempfile +import logging +import pytest + + +from kiplot import kiplot +from kiplot import config_reader + + +KICAD_PCB_EXT = '.kicad_pcb' + + +class KiPlotTestContext(object): + + def __init__(self, test_name): + self.cfg = None + + # The name used for the test output dirs and other logging + self.test_name = test_name + + # The name of the PCB board file (will be interpolated into the plot + # files by pcbnewm so we need to know + self.board_name = None + + # The actual board file that will be loaded + self.board_file = None + + # The directory under which to place plots (None: use a temp dir) + self.plot_dir = pytest.config.getoption('plot_dir') + + # The actual output dir for this plot run + self._output_dir = None + # Clean the output dir afterwards (true for temp dirs) + self._del_dir_after = self.plot_dir is None + + def _get_text_cfg_dir(self): + + this_dir = os.path.dirname(os.path.realpath(__file__)) + + return os.path.join(this_dir, '../yaml_samples') + + def _get_board_cfg_dir(self): + + this_dir = os.path.dirname(os.path.realpath(__file__)) + + return os.path.join(this_dir, '../board_samples') + + def load_yaml_config_file(self, filename): + """ + Reads a config from a YAML file + """ + + cfg_file = os.path.join(self._get_text_cfg_dir(), filename) + + cr = config_reader.CfgYamlReader() + + with open(cfg_file) as cf_file: + cfg = cr.read(cf_file) + + self.cfg = cfg + + def _load_board_file(self, filename=None): + """ + Load the named board. + + @param filename: a filename to load, or None to load the relevant + board name from the board sample dir + """ + + if filename is None: + self.board_file = os.path.join(self._get_board_cfg_dir(), + self.board_name + KICAD_PCB_EXT) + else: + self.board_file = filename + + assert os.path.isfile(self.board_file) + + def _set_up_output_dir(self): + + if not self.plot_dir: + # create a tmp dir + self.output_dir = tempfile.mkdtemp( + prefix='tmp_kiplot_{}'.format(self.test_name)) + + else: + self.output_dir = os.path.join(self.plot_dir, self.test_name) + # just create the dir + if os.path.isdir(self.output_dir): + # exists, that's OK + pass + else: + os.makedirs(self.output_dir) + + self.cfg.outdir = self.output_dir + logging.info(self.output_dir) + + def clean_up(self): + + if self._del_dir_after: + shutil.rmtree(self.output_dir) + + def do_plot(self): + + self.cfg.validate() + + self._load_board_file(self.board_file) + + self._set_up_output_dir() + + plotter = kiplot.Plotter(self.cfg) + plotter.plot(self.board_file) diff --git a/tests/test_plot/test_simple_2layer.py b/tests/test_plot/test_simple_2layer.py new file mode 100644 index 00000000..ed97a0e2 --- /dev/null +++ b/tests/test_plot/test_simple_2layer.py @@ -0,0 +1,103 @@ +""" +Tests of simple 2-layer PCBs +""" + +from . import plotting_test_utils + +import os +import mmap +import re +import logging + + +def expect_file_at(filename): + + assert(os.path.isfile(filename)) + + +def get_gerber_filename(board_name, layer_slug, ext='.gbr'): + return board_name + '-' + layer_slug + ext + + +def find_gerber_aperture(s, ap_desc): + + m = re.search(r'%AD(.*)' + ap_desc + r'\*%', s) + + if not m: + return None + + return m.group(1) + + +def expect_gerber_has_apertures(gbr_data, ap_list): + + aps = [] + + for ap in ap_list: + + # find the circular aperture for the outline + ap_no = find_gerber_aperture(gbr_data, ap) + + assert ap_no is not None + + # apertures from D10 to D999 + assert len(ap_no) in [2, 3] + + aps.append(ap_no) + + logging.debug("Found apertures {}".format(aps)) + return aps + + +def expect_gerber_flash_at(gbr_data, pos): + """ + Check for a gerber flash at a given point + (it's hard to check that aperture is right without a real gerber parser + """ + + repat = r'^X{x}Y{y}D03\*$'.format( + x=int(pos[0] * 100000), + y=int(pos[1] * 100000) + ) + + m = re.search(repat, gbr_data, re.MULTILINE) + + assert(m) + + logging.debug("Gerber flash found: " + repat) + + +def get_mmapped_data(filename): + + with open(filename) as fo: + return mmap.mmap(fo.fileno(), 0, access=mmap.ACCESS_READ) + + +# content of test_sample.py +def test_2layer(): + + ctx = plotting_test_utils.KiPlotTestContext('simple_2layer') + + ctx.load_yaml_config_file('simple_2layer.kiplot.yaml') + ctx.board_name = 'simple_2layer' + + ctx.do_plot() + + gbr_dir = ctx.cfg.resolve_output_dir_for_name('gerbers') + + f_cu_gbr = os.path.join(gbr_dir, + get_gerber_filename(ctx.board_name, "F_Cu")) + + expect_file_at(f_cu_gbr) + + f_cu_data = get_mmapped_data(f_cu_gbr) + + ap_ids = expect_gerber_has_apertures(f_cu_data, [ + "C,0.200000", + "R,2.000000X2.000000", + "C,1.000000"]) + + # expect a flash for the square pad + expect_gerber_flash_at(f_cu_data, (140, -100)) + + ctx.clean_up() diff --git a/tests/yaml_samples/simple_2layer.kiplot.yaml b/tests/yaml_samples/simple_2layer.kiplot.yaml new file mode 100644 index 00000000..df3a6838 --- /dev/null +++ b/tests/yaml_samples/simple_2layer.kiplot.yaml @@ -0,0 +1,36 @@ +# Example KiPlot config file for a basic 2-layer board +kiplot: + version: 1 + +outputs: + + - name: 'gerbers' + comment: "Gerbers for the Gerber god" + type: gerber + dir: gerberdir + options: + # generic layer options + exclude_edge_layer: false + exclude_pads_from_silkscreen: false + use_aux_axis_as_origin: false + plot_sheet_reference: false + plot_footprint_refs: true + plot_footprint_values: true + force_plot_invisible_refs_vals: false + tent_vias: true + check_zone_fills: true + + # gerber options + line_width: 0.15 + subtract_mask_from_silk: true + use_protel_extensions: false + gerber_precision: 4.5 + create_gerber_job_file: true + use_gerber_x2_attributes: true + use_gerber_net_attributes: false + + layers: + - layer: F.Cu + suffix: F_Cu + - layer: F.SilkS + suffix: F_SilkS \ No newline at end of file