From 564ab243cc3fa7c0239d71a6dacb4a6c7765e9f5 Mon Sep 17 00:00:00 2001
From: jaseg <git@jaseg.de>
Date: Thu, 3 Jun 2021 23:45:11 +0200
Subject: Add svg-flatten SVG feature tests

---
 svg-flatten/src/test/nopencv_test.cpp | 405 ++++++++++++++++++++++++++++++++++
 svg-flatten/src/test/svg_tests.py     |  75 +++++++
 2 files changed, 480 insertions(+)
 create mode 100644 svg-flatten/src/test/nopencv_test.cpp
 create mode 100644 svg-flatten/src/test/svg_tests.py

(limited to 'svg-flatten/src/test')

diff --git a/svg-flatten/src/test/nopencv_test.cpp b/svg-flatten/src/test/nopencv_test.cpp
new file mode 100644
index 0000000..2f358b2
--- /dev/null
+++ b/svg-flatten/src/test/nopencv_test.cpp
@@ -0,0 +1,405 @@
+
+#include <iostream>
+#include <fstream>
+#include <iomanip>
+#include <cmath>
+#include <filesystem>
+
+#include "nopencv.hpp"
+
+#include <subprocess.h>
+#include <minunit.h>
+
+#include "stb_image.h"
+
+using namespace gerbolyze;
+using namespace gerbolyze::nopencv;
+
+char msg[512];
+
+class TempfileHack {
+public:
+    TempfileHack(const string ext) : m_path { filesystem::temp_directory_path() / (std::tmpnam(nullptr) + ext) } {}
+    ~TempfileHack() { remove(m_path); }
+
+    const char *c_str() { return m_path.c_str(); }
+
+private:
+    filesystem::path m_path;
+};
+
+class SVGPolyRenderer {
+public:
+    SVGPolyRenderer(const char *fn, int width_px, int height_px)
+        : m_svg(fn) {
+        m_svg << "<svg width=\"" << width_px << "px\" height=\"" << height_px << "px\" viewBox=\"0 0 "
+            << width_px << " " << height_px << "\" "
+            << "xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">" << endl;
+        m_svg << "<rect width=\"100%\" height=\"100%\" fill=\"black\"/>" << endl;
+    }
+
+    ContourCallback callback() {
+        return [this](Polygon_i &poly, ContourPolarity pol) {
+            mu_assert(poly.size() > 0, "Empty contour returned");
+            mu_assert(poly.size() > 2, "Contour has less than three points, no area");
+            mu_assert(pol == CP_CONTOUR || pol == CP_HOLE, "Contour has invalid polarity");
+
+            m_svg << "<path fill=\"" << ((pol == CP_HOLE) ? "black" : "white") << "\" d=\"";
+            m_svg << "M " << poly[0][0] << " " << poly[0][1];
+            for (size_t i=1; i<poly.size(); i++) {
+                m_svg << " L " << poly[i][0] << " " << poly[i][1];
+            }
+            m_svg << " Z\"/>" << endl;
+        };
+    }
+
+    void close() {
+        m_svg << "</svg>" << endl;
+        m_svg.close();
+    }
+
+private:
+    ofstream m_svg;
+};
+
+MU_TEST(test_complex_example_from_paper) {
+    int32_t img_data[6*9] = {
+        0, 0, 0, 0, 0, 0, 0, 0, 0,
+        0, 1, 1, 1, 1, 1, 1, 1, 0,
+        0, 1, 0, 0, 1, 0, 0, 1, 0,
+        0, 1, 0, 0, 1, 0, 0, 1, 0,
+        0, 1, 1, 1, 1, 1, 1, 1, 0,
+        0, 0, 0, 0, 0, 0, 0, 0, 0,
+    };
+    Image32 test_img(9, 6, static_cast<int*>(img_data));
+
+    const Polygon_i expected_polys[3] = {
+        {
+            {1,1}, {1,2}, {1,3}, {1,4}, {1,5},
+            {2,5}, {3,5}, {4,5}, {5,5}, {6,5}, {7,5}, {8,5},
+            {8,4}, {8,3}, {8,2}, {8,1},
+            {7,1}, {6,1}, {5,1}, {4,1}, {3,1}, {2,1}
+        },
+        {
+            {2,2}, {2,3}, {2,4},
+            {3,4}, {4,4},
+            {4,3}, {4,2},
+            {3,2}
+        },
+        {
+            {5,2}, {5,3}, {5,4},
+            {6,4}, {7,4},
+            {7,3}, {7,2},
+            {6,2}
+        }
+    };
+
+    const ContourPolarity expected_polarities[3] = {CP_CONTOUR, CP_HOLE, CP_HOLE};
+    
+    int invocation_count = 0;
+    gerbolyze::nopencv::find_contours(test_img, [&invocation_count, &expected_polarities, &expected_polys](Polygon_i &poly, ContourPolarity pol) {
+            invocation_count += 1;
+            mu_assert((invocation_count <= 3), "Too many contours returned"); 
+
+            mu_assert(poly.size() > 0, "Empty contour returned");
+            mu_assert_int_eq(pol, expected_polarities[invocation_count-1]);
+
+            i2p last;
+            bool first = true;
+            Polygon_i exp = expected_polys[invocation_count-1];
+            for (auto &p : poly) {
+                if (!first) {
+                    mu_assert((fabs(p[0] - last[0]) + fabs(p[1] - last[1]) == 1), "Subsequent contour points have distance other than one");
+                    mu_assert(find(exp.begin(), exp.end(), p) != exp.end(), "Got unexpected contour point");
+                }
+                last = p;
+            }
+        });
+    mu_assert_int_eq(3, invocation_count);
+
+    int32_t tpl[6*9] = {
+        0, 0, 0, 0, 0, 0, 0, 0, 0,
+        0, 2, 2, 2, 2, 2, 2,-2, 0,
+        0,-3, 0, 0,-4, 0, 0,-2, 0,
+        0,-3, 0, 0,-4, 0, 0,-2, 0,
+        0, 2, 2, 2, 2, 2, 2,-2, 0,
+        0, 0, 0, 0, 0, 0, 0, 0, 0,
+    };
+
+
+    for (int y=0; y<6; y++) {
+        for (int x=0; x<9; x++) {
+            int a = test_img.at(x, y), b = tpl[y*9+x];
+            if (a != b) {
+                cout << "Result:" << endl;
+                cout << "    ";
+                for (int x=0; x<9; x++) {
+                    cout << x << "  ";
+                }
+                cout << endl;
+                cout << "    ";
+                for (int x=0; x<9; x++) {
+                    cout << "---";
+                }
+                cout << endl;
+                for (int y=0; y<6; y++) {
+                    cout << y << " | ";
+                    for (int x=0; x<9; x++) {
+                        cout << setfill(' ') << setw(2) << test_img.at(x, y) << " ";
+                    }
+                    cout << endl;
+                }
+
+                snprintf(msg, sizeof(msg), "Result does not match template @(%d, %d): %d != %d\n", x, y, a, b);
+                mu_fail(msg);
+            }
+        }
+    }
+}
+
+int render_svg(const char *in_svg, const char *out_png) {
+    const char *command_line[] = {"resvg", in_svg, out_png, nullptr};
+    struct subprocess_s subprocess;
+    int rc = subprocess_create(command_line, subprocess_option_inherit_environment, &subprocess);
+    if (rc)
+        return rc;
+
+    int resvg_rc = -1;
+    rc = subprocess_join(&subprocess, &resvg_rc);
+    if (rc)
+        return rc;
+    if (resvg_rc)
+        return -resvg_rc;
+
+    rc = subprocess_destroy(&subprocess);
+    if (rc)
+        return rc;
+
+    return 0;
+}
+
+static void testdata_roundtrip(const char *fn) {
+    Image32 ref_img;
+    mu_assert(ref_img.load(fn), "Input image failed to load");
+    ref_img.binarize();
+    Image32 ref_img_copy(ref_img);
+
+    TempfileHack tmp_svg(".svg");
+    TempfileHack tmp_png(".png");
+
+    SVGPolyRenderer ctx(tmp_svg.c_str(), ref_img.cols(), ref_img.rows());
+    gerbolyze::nopencv::find_contours(ref_img, ctx.callback());
+    ctx.close();
+
+    mu_assert_int_eq(0, render_svg(tmp_svg.c_str(), tmp_png.c_str()));
+
+    Image32 out_img;
+    mu_assert(out_img.load(tmp_png.c_str()), "Output image failed to load");
+    out_img.binarize();
+
+    mu_assert_int_eq(ref_img.cols(), out_img.cols());
+    mu_assert_int_eq(ref_img.rows(), out_img.rows());
+
+    for (int y=0; y<out_img.rows(); y++) {
+        for (int x=0; x<out_img.cols(); x++) {
+            if (out_img.at(x, y) != ref_img_copy.at(x, y)) {
+                snprintf(msg, sizeof(msg), "%s: Result does not match input @(%d, %d): %d != %d\n", fn, x, y, out_img.at(x, y), ref_img_copy.at(x, y));
+                mu_fail(msg);
+            }
+        }
+    }
+}
+
+MU_TEST(test_round_trip_blank)              { testdata_roundtrip("testdata/blank.png"); }
+MU_TEST(test_round_trip_white)              { testdata_roundtrip("testdata/white.png"); }
+MU_TEST(test_round_trip_blob_border_w)      { testdata_roundtrip("testdata/blob-border-w.png"); }
+MU_TEST(test_round_trip_blobs_borders)      { testdata_roundtrip("testdata/blobs-borders.png"); }
+MU_TEST(test_round_trip_blobs_corners)      { testdata_roundtrip("testdata/blobs-corners.png"); }
+MU_TEST(test_round_trip_blobs_crossing)     { testdata_roundtrip("testdata/blobs-crossing.png"); }
+MU_TEST(test_round_trip_cross)              { testdata_roundtrip("testdata/cross.png"); }
+MU_TEST(test_round_trip_letter_e)           { testdata_roundtrip("testdata/letter-e.png"); }
+MU_TEST(test_round_trip_paper_example)      { testdata_roundtrip("testdata/paper-example.png"); }
+MU_TEST(test_round_trip_paper_example_inv)  { testdata_roundtrip("testdata/paper-example-inv.png"); }
+MU_TEST(test_round_trip_single_px)          { testdata_roundtrip("testdata/single-px.png"); }
+MU_TEST(test_round_trip_single_px_inv)      { testdata_roundtrip("testdata/single-px-inv.png"); }
+MU_TEST(test_round_trip_two_blobs)          { testdata_roundtrip("testdata/two-blobs.png"); }
+MU_TEST(test_round_trip_two_px)             { testdata_roundtrip("testdata/two-px.png"); }
+MU_TEST(test_round_trip_two_px_inv)         { testdata_roundtrip("testdata/two-px-inv.png"); }
+
+static void test_polygon_area(const char *fn) {
+    //cerr << endl << "poly area test " << fn << endl;
+    Image32 ref_img;
+    mu_assert(ref_img.load(fn), "Input image failed to load");
+    ref_img.binarize();
+
+    int white_px_count = 0;
+    int black_px_count = 0;
+    for (int y=0; y<ref_img.rows(); y++) {
+        for (int x=0; x<ref_img.cols(); x++) {
+            if (ref_img.at(x, y)) {
+                white_px_count += 1;
+            } else {
+                black_px_count += 1;
+            }
+        }
+    }
+
+    double pos_sum = 0.0;
+    double neg_sum = ref_img.size();
+    gerbolyze::nopencv::find_contours(ref_img, [&pos_sum, &neg_sum](Polygon_i& poly, ContourPolarity pol) {
+            double area = polygon_area(poly);
+            //cerr << endl << fn << ": " << area << " " << pos_sum << " / " << neg_sum << " -- " << white_px_count << " / " << black_px_count << " GOT: " << poly.size() << " w/ " << pol << endl;
+            mu_assert(fabs(area) > 0.99, "Polygon smaller than a single pixel");
+            mu_assert((pol == CP_CONTOUR) == (area >= 0), "Polygon area has incorrect sign");
+
+            pos_sum += area;
+            neg_sum -= area;
+        });
+
+    mu_assert(pos_sum - white_px_count < 0.01, "Calculated area outside tolerance");
+    mu_assert(neg_sum - black_px_count < 0.01, "Calculated area outside tolerance");
+    //cerr << endl << "poly area test " << fn << " done" << endl;
+}
+
+MU_TEST(test_polygon_area_blank)              { test_polygon_area("testdata/blank.png"); }
+MU_TEST(test_polygon_area_white)              { test_polygon_area("testdata/white.png"); }
+MU_TEST(test_polygon_area_blob_border_w)      { test_polygon_area("testdata/blob-border-w.png"); }
+MU_TEST(test_polygon_area_blobs_borders)      { test_polygon_area("testdata/blobs-borders.png"); }
+MU_TEST(test_polygon_area_blobs_corners)      { test_polygon_area("testdata/blobs-corners.png"); }
+MU_TEST(test_polygon_area_blobs_crossing)     { test_polygon_area("testdata/blobs-crossing.png"); }
+MU_TEST(test_polygon_area_cross)              { test_polygon_area("testdata/cross.png"); }
+MU_TEST(test_polygon_area_letter_e)           { test_polygon_area("testdata/letter-e.png"); }
+MU_TEST(test_polygon_area_paper_example)      { test_polygon_area("testdata/paper-example.png"); }
+MU_TEST(test_polygon_area_paper_example_inv)  { test_polygon_area("testdata/paper-example-inv.png"); }
+MU_TEST(test_polygon_area_single_px)          { test_polygon_area("testdata/single-px.png"); }
+MU_TEST(test_polygon_area_single_px_inv)      { test_polygon_area("testdata/single-px-inv.png"); }
+MU_TEST(test_polygon_area_two_blobs)          { test_polygon_area("testdata/two-blobs.png"); }
+MU_TEST(test_polygon_area_two_px)             { test_polygon_area("testdata/two-px.png"); }
+MU_TEST(test_polygon_area_two_px_inv)         { test_polygon_area("testdata/two-px-inv.png"); }
+
+static void chain_approx_test(const char *fn) {
+    //cout << endl << "Testing \"" << fn << "\"" << endl;
+    Image32 ref_img;
+    mu_assert(ref_img.load(fn), "Input image failed to load");
+    ref_img.binarize();
+    Image32 ref_img_copy(ref_img);
+
+    TempfileHack tmp_svg(".svg");
+    TempfileHack tmp_png(".png");
+
+    SVGPolyRenderer ctx(tmp_svg.c_str(), ref_img.cols(), ref_img.rows());
+    gerbolyze::nopencv::find_contours(ref_img, simplify_contours_teh_chin(ctx.callback()));
+    ctx.close();
+
+    mu_assert_int_eq(0, render_svg(tmp_svg.c_str(), tmp_png.c_str()));
+
+    Image32 out_img;
+    mu_assert(out_img.load(tmp_png.c_str()), "Output image failed to load");
+    mu_assert_int_eq(ref_img.rows(), out_img.rows());
+    mu_assert_int_eq(ref_img.cols(), out_img.cols());
+
+    double max_abs_deviation = 0;
+    double rms_sum = 0; 
+    double mean_sum = 0; 
+    for (int y=0; y<out_img.rows(); y++) {
+        for (int x=0; x<out_img.cols(); x++) {
+            double delta = fabs((double)out_img.at(x, y)/255.0 - (double)ref_img_copy.at(x, y));
+            max_abs_deviation = fmax(max_abs_deviation, delta);
+            rms_sum += delta*delta;
+            mean_sum += delta;
+        }
+    }
+
+    rms_sum = sqrt(rms_sum / out_img.size());
+    mean_sum /= out_img.size();
+    if (rms_sum > 0.5) {
+        snprintf(msg, sizeof(msg), "%s: Chain approximation RMS error is above threshold: %.3f > 0.5\n", fn, rms_sum);
+        mu_fail(msg);
+    }
+    if (mean_sum > 0.1) {
+        snprintf(msg, sizeof(msg), "%s: Chain approximation mean error is above threshold: %.3f > 0.1\n", fn, mean_sum);
+        mu_fail(msg);
+    }
+    //mu_assert(max_abs_deviation < 0.5, "Maximum chain approximation error is above threshold");
+}
+
+
+MU_TEST(chain_approx_test_chromosome)         { chain_approx_test("testdata/chain-approx-teh-chin-chromosome.png"); }
+MU_TEST(chain_approx_test_blank)              { chain_approx_test("testdata/blank.png"); }
+MU_TEST(chain_approx_test_white)              { chain_approx_test("testdata/white.png"); }
+MU_TEST(chain_approx_test_blob_border_w)      { chain_approx_test("testdata/blob-border-w.png"); }
+MU_TEST(chain_approx_test_blobs_borders)      { chain_approx_test("testdata/blobs-borders.png"); }
+MU_TEST(chain_approx_test_blobs_corners)      { chain_approx_test("testdata/blobs-corners.png"); }
+MU_TEST(chain_approx_test_blobs_crossing)     { chain_approx_test("testdata/blobs-crossing.png"); }
+MU_TEST(chain_approx_test_cross)              { chain_approx_test("testdata/cross.png"); }
+MU_TEST(chain_approx_test_letter_e)           { chain_approx_test("testdata/letter-e.png"); }
+MU_TEST(chain_approx_test_paper_example)      { chain_approx_test("testdata/paper-example.png"); }
+MU_TEST(chain_approx_test_paper_example_inv)  { chain_approx_test("testdata/paper-example-inv.png"); }
+MU_TEST(chain_approx_test_single_px)          { chain_approx_test("testdata/single-px.png"); }
+MU_TEST(chain_approx_test_single_px_inv)      { chain_approx_test("testdata/single-px-inv.png"); }
+MU_TEST(chain_approx_test_two_blobs)          { chain_approx_test("testdata/two-blobs.png"); }
+MU_TEST(chain_approx_test_two_px)             { chain_approx_test("testdata/two-px.png"); }
+MU_TEST(chain_approx_test_two_px_inv)         { chain_approx_test("testdata/two-px-inv.png"); }
+
+
+MU_TEST_SUITE(nopencv_contours_suite) {
+    MU_RUN_TEST(test_complex_example_from_paper);
+
+    MU_RUN_TEST(test_round_trip_blank);
+    MU_RUN_TEST(test_round_trip_white);
+    MU_RUN_TEST(test_round_trip_blob_border_w);
+    MU_RUN_TEST(test_round_trip_blobs_borders);
+    MU_RUN_TEST(test_round_trip_blobs_corners);
+    MU_RUN_TEST(test_round_trip_blobs_crossing);
+    MU_RUN_TEST(test_round_trip_cross);
+    MU_RUN_TEST(test_round_trip_letter_e);
+    MU_RUN_TEST(test_round_trip_paper_example);
+    MU_RUN_TEST(test_round_trip_paper_example_inv);
+    MU_RUN_TEST(test_round_trip_single_px);
+    MU_RUN_TEST(test_round_trip_single_px_inv);
+    MU_RUN_TEST(test_round_trip_two_blobs);
+    MU_RUN_TEST(test_round_trip_two_px);
+    MU_RUN_TEST(test_round_trip_two_px_inv);
+
+    MU_RUN_TEST(chain_approx_test_chromosome);
+    MU_RUN_TEST(chain_approx_test_blank);
+    MU_RUN_TEST(chain_approx_test_white);
+    MU_RUN_TEST(chain_approx_test_blob_border_w);
+    MU_RUN_TEST(chain_approx_test_blobs_borders);
+    MU_RUN_TEST(chain_approx_test_blobs_corners);
+    MU_RUN_TEST(chain_approx_test_blobs_crossing);
+    MU_RUN_TEST(chain_approx_test_cross);
+    MU_RUN_TEST(chain_approx_test_letter_e);
+    MU_RUN_TEST(chain_approx_test_paper_example);
+    MU_RUN_TEST(chain_approx_test_paper_example_inv);
+    MU_RUN_TEST(chain_approx_test_single_px);
+    MU_RUN_TEST(chain_approx_test_single_px_inv);
+    MU_RUN_TEST(chain_approx_test_two_blobs);
+    MU_RUN_TEST(chain_approx_test_two_px);
+    MU_RUN_TEST(chain_approx_test_two_px_inv);
+
+    MU_RUN_TEST(test_polygon_area_blank);
+    MU_RUN_TEST(test_polygon_area_white);
+    MU_RUN_TEST(test_polygon_area_blob_border_w);
+    MU_RUN_TEST(test_polygon_area_blobs_borders);
+    MU_RUN_TEST(test_polygon_area_blobs_corners);
+    MU_RUN_TEST(test_polygon_area_blobs_crossing);
+    MU_RUN_TEST(test_polygon_area_cross);
+    MU_RUN_TEST(test_polygon_area_letter_e);
+    MU_RUN_TEST(test_polygon_area_paper_example);
+    MU_RUN_TEST(test_polygon_area_paper_example_inv);
+    MU_RUN_TEST(test_polygon_area_single_px);
+    MU_RUN_TEST(test_polygon_area_single_px_inv);
+    MU_RUN_TEST(test_polygon_area_two_blobs);
+    MU_RUN_TEST(test_polygon_area_two_px);
+    MU_RUN_TEST(test_polygon_area_two_px_inv);
+};
+
+int main(int argc, char **argv) {
+    (void)argc;
+    (void)argv;
+
+    MU_RUN_SUITE(nopencv_contours_suite);
+    MU_REPORT();
+    return MU_EXIT_CODE;
+}
diff --git a/svg-flatten/src/test/svg_tests.py b/svg-flatten/src/test/svg_tests.py
new file mode 100644
index 0000000..a5139ec
--- /dev/null
+++ b/svg-flatten/src/test/svg_tests.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+
+import tempfile
+import unittest
+from pathlib import Path
+import subprocess
+import os
+
+from PIL import Image
+import numpy as np
+
+def run_svg_flatten(input_file, output_file, **kwargs):
+    if 'SVG_FLATTEN' in os.environ:
+        svg_flatten = os.environ.get('SVG_FLATTEN')
+    elif (Path(__file__) / '../../build/svg-flatten').is_file():
+        svg_flatten = '../../build/svg-flatten'
+    elif Path('./build/svg-flatten').is_file():
+        svg_flatten = './build/svg-flatten'
+    else:
+        svg_flatten = 'svg-flatten'
+
+    args = [ svg_flatten,
+            *(arg for (key, value) in kwargs.items() for arg in (f'--{key.replace("_", "-")}', value)),
+            str(input_file), str(output_file) ]
+
+    try:
+        proc = subprocess.run(args, capture_output=True, check=True)
+    except:
+        print('Subprocess stdout:')
+        print(proc.stdout)
+        print('Subprocess stderr:')
+        print(proc.stderr)
+        raise
+
+class SVGRoundTripTests(unittest.TestCase):
+
+    def compare_images(self, reference, output, test_name, mean=0.01):
+        ref = np.array(Image.open(reference))
+        out = np.array(Image.open(output))
+        delta = np.abs(out - ref).astype(float) / 255
+
+        #print(f'{test_name}: mean={delta.mean():.5g}')
+
+        self.assertTrue(delta.mean() < mean,
+                f'Expected mean pixel difference between images to be <{mean}, was {delta.mean():.5g}')
+
+    def run_svg_round_trip_test(self, test_in_svg):
+        with tempfile.NamedTemporaryFile(suffix='.svg') as tmp_out_svg,\
+            tempfile.NamedTemporaryFile(suffix='.png') as tmp_out_png,\
+            tempfile.NamedTemporaryFile(suffix='.png') as tmp_in_png:
+
+            run_svg_flatten(test_in_svg, tmp_out_svg.name, format='svg')
+
+            subprocess.run(['resvg', tmp_out_svg.name, tmp_out_png.name], check=True, stdout=subprocess.DEVNULL)
+            subprocess.run(['resvg', test_in_svg, tmp_in_png.name], check=True, stdout=subprocess.DEVNULL)
+
+            try:
+                self.compare_images(tmp_in_png, tmp_out_png, test_in_svg.stem)
+            except AssertionError as e:
+                import shutil
+                shutil.copyfile(tmp_in_png.name, f'/tmp/gerbolyze-fail-{test_in_svg.stem}-in.png')
+                shutil.copyfile(tmp_out_png.name, f'/tmp/gerbolyze-fail-{test_in_svg.stem}-out.png')
+                foo = list(e.args)
+                foo[0] += '\nFailing test renderings copied to:\n'
+                foo[0] += f'  /tmp/gerbolyze-fail-{test_in_svg.stem}-{{in|out}}.png\n'
+                e.args = tuple(foo)
+                raise e
+
+for test_in_svg in Path('testdata/svg').glob('*.svg'):
+    # We need to make sure we capture the loop variable's current value here.
+    gen = lambda testcase: lambda self: self.run_svg_round_trip_test(testcase)
+    setattr(SVGRoundTripTests, f'test_{test_in_svg.stem}', gen(test_in_svg))
+
+if __name__ == '__main__':
+    unittest.main()
-- 
cgit