"""Blender-free tests for aetheria_ships/hull_source.py, which reads a hull.scad through OpenSCAD and judges it against its faction's form language. A fake runner answers each part with fixture OFF and ECHO text, so no OpenSCAD runs here; the real OpenSCAD and the real language files are exercised by smoke_hull_source.py. python -m unittest discover -s tools/blender/tests -p "test_hull_source.py" hull_source is loaded by path because the package __init__ imports bpy. """ import dataclasses import importlib.util import json import sys import tempfile import unittest from pathlib import Path from unittest import mock _spec = importlib.util.spec_from_file_location( "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") hs = importlib.util.module_from_spec(_spec) sys.modules["hull_source"] = hs _spec.loader.exec_module(hs) SECRET = "SECRET-NAME-7" # a value a hull source supplies and no refusal may echo def box(x0, x1, y0, y1, z0, z1): """An OFF text for an axis-aligned box: 8 vertices, 6 quads (12 triangles once fanned).""" v = [(x, y, z) for z in (z0, z1) for y in (y0, y1) for x in (x0, x1)] quads = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] rows = ["OFF", "8 6 0"] + [f"{x} {y} {z}" for x, y, z in v] + [f"4 {a} {b} {c} {d}" for a, b, c, d in quads] return "\n".join(rows) def echo(name, value): return f"ECHO: {name} = {json.dumps(value)}\n" MAKER = "faction-record-key" def language(faction="zhestokost", zones=("primary", "secondary"), body="primary", proportion=((0.25, 0.6), (0.35, 0.75)), symmetry=("bilateral", 0.97), maker=MAKER): palette = [[z, [0.1 * (i + 1), 0.2, 0.3]] for i, z in enumerate(zones)] return echo("lang", [faction, palette, body, [1, 0.6, 0.2], [0.5, 0.5, 0.5], [list(p) for p in proportion] if proportion else [], list(symmetry), maker]) def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]]) def marks_off(points, size=0.002): """An OFF text of one tiny cube per point, as the kit's marks part is.""" rows, faces = [], [] for i, (x, y, z) in enumerate(points): h = size / 2 rows += [(x + dx * h, y + dy * h, z + dz * h) for dz in (-1, 1) for dy in (-1, 1) for dx in (-1, 1)] b = 8 * i faces += [(b, b + 1, b + 3, b + 2), (b + 4, b + 6, b + 7, b + 5), (b, b + 2, b + 6, b + 4), (b + 1, b + 5, b + 7, b + 3), (b, b + 4, b + 5, b + 1), (b + 2, b + 3, b + 7, b + 6)] return "\n".join(["OFF", f"{len(rows)} {len(faces)} 0"] + [" ".join(map(repr, r)) for r in rows] + ["4 " + " ".join(map(str, f)) for f in faces]) + "\n" class Fixture: """A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise.""" def __init__(self): self.library = {"zhestokost": language()} self.hull_lang = None self.hull_type = "Ship" # HullData.HullType, which the caller reads from the package # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle. self.parts = { "brief": ("", brief()), "primary": (box(-3, 3, -5, 5, 0, 2), ""), "secondary": (box(-1, 1, -1, 1, 2, 3), ""), "anchors": ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), "lines": ("", ""), } self.marks = None # the "marks" part when a test sets one; else a mark at each kit line's first point @staticmethod def projector(meshes): """A top view that is the bounding rectangle of every mesh it is given.""" lows = [m.bounds()[0] for m in meshes] highs = [m.bounds()[1] for m in meshes] x0, y0 = min(v[0] for v in lows), min(v[1] for v in lows) x1, y1 = max(v[0] for v in highs), max(v[1] for v in highs) return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] def runner(self): return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.mark_part() if part == "marks" else self.parts[part] def mark_part(self): """What the kit's lp_line draws in the geometry's own frame: a tiny cube at each line's first point.""" if self.marks is not None: return self.marks return (marks_off([l.points[0] for l in hs.read_lines(self.parts["lines"][1])]), "") def read_library(self, faction): if faction not in self.library: raise hs.HullSourceError("no such language file") return self.library[faction] def build(self): return hs.build(self.runner(), self.read_library, self.hull_type, self.projector) def failed(self): return [c.name for c in self.build().failed()] class ReadingTests(unittest.TestCase): def test_off_fans_polygons_into_triangles(self): mesh = hs.parse_off(box(0, 1, 0, 1, 0, 1)) self.assertEqual((len(mesh.vertices), len(mesh.triangles)), (8, 12)) self.assertEqual(mesh.bounds(), ((0, 0, 0), (1, 1, 1))) def test_off_accepts_a_header_with_counts_on_the_first_line_and_skips_comments(self): mesh = hs.parse_off("OFF 3 1 0\n# c\n0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n") self.assertEqual(mesh.triangles, ((0, 1, 2),)) def test_nothing_is_an_empty_mesh_and_a_broken_file_is_refused_without_echoing_it(self): self.assertEqual(hs.parse_off("").triangles, ()) with self.assertRaises(hs.HullSourceError) as caught: hs.parse_off(f"OFF\n3 1 0\n0 0 0\n1 0 0\n0 1 0\n3 0 1 9 {SECRET}\n") self.assertNotIn(SECRET, str(caught.exception)) def test_a_face_naming_the_vertex_one_past_the_last_is_refused(self): with self.assertRaises(hs.HullSourceError): hs.parse_off("\n".join(["OFF", "3 1 0", "0 0 0", "1 0 0", "0 1 0", "3 0 1 3"])) def test_echoes_are_read_by_name_and_only_plain_values_are_accepted(self): text = echo("anchor", ["a", 1]) + echo("other", [2]) + echo("anchor", ["b", 2.5e-05]) self.assertEqual(hs.parse_echoes(text, "anchor"), [["a", 1], ["b", 2.5e-05]]) with self.assertRaises(hs.HullSourceError) as caught: hs.parse_echoes(f"ECHO: anchor = [{SECRET}, undef]\n", "anchor") self.assertNotIn(SECRET, str(caught.exception)) def test_a_brief_is_exactly_one_echo_of_six_fields(self): self.assertEqual(hs.read_brief(brief()).faction, "zhestokost") for text in ("", brief() + brief(), echo("brief", ["x"])): with self.assertRaises(hs.HullSourceError): hs.read_brief(text) def test_a_pirate_language_reads_with_no_body_and_no_proportion(self): pirate = hs.read_languages(language("pirate", ("primer", "weld"), body="", proportion=None, symmetry=("asymmetric", 0.9)))[0] self.assertEqual((pirate.body, pirate.proportion, pirate.symmetry), ("", None, ("asymmetric", 0.9))) def test_a_language_naming_an_unknown_rule_or_a_body_outside_its_zones_is_refused(self): with self.assertRaises(hs.HullSourceError): hs.read_languages(language(symmetry=("mostly", 0.5))) with self.assertRaises(hs.HullSourceError): hs.read_languages(language(body="keel")) PROPORTION = "the body's proportions lie inside the language's ranges" GRID = "the top view fits the layout grid" def names(built): return [c.name for c in built.checks] class HullTypeTests(unittest.TestCase): """A language's proportions judge Ship hulls only; every other rule, and the layout grid, judges every hull type.""" @staticmethod def fixture(hull_type, **parts): fixture = Fixture() fixture.hull_type = hull_type fixture.parts.update({k: (v, "") for k, v in parts.items()}) return fixture def test_a_station_or_turret_is_not_held_to_the_languages_proportions_and_lists_no_proportion_check(self): wide = box(-12, 12, -5, 5, 0, 2) # h/w 0.08, w/l 2.4: outside both of Zhestokost's ranges for hull_type in ("Station", "Turret"): with self.subTest(hull_type): built = self.fixture(hull_type, primary=wide).build() self.assertEqual([c.name for c in built.failed()], []) self.assertNotIn(PROPORTION, names(built)) self.assertEqual(built.hull_type, hull_type) self.assertEqual(self.fixture("Ship", primary=wide).failed(), [PROPORTION]) def test_a_station_with_no_body_lists_no_proportion_check_but_a_ship_fails_it(self): self.assertNotIn(PROPORTION, names(self.fixture("Station", primary="").build())) ship = self.fixture("Ship", primary="").build() self.assertEqual([c.detail for c in ship.checks if c.name == PROPORTION], ["no body to judge"]) def test_a_ship_lists_the_proportion_check_when_it_passes(self): built = self.fixture("Ship").build() self.assertEqual([c.ok for c in built.checks if c.name == PROPORTION], [True]) def test_a_station_is_still_judged_on_symmetry_length_and_budget(self): self.assertEqual(self.fixture("Station", secondary=box(2, 6, -1, 1, 2, 3)).failed(), ["the top view obeys the language's bilateral rule"]) self.assertEqual(self.fixture("Station", primary=box(-3, 3, -10, 10, 0, 2)).failed(), ["the length lies inside the brief's range"]) station = self.fixture("Station") station.parts["brief"] = ("", brief(triangles=(10, 20))) self.assertEqual(station.failed(), ["the triangle count lies inside the brief's budget"]) def test_every_hull_type_must_fit_the_layout_grid_at_thirty_two_cells_a_side(self): for hull_type in ("Ship", "Station", "Turret"): with self.subTest(hull_type): fits = self.fixture(hull_type, secondary=box(-32, 32, -1, 1, 2, 3)).build() self.assertEqual([(c.ok, c.detail) for c in fits.checks if c.name == GRID], [(True, "32 x 5 cells, at most 32 a side")]) wide = self.fixture(hull_type, secondary=box(-33, 33, -1, 1, 2, 3)) self.assertEqual(wide.failed(), [GRID]) self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID], ["33 x 5 cells, at most 32 a side"]) long = self.fixture("Station", secondary=box(-1, 1, -32, 32, 2, 3)) long.parts["brief"] = ("", brief(length=(8, 70))) self.assertEqual(long.failed(), []) self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 32 cells, at most 32 a side"]) long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3)) long.parts["brief"] = ("", brief(length=(8, 70))) self.assertEqual(long.failed(), [GRID]) self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 33 cells, at most 32 a side"]) def test_the_grid_reads_zone_meshes_only_not_anchor_meshes(self): built = self.fixture("Station", **{"emit:Th.A": box(-35, 35, 5, 5.1, 0.5, 1.5)}).build() self.assertEqual([c.name for c in built.failed()], []) self.assertEqual([c.detail for c in built.checks if c.name == GRID], ["3 x 5 cells, at most 32 a side"]) def test_a_hull_with_no_zone_geometry_fails_the_grid_check(self): built = self.fixture("Station", primary="", secondary="").build() self.assertEqual([(c.ok, c.detail) for c in built.checks if c.name == GRID], [(False, "no geometry")]) def test_build_has_no_default_hull_type(self): fixture = Fixture() with self.assertRaises(TypeError): hs.build(fixture.runner(), fixture.read_library, projector=fixture.projector) def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): with self.subTest(hull_type=hull_type): fixture, read = Fixture(), [] runner = fixture.runner() with self.assertRaises(hs.HullSourceError) as caught: hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type, fixture.projector) self.assertEqual(read, []) self.assertNotIn(SECRET, str(caught.exception)) def test_build_accepts_each_hulltype_name_and_only_a_ship_lists_the_proportion_check(self): for hull_type in ("Ship", "Station", "Turret"): with self.subTest(hull_type): built = self.fixture(hull_type).build() self.assertEqual([c.name for c in built.failed()], []) self.assertEqual(PROPORTION in names(built), hull_type == "Ship") class MirrorOverlapTests(unittest.TestCase): @staticmethod def top(text): mesh = hs.parse_off(text) return hs._triangles_xy(mesh) def test_a_centred_box_overlaps_its_reflection_completely(self): self.assertEqual(hs.mirror_overlap(self.top(box(-2, 2, 0, 4, 0, 1))), 1.0) def test_nothing_overlaps_itself_completely(self): self.assertEqual(hs.mirror_overlap([]), 1.0) def test_a_box_on_one_side_does_not_overlap_it_at_all(self): self.assertEqual(hs.mirror_overlap(self.top(box(1, 3, 0, 4, 0, 1))), 0.0) def test_a_box_straddling_the_axis_unevenly_overlaps_by_the_shared_part(self): # x in [-1, 3]: the reflection covers [-3, 1]; shared [-1, 1] = 2 of a union 6 wide self.assertAlmostEqual(hs.mirror_overlap(self.top(box(-1, 3, 0, 4, 0, 1))), 2 / 6, places=2) class JudgementTests(unittest.TestCase): def test_a_hull_that_keeps_every_rule_passes_every_check(self): self.assertEqual(Fixture().failed(), []) def test_the_language_decides_proportions_not_the_hull(self): # The same 6 x 10 x 2 body: inside Zhestokost's ranges, outside a language that wants tall narrow bodies. fixture = Fixture() fixture.library["zhestokost"] = language(proportion=((0.8, 1.2), (0.1, 0.3))) self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) def test_a_body_too_tall_for_the_language_fails_proportion_and_nothing_else(self): fixture = Fixture() fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) def test_a_body_too_wide_for_its_length_fails_proportion_and_nothing_else(self): fixture = Fixture() fixture.parts["primary"] = (box(-6, 6, -5, 5, 0, 4), "") # h/w 0.33 but w/l 1.2 self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) def test_every_zone_of_the_language_is_coloured_with_its_own_palette_entry(self): built = Fixture().build() self.assertEqual(built.colours, {"primary": (0.1, 0.2, 0.3), "secondary": (0.2, 0.2, 0.3)}) def test_a_body_too_narrow_for_the_language_fails_proportion(self): fixture = Fixture() fixture.parts["primary"] = (box(-1, 1, -5, 5, 0, 1), "") self.assertIn("the body's proportions lie inside the language's ranges", fixture.failed()) def test_a_bilateral_language_refuses_a_lopsided_hull(self): fixture = Fixture() fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") self.assertEqual(fixture.failed(), ["the top view obeys the language's bilateral rule"]) def test_a_bilateral_bound_is_met_by_exactly_that_overlap(self): fixture = Fixture() fixture.library["zhestokost"] = language(symmetry=("bilateral", 1.0)) self.assertEqual(fixture.failed(), []) def test_an_asymmetric_language_refuses_a_symmetric_hull(self): fixture = Fixture() fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9)) self.assertEqual(fixture.failed(), ["the top view obeys the language's asymmetric rule"]) def test_an_asymmetric_language_accepts_a_lopsided_hull(self): fixture = Fixture() fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9)) fixture.parts["secondary"] = (box(2, 8, -4, 4, 0, 3), "") self.assertEqual(fixture.failed(), []) def test_a_zone_the_language_names_must_have_geometry(self): fixture = Fixture() fixture.parts["secondary"] = ("", "") self.assertEqual(fixture.failed(), ["every zone of the language has geometry"]) def test_the_triangle_budget_is_a_range_with_two_ends(self): for budget in ((10, 20), (1000, 2000)): fixture = Fixture() fixture.parts["brief"] = ("", brief(triangles=budget)) self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"], budget) def test_the_budget_counts_the_triangles_of_the_emitter_meshes_too(self): # 12 + 12 triangles of zones, 12 of the emitter: a budget of 30 holds without the emitter and not with it fixture = Fixture() fixture.parts["brief"] = ("", brief(triangles=(10, 30))) self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"]) def test_the_length_is_a_range_with_two_ends(self): for length in ((1, 5), (30, 40)): fixture = Fixture() fixture.parts["brief"] = ("", brief(length=length)) self.assertEqual(fixture.failed(), ["the length lies inside the brief's range"], length) MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" def test_a_muzzle_facing_any_way_is_accepted(self): # Which way a gun points is HullData's hardpoint rotation; the hull source never judges it. fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) + echo("anchor", ["Gun.B", "weapon-mount", [0, 0, 3], 0, ""]) + echo("anchor", ["Gun.B.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.B"]) + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) self.assertEqual(fixture.failed(), []) def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])) self.assertEqual(fixture.failed(), [self.MUZZLE]) fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])) self.assertEqual(fixture.failed(), [self.MUZZLE]) # Another gun's muzzle does not pair this one. fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + echo("anchor", ["Gun.B", "weapon-mount", [2, 0, 3], 0, ""])) self.assertEqual(fixture.failed(), [self.MUZZLE]) def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""]) + echo("anchor", ["Th.A.muzzle", "weapon-muzzle", [1, 2, 1], 0, "Th.A"])) self.assertEqual(fixture.failed(), [self.MUZZLE]) def test_an_emitter_with_no_mesh_is_refused(self): fixture = Fixture() fixture.parts["emit:Th.A"] = ("", "") self.assertEqual(fixture.failed(), ["every emitter and radiator anchor has a mesh"]) def test_the_collider_zone_is_the_languages_body(self): self.assertEqual(Fixture().build().body_zone, "primary") class DonorTests(unittest.TestCase): """A Pirate hull: the Pirate language has no body and no proportions, so the donor's decide.""" @staticmethod def pirate(donor_proportion=((0.12, 0.35), (0.55, 1.1)), donor="rnd"): fixture = Fixture() fixture.parts["brief"] = ("", brief("pirate", donor, length=(8, 14))) fixture.library = {"pirate": language("pirate", ("primer", "weld"), body="", proportion=None, symmetry=("asymmetric", 0.9)), "rnd": language("rnd", ("primary", "secondary"), proportion=donor_proportion)} for key in ("primary", "secondary"): del fixture.parts[key] fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") # h/w 0.17, w/l 0.6 fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "") fixture.parts["primer"] = (box(2, 4, -4, -2, 0, 1), "") fixture.parts["weld"] = (box(2, 6, 0, 4, 0, 1), "") return fixture def test_the_donors_proportions_judge_the_body(self): fixture = self.pirate() self.assertEqual(fixture.build().body_zone, "rnd:primary") self.assertEqual(fixture.failed(), []) self.assertEqual(self.pirate(donor_proportion=((0.5, 0.9), (0.55, 1.1))).failed(), ["the body's proportions lie inside the language's ranges"]) def test_the_donors_zones_are_required_and_coloured_from_the_donor(self): fixture = self.pirate() fixture.parts["rnd:secondary"] = ("", "") self.assertEqual(fixture.failed(), ["every zone of the language has geometry"]) built = fixture.build() for zone in ("primary", "secondary"): self.assertEqual(built.colours[f"rnd:{zone}"], built.languages["rnd"].colour(zone)) self.assertNotEqual(built.colours["rnd:primary"], built.colours["rnd:secondary"]) DONOR = "a donor is named exactly when the language has no proportions of its own" def test_a_pirate_hull_that_names_no_donor_is_refused(self): fixture = self.pirate(donor="") del fixture.library["rnd"] for key in ("rnd:primary", "rnd:secondary"): del fixture.parts[key] self.assertIn(self.DONOR, fixture.failed()) def test_a_hull_whose_language_has_proportions_of_its_own_may_not_name_a_donor(self): fixture = Fixture() fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) fixture.library["rnd"] = language("rnd", ("primary", "secondary")) fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "") self.assertIn(self.DONOR, fixture.failed()) class LanguageOwnershipTests(unittest.TestCase): """The faction's language file owns its palette, proportions and symmetry rule: the hull's own `lang` echo is compared with it and never judged against.""" OWN = "the hull includes its faction's language files and changes none of them" PROPORTIONS = "the body's proportions lie inside the language's ranges" def redefined(self, **language_arguments): fixture = Fixture() fixture.hull_lang = language(**language_arguments) return fixture def test_a_hull_that_includes_the_languages_unchanged_passes(self): self.assertEqual(Fixture().failed(), []) def test_a_hull_that_redefines_its_palette_fails_and_is_still_coloured_from_the_file(self): fixture = Fixture() fixture.hull_lang = echo("lang", ["zhestokost", [["primary", [1, 0, 1]], ["secondary", [1, 0, 1]]], "primary", [1, 0.6, 0.2], [0.5, 0.5, 0.5], [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97], MAKER]) self.assertEqual(fixture.failed(), [self.OWN]) built = fixture.build() self.assertEqual(built.colours["primary"], built.languages["zhestokost"].colour("primary")) self.assertNotEqual(built.colours["primary"], (1, 0, 1)) def test_a_hull_that_adds_a_zone_to_its_palette_fails(self): self.assertEqual(self.redefined(zones=("primary", "secondary", "extra")).failed(), [self.OWN]) def test_a_hull_that_redefines_the_proportions_fails_and_is_judged_by_the_file(self): fixture = self.redefined(proportion=((0.0, 9.0), (0.0, 9.0))) fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") # too tall for the file's ranges self.assertEqual(sorted(fixture.failed()), sorted([self.OWN, self.PROPORTIONS])) def test_a_hull_that_redefines_the_symmetry_rule_fails_and_is_judged_by_the_file(self): fixture = self.redefined(symmetry=("asymmetric", 0.99)) self.assertEqual(fixture.failed(), [self.OWN]) fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") # lopsided: the file's bilateral rule refuses it self.assertIn("the top view obeys the language's bilateral rule", fixture.failed()) def test_a_hull_cannot_restate_its_languages_maker(self): """The language file owns its maker (the Faction record key Package compares with the product's): an included language echoed with another maker fails the includes-unchanged check, and the hull is read as the file's.""" fixture = self.redefined(maker="another-faction-record-key") self.assertEqual(fixture.failed(), [self.OWN]) self.assertEqual(fixture.build().languages["zhestokost"].maker, MAKER) def test_a_second_echo_of_the_language_after_the_first_fails(self): fixture = Fixture() fixture.hull_lang = language() + language(symmetry=("asymmetric", 0.99)) self.assertEqual(fixture.failed(), [self.OWN]) def test_the_genuine_language_echoed_after_an_altered_one_does_not_save_the_hull(self): fixture = Fixture() fixture.hull_lang = language(symmetry=("asymmetric", 0.99)) + language() self.assertEqual(fixture.failed(), [self.OWN]) def test_a_hull_that_echoes_no_language_or_another_factions_fails(self): for text in ("", language("rnd")): fixture = Fixture() fixture.hull_lang = text self.assertEqual(fixture.failed(), [self.OWN]) def test_a_pirate_hull_must_include_its_donors_language_too(self): fixture = DonorTests.pirate() fixture.hull_lang = language("pirate", ("primer", "weld"), body="", proportion=None, symmetry=("asymmetric", 0.9)) self.assertEqual(fixture.failed(), [self.OWN]) def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship", fixture.projector) self.assertEqual(sorted(asked), ["pirate", "rnd"]) class FakeOpenScad: """Stands in for subprocess.Popen: records each command and environment, writes `off` where -o points, and answers with `output` and `returncode`.""" def __init__(self, returncode=0, off="", output=""): self.returncode, self.off, self.output, self.calls = returncode, off, output, [] self.pid = 4242 def __call__(self, command, **kwargs): self.calls.append((command, kwargs.get("env"))) if self.off: Path(command[command.index("-o") + 1]).write_text(self.off, encoding="utf-8") return self def communicate(self, timeout=None): return self.output, None def kill(self): pass class LanguageLibraryTests(unittest.TestCase): def test_a_faction_name_that_is_not_a_plain_word_or_has_no_file_is_refused_without_a_process(self): with tempfile.TemporaryDirectory() as folder, mock.patch.object(hs.subprocess, "Popen") as popen: library = Path(folder) / "languages" library.mkdir() (library / "zhestokost.scad").write_text("", encoding="utf-8") (Path(folder) / "escape.scad").write_text("", encoding="utf-8") # a file outside the library read = hs.language_library("openscad", library) for faction in ("../escape", "Zhestokost", "", "nobody", "zhestokost.scad"): with self.assertRaises(hs.HullSourceError) as caught: read(faction) self.assertNotIn("nobody", str(caught.exception)) popen.assert_not_called() def test_the_language_is_read_from_the_factions_own_file_run_alone(self): with tempfile.TemporaryDirectory() as folder: library = Path(folder) (library / "zhestokost.scad").write_text("", encoding="utf-8") openscad = FakeOpenScad(output=language()) with mock.patch.object(hs.subprocess, "Popen", openscad): text = hs.language_library("openscad", library)("zhestokost") self.assertEqual(hs.read_languages(text)[0].faction, "zhestokost") command = openscad.calls[0][0] self.assertEqual(command[-1], str(library / "zhestokost.scad")) self.assertEqual(command[command.index("-D") + 1], 'part="lang"') class RunnerTests(unittest.TestCase): def run_part(self, part, openscad, **arguments): with mock.patch.object(hs.subprocess, "Popen", openscad): return hs.openscad_runner("scad", "hull.scad", **arguments)(part) def test_openscad_is_asked_for_a_full_render_on_the_manifold_backend_of_one_part(self): openscad = FakeOpenScad() self.run_part("primary", openscad) command = openscad.calls[0][0] self.assertEqual(command[0], "scad") self.assertEqual(command[command.index("--backend") + 1], "Manifold") self.assertEqual(command[command.index("--render") + 1], "all") self.assertEqual(command[command.index("-D") + 1], 'part="primary"') self.assertEqual(command[-1], "hull.scad") def test_the_language_folder_is_what_openscad_searches_for_includes(self): openscad = FakeOpenScad() self.run_part("primary", openscad, library=Path("some") / "languages") self.assertEqual(openscad.calls[0][1]["OPENSCADPATH"], str(Path("some") / "languages")) def test_a_part_openscad_fails_to_make_is_refused_without_echoing_its_output(self): with self.assertRaises(hs.HullSourceError) as caught: self.run_part("primary", FakeOpenScad(returncode=1, output=SECRET)) self.assertNotIn(SECRET, str(caught.exception)) def test_echo_parts_answer_the_echo_and_geometry_parts_the_off(self): self.assertEqual(self.run_part("brief", FakeOpenScad(output=brief())), ("", brief())) self.assertEqual(self.run_part("primary", FakeOpenScad(off=box(0, 1, 0, 1, 0, 1), output="log")), (box(0, 1, 0, 1, 0, 1), "log")) def test_each_process_is_reported_to_on_start(self): started = [] self.run_part("primary", FakeOpenScad(), on_start=started.append) self.assertEqual(started, [4242]) class FingerprintTests(unittest.TestCase): def test_equal_values_give_equal_digests_and_a_change_gives_another(self): base = hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3) self.assertEqual(base, hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3)) for other in (("b", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3), ("a", (1.0, 2.501), [[0.0, 1.0, 2.0]], 3), ("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 4), ("a", (1.0, 2.5), [[0.0, 1.0], [2.0]], 3)): self.assertNotEqual(base, hs.fingerprint(*other)) def test_a_list_of_two_empty_lists_is_not_one_list_holding_one(self): self.assertNotEqual(hs.fingerprint([], []), hs.fingerprint([[]])) def test_float_noise_below_five_decimals_and_a_negative_zero_do_not_change_it(self): self.assertEqual(hs.fingerprint((1.0, 0.0)), hs.fingerprint((1.0 + 1e-8, -0.0))) self.assertEqual(hs.fingerprint((-1e-9,)), hs.fingerprint((0.0,))) class RefusalTests(unittest.TestCase): def test_a_source_that_includes_no_language_for_its_faction_is_refused(self): fixture = Fixture() fixture.parts["brief"] = ("", brief("nobody")) with self.assertRaises(hs.HullSourceError): fixture.build() def test_a_named_donor_that_has_no_language_file_is_refused(self): fixture = Fixture() fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) with self.assertRaises(hs.HullSourceError): fixture.build() def test_a_language_file_that_echoes_another_faction_or_not_exactly_one_language_is_refused(self): for text in (language("rnd"), language() + language(), ""): fixture = Fixture() fixture.library["zhestokost"] = text with self.assertRaises(hs.HullSourceError): fixture.build() def test_anchors_that_repeat_an_id_or_name_an_unknown_role_are_refused_without_echoing_them(self): for anchors in (echo("anchor", [SECRET, "weapon-mount", [0, 0, 0], 0, ""]) * 2, echo("anchor", [SECRET, "map-icon", [0, 0, 0], 0, ""])): fixture = Fixture() fixture.parts["anchors"] = ("", anchors) with self.assertRaises(hs.HullSourceError) as caught: fixture.build() self.assertNotIn(SECRET, str(caught.exception)) def test_a_name_that_is_not_a_string_is_refused_wherever_the_source_gives_one(self): def refused(fixture): with self.assertRaises(hs.HullSourceError): fixture.build() for index in range(3): # faction, donor, role fixture = Fixture() raw = ["zhestokost", "", "tender", [8, 14], [10, 100], ["a read"]] raw[index] = 7 fixture.parts["brief"] = ("", echo("brief", raw)) refused(fixture) fixture = Fixture() fixture.parts["brief"] = ("", echo("brief", ["zhestokost", "", "tender", [8, 14], [10, 100], [7]])) refused(fixture) for anchor in ([7, "weapon-mount", [0, 0, 3], 0, ""], ["Gun.A", 7, [0, 0, 3], 0, ""], ["Gun.A", "weapon-mount", [0, 0, 3], 0, 7]): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", anchor)) refused(fixture) for raw in ([7, [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER], ["zhestokost", [[7, [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER], ["zhestokost", [["primary", [1, 1, 1]]], 7, [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER]): with self.assertRaises(hs.HullSourceError): hs.read_languages(echo("lang", raw)) def test_the_maker_is_the_eighth_element_of_a_language_echo(self): self.assertEqual(hs.read_languages(language(maker="K1"))[0].maker, "K1") raw = ["zhestokost", [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]] for bad in (raw, raw + [""], raw + [7], raw + ["K1", "extra"]): # seven elements, empty maker, not text, nine with self.assertRaises(hs.HullSourceError) as caught: hs.read_languages(echo("lang", bad)) self.assertNotIn("K1", str(caught.exception)) self.assertEqual(hs.read_languages(echo("lang", raw + ["K1"]))[0].maker, "K1") def test_a_malformed_anchor_is_refused_without_echoing_it(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0], 0, ""])) with self.assertRaises(hs.HullSourceError) as caught: fixture.build() self.assertNotIn(SECRET, str(caught.exception)) def test_check_names_and_details_carry_no_hull_supplied_text(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0, 3], 0, ""])) fixture.parts["emit:Th.A"] = ("", "") for check in fixture.build().checks: self.assertNotIn(SECRET, check.name + check.detail) def test_the_module_writes_no_json_and_starts_no_second_process(self): source = (Path(hs.__file__)).read_text(encoding="utf-8") for forbidden in ("json.dump", "ThreadPool", "concurrent.futures", "multiprocessing"): self.assertNotIn(forbidden, source) self.assertEqual(source.count("subprocess.Popen"), 1) LINES_OVER = "every kit line lies over the hull's top view" LINES_DISTINCT = "no two kit lines are the same" LINES_FRAME = "every kit line is drawn in the frame of its geometry" SILHOUETTE = "the top view yields a silhouette" def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): return echo("line", [layer, cyclic, [list(p) for p in points]]) class FrameCheckTests(unittest.TestCase): """The kit draws a mark where each line starts, in the frame the geometry is drawn in; the line is echoed in the frame lp_at tracks. A part placed by anything but lp_at has its mark and its line in different places.""" def built(self, kit, marks=None): fixture = Fixture() fixture.parts["lines"] = ("", kit) if marks is not None: fixture.marks = (marks_off(marks), "") return fixture.build() def test_marks_where_the_lines_start_pass_and_the_check_is_listed(self): built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2)))) self.assertEqual(built.failed(), []) self.assertIn(LINES_FRAME, names(built)) def test_a_mark_three_metres_from_its_line_fails_the_frame_check_and_nothing_else(self): built = self.built(line(points=((-1, -1, 2), (1, 1, 2))), marks=[(-1, 2, 2)]) self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) def test_a_line_with_no_mark_fails(self): built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2))), marks=[(-1, -1, 2)]) self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) def test_a_mark_with_no_line_fails(self): built = self.built(line(), marks=[(-1, -1, 2), (2, 2, 2)]) self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) def test_a_mark_a_millimetre_off_is_the_same_place(self): self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) def test_a_source_with_no_lines_asks_for_no_marks_because_openscad_refuses_a_part_that_draws_nothing(self): fixture = Fixture() runner = fixture.runner() asked = [] built = hs.build(lambda part: asked.append(part) or runner(part), fixture.read_library, "Ship", fixture.projector) self.assertNotIn("marks", asked) self.assertEqual(built.failed(), []) def test_the_detail_counts_the_lines_and_marks_that_disagree(self): built = self.built(line(), marks=[(5, 5, 2)]) self.assertEqual({c.name: c.detail for c in built.checks}[LINES_FRAME], "1 lines and 1 marks disagree") class LineReadingTests(unittest.TestCase): def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") read = hs.read_lines(text) self.assertEqual([(l.layer, l.cyclic, len(l.points)) for l in read], [("step", True, 3), ("fitting", False, 2)]) self.assertEqual(read[0].points[1], (1, 0, 1)) def test_a_layer_outside_the_kit_layers_is_refused_without_echoing_it(self): for layer in ("silhouette", "outlines", "", SECRET): with self.subTest(layer=layer), self.assertRaises(hs.HullSourceError) as caught: hs.read_lines(line(layer)) self.assertNotIn(SECRET, str(caught.exception)) def test_a_line_of_one_point_or_a_point_that_is_not_finite_is_refused(self): for text in (line(points=((0, 0, 0),)), line(points=()), 'ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n', 'ECHO: line = ["seam", false, [[0, 0, Infinity], [1, 1, 1]]]\n', line(points=((0, 0), (1, 1)))): with self.subTest(text=text), self.assertRaises(hs.HullSourceError): hs.read_lines(text) def test_a_cyclic_flag_that_is_not_a_boolean_is_refused(self): with self.assertRaises(hs.HullSourceError): hs.read_lines(echo("line", ["seam", 1, [[0, 0, 0], [1, 1, 1]]])) SVG = """ """ class SvgContourTests(unittest.TestCase): def test_contours_come_back_in_model_coordinates_with_y_negated(self): contours = hs.parse_svg_contours(SVG) self.assertEqual(contours[0], [(0.0, 4.0), (4.0, 4.0), (4.0, 1.0), (0.0, 1.0)]) self.assertEqual(contours[1][0], (-1e-05, -2.0)) def test_a_contour_under_a_quarter_square_metre_is_a_sliver_and_dropped(self): self.assertEqual(len(hs.parse_svg_contours(SVG)), 2) def test_a_drawing_with_no_path_has_no_contours(self): self.assertEqual(hs.parse_svg_contours(""), []) class LineBuildTests(unittest.TestCase): def built(self, kit="", **parts): fixture = Fixture() fixture.parts["lines"] = ("", kit) fixture.parts.update(parts) return fixture, fixture.build() def test_a_source_with_no_lines_has_the_silhouette_alone_and_passes_every_check(self): _, built = self.built() self.assertEqual([(l.layer, l.cyclic) for l in built.lines], [("silhouette", True)]) self.assertEqual(built.failed(), []) def test_the_silhouette_is_first_cyclic_and_at_the_top_of_the_geometry(self): _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.0), (-3.0, 5.0)}) def test_each_layer_carries_its_fixed_style(self): kit = "".join(line(layer) for layer in hs.KIT_LAYERS) _, built = self.built(kit) self.assertEqual([l.rgba() for l in built.lines], [hs.LINE_STYLE[l.layer] for l in built.lines]) self.assertEqual(len({l.rgba() for l in built.lines}), 5) self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) def test_the_projector_is_given_every_zone_mesh_that_has_geometry_and_no_anchor_mesh(self): fixture, given = Fixture(), [] hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) self.assertEqual(given, [2]) # primary and secondary; the emitter mesh is not part of the layout's top view def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) def test_a_kit_line_a_little_off_the_edge_still_lies_over_the_hull(self): _, built = self.built(line(points=((3.2, 0, 2), (3.2, 1, 2)))) self.assertEqual(built.failed(), []) _, built = self.built(line(points=((3.4, 0, 2), (3.4, 1, 2)))) self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) def test_the_inside_of_a_hole_in_the_silhouette_is_not_over_the_hull(self): hole = [(-1, -1), (1, -1), (1, 1), (-1, 1)] outer = [(-3, -5), (3, -5), (3, 5), (-3, 5)] self.assertTrue(hs.over_silhouette((2, 0, 0), [outer, hole])) self.assertFalse(hs.over_silhouette((0, 0, 0), [outer, hole])) def test_two_equal_kit_lines_fail_the_distinct_check_and_nothing_else(self): _, built = self.built(line() + line("fitting")) self.assertEqual([c.name for c in built.failed()], [LINES_DISTINCT]) def test_lines_that_differ_by_a_millimetre_or_more_are_distinct(self): _, built = self.built(line(points=((0, 0, 2), (1, 0, 2))) + line(points=((0, 0.002, 2), (1, 0, 2)))) self.assertEqual(built.failed(), []) def test_no_contour_fails_the_silhouette_check(self): fixture = Fixture() built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) self.assertEqual([c.name for c in built.failed()], [SILHOUETTE]) self.assertEqual(built.lines, []) def test_a_kit_line_with_no_silhouette_to_lie_over_fails_both_checks(self): fixture = Fixture() fixture.parts["lines"] = ("", line()) built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) self.assertEqual(sorted(c.name for c in built.failed()), sorted([SILHOUETTE, LINES_OVER])) def test_a_source_with_no_geometry_asks_for_no_silhouette(self): fixture = Fixture() fixture.parts.update({"primary": ("", ""), "secondary": ("", ""), "emit:Th.A": ("", "")}) built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: self.fail("projected nothing")) self.assertEqual(built.lines, []) def test_a_refused_line_is_refused_without_echoing_it(self): fixture = Fixture() fixture.parts["lines"] = ("", line(SECRET)) with self.assertRaises(hs.HullSourceError) as caught: fixture.build() self.assertNotIn(SECRET, str(caught.exception)) class WrapperOpenScad(FakeOpenScad): """Keeps the wrapper scad and the OFF files OpenSCAD is given, which vanish with the temp folder.""" def __call__(self, command, **kwargs): wrapper = Path(command[-1]) self.wrapper = wrapper.read_text(encoding="utf-8") self.imports = {f.name: f.read_text(encoding="utf-8") for f in wrapper.parent.glob("*.off")} return super().__call__(command, **kwargs) class ProjectorTests(unittest.TestCase): def project(self, openscad, meshes): with mock.patch.object(hs.subprocess, "Popen", openscad): return hs.openscad_projector("scad")(meshes) def test_the_meshes_are_unioned_projected_and_the_svg_read_back(self): a, b = hs.parse_off(box(0, 1, 0, 1, 0, 1)), hs.parse_off(box(2, 3, 0, 1, 0, 1)) openscad = WrapperOpenScad(off=SVG) contours = self.project(openscad, [a, b]) self.assertEqual(openscad.wrapper.split(), ["projection()", "union()", "{", 'import("m0.off");', 'import("m1.off");', "}"]) self.assertEqual(sorted(openscad.imports), ["m0.off", "m1.off"]) self.assertEqual(hs.parse_off(openscad.imports["m1.off"]).bounds(), b.bounds()) command = openscad.calls[0][0] self.assertEqual(command[command.index("--backend") + 1], "Manifold") self.assertTrue(command[command.index("-o") + 1].endswith(".svg")) self.assertEqual(len(contours), 2) def test_a_failed_or_silent_openscad_is_refused_without_echoing_its_output(self): for openscad in (FakeOpenScad(returncode=1, output=SECRET), FakeOpenScad(output=SECRET)): with self.assertRaises(hs.HullSourceError) as caught: self.project(openscad, [hs.parse_off(box(0, 1, 0, 1, 0, 1))]) self.assertNotIn(SECRET, str(caught.exception)) def test_the_process_is_reported_to_on_start(self): started = [] with mock.patch.object(hs.subprocess, "Popen", WrapperOpenScad(off=SVG)): hs.openscad_projector("scad", on_start=started.append)([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) self.assertEqual(started, [4242]) def test_the_lines_part_answers_the_echo(self): with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(output=line())): self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) class LineStyleTests(unittest.TestCase): def test_the_style_table_is_the_one_the_underlay_reads(self): self.assertEqual(hs.LINE_STYLE, { "silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)}) self.assertEqual(hs.KIT_LAYERS, ("outline", "step", "seam", "fitting")) def test_a_schematic_line_cannot_be_changed_after_it_is_read(self): read = hs.read_lines(line())[0] with self.assertRaises(dataclasses.FrozenInstanceError): read.layer = "step" def test_the_four_echo_parts_are_written_as_echo_files_and_geometry_parts_as_off(self): for part, suffix in (("brief", ".echo"), ("lang", ".echo"), ("anchors", ".echo"), ("lines", ".echo"), ("primary", ".off")): with self.subTest(part=part): openscad = FakeOpenScad() with mock.patch.object(hs.subprocess, "Popen", openscad): hs.openscad_runner("scad", "hull.scad")(part) command = openscad.calls[0][0] self.assertTrue(command[command.index("-o") + 1].endswith(suffix)) class LineRefusalTextTests(unittest.TestCase): def refusal(self, text): with self.assertRaises(hs.HullSourceError) as caught: hs.read_lines(text) return str(caught.exception) def test_each_refusal_says_one_fixed_thing(self): self.assertEqual(self.refusal(echo("line", ["seam", False])), "A schematic line has the wrong shape") self.assertEqual(self.refusal(echo("line", ["seam", 1, []])), "A schematic line has the wrong shape") self.assertEqual(self.refusal(line("nope")), "A schematic line names a layer a kit does not draw") self.assertEqual(self.refusal(line(points=((0, 0, 0),))), "A schematic line has fewer than two points") self.assertEqual(self.refusal('ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n'), "A schematic line has a point that is not a finite number") class ContourAreaTests(unittest.TestCase): @staticmethod def svg(*rings): paths = "\n".join("M " + " L ".join(f"{x},{-y}" for x, y in ring) + " z" for ring in rings) return f'' def test_a_contour_is_kept_from_a_quarter_square_metre_whichever_way_it_winds_or_where_it_lies(self): square = [(3, 7), (3.5, 7), (3.5, 7.5), (3, 7.5)] triangle = [(10, 10), (11, 10), (10, 11)] for ring in (square, square[::-1], triangle, triangle[::-1]): with self.subTest(ring=ring): self.assertEqual(len(hs.parse_svg_contours(self.svg(ring))), 1) def test_a_contour_just_under_a_quarter_square_metre_is_dropped(self): for ring in ([(3, 7), (3.6, 7), (3.6, 7.4), (3, 7.4)], [(0, 0), (1, 0), (0, 0.49)]): with self.subTest(ring=ring): self.assertEqual(hs.parse_svg_contours(self.svg(ring)), []) def test_a_contour_of_fewer_than_three_points_is_dropped(self): self.assertEqual(hs.parse_svg_contours(self.svg([(0, 0), (9, 9)])), []) class OverSilhouetteTests(unittest.TestCase): TRIANGLE = [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)] def over(self, x, y, contours=None, z=99.0): return hs.over_silhouette((x, y, z), [self.TRIANGLE] if contours is None else contours) def test_inside_and_far_outside(self): self.assertTrue(self.over(2, 2)) self.assertFalse(self.over(8, 8)) self.assertFalse(self.over(-5, 3)) def test_the_height_of_a_point_does_not_matter(self): self.assertTrue(self.over(2, 2, z=-50.0)) self.assertFalse(self.over(8, 8, z=2.0)) def test_a_point_outside_but_within_the_tolerance_of_a_slanted_edge_is_over_the_hull(self): # the hypotenuse x + y = 10; (5.1, 5.1) is 0.14 m outside it, (5.3, 5.3) is 0.42 m outside self.assertTrue(self.over(5.1, 5.1)) self.assertFalse(self.over(5.3, 5.3)) def test_a_point_beyond_the_end_of_an_edge_is_measured_to_its_end(self): self.assertTrue(self.over(10.25, 0)) self.assertFalse(self.over(10.35, 0)) self.assertTrue(self.over(-0.25, -0.0)) self.assertFalse(self.over(0, -0.35)) def test_a_ray_through_a_vertex_counts_the_edge_once(self): diamond = [(0.0, -2.0), (2.0, 0.0), (0.0, 2.0), (-2.0, 0.0)] self.assertTrue(self.over(0.5, 0, [diamond])) self.assertTrue(self.over(-0.5, 0, [diamond])) self.assertFalse(self.over(3.0, 0, [diamond])) self.assertFalse(self.over(0.0, 3.0, [diamond])) def test_the_even_odd_rule_alternates_through_nested_contours(self): rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 7.0, 4.0, 1.0)] self.assertTrue(self.over(8.5, 0, rings)) # in the outer ring only self.assertFalse(self.over(5.5, 0, rings)) # a hole self.assertTrue(self.over(2.5, 0, rings)) # island in the hole def test_three_nested_contours_put_the_centre_back_on_the_hull(self): rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 6.0, 2.0)] self.assertTrue(self.over(0.0, 0.0, rings)) class ProcessRefusalTests(unittest.TestCase): def test_openscad_that_cannot_start_is_refused_in_fixed_words(self): with mock.patch.object(hs.subprocess, "Popen", side_effect=OSError(SECRET)): for make in (lambda: hs.openscad_runner("scad", "hull.scad")("primary"), lambda: hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])): with self.assertRaises(hs.HullSourceError) as caught: make() self.assertEqual(str(caught.exception), "OpenSCAD could not be started") def test_openscad_that_does_not_finish_is_killed_and_refused_in_fixed_words(self): class Slow(FakeOpenScad): killed = False def communicate(self, timeout=None): if not self.killed: raise hs.subprocess.TimeoutExpired("scad", timeout) return "", None def kill(self): self.killed = True slow = Slow() with mock.patch.object(hs.subprocess, "Popen", slow): with self.assertRaises(hs.HullSourceError) as caught: hs.openscad_runner("scad", "hull.scad")("primary") self.assertEqual((str(caught.exception), slow.killed), ("OpenSCAD did not finish a part in time", True)) def test_a_projection_openscad_never_wrote_is_refused(self): with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad()): with self.assertRaises(hs.HullSourceError) as caught: hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") def test_a_refusing_openscad_says_so_in_fixed_words(self): with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(returncode=2)): with self.assertRaises(hs.HullSourceError) as caught: hs.openscad_runner("scad", "hull.scad")("primary") self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") class LineCheckDetailTests(unittest.TestCase): def details(self, kit, projector=Fixture.projector): fixture = Fixture() fixture.parts["lines"] = ("", kit) built = hs.build(fixture.runner(), fixture.read_library, "Ship", projector) return {c.name: c.detail for c in built.checks} def test_the_details_count_what_the_checks_found(self): got = self.details(line(points=((0, 0, 2), (20, 0, 2))) + line("step") + line("step")) self.assertEqual((got[SILHOUETTE], got[LINES_OVER], got[LINES_DISTINCT]), ("1 contours", "1 outside", "1 repeated")) def test_a_silhouette_is_not_a_kit_line_so_two_equal_contours_are_not_a_repeat(self): twice = lambda meshes: Fixture.projector(meshes) * 2 got = self.details("", twice) self.assertEqual((got[SILHOUETTE], got[LINES_DISTINCT]), ("2 contours", "0 repeated")) fixture = Fixture() built = hs.build(fixture.runner(), fixture.read_library, "Ship", twice) self.assertEqual(built.failed(), []) class BytesOpenScad(FakeOpenScad): """Writes raw bytes where -o points: an SVG with a byte that is not UTF-8 in its title.""" def __call__(self, command, **kwargs): self.calls.append((command, kwargs.get("env"))) Path(command[command.index("-o") + 1]).write_bytes(bytes([255]) + SVG.encode("utf-8")) return self class SilhouetteReadTests(unittest.TestCase): def test_a_drawing_with_a_byte_that_is_not_utf8_still_reads(self): with mock.patch.object(hs.subprocess, "Popen", BytesOpenScad()): self.assertEqual(len(hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])), 2) def test_a_number_that_is_not_a_number_is_refused_in_fixed_words(self): with self.assertRaises(hs.HullSourceError) as caught: hs.parse_svg_contours('') self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable") if __name__ == "__main__": unittest.main()