Aetheria/tools/blender/tests/test_hull_source.py
2026-10-22 17:17:40 +02:00

1112 lines
57 KiB
Python

"""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 = """<?xml version="1.0" standalone="no"?>
<svg width="9mm" height="9mm" viewBox="0 -4 9 9" xmlns="http://www.w3.org/2000/svg" version="1.1">
<path d="
M 0,-4 L 4,-4 L 4,-1 L 0,-1 z
M 5,1 L 5.001,1 L 5.001,1.001 z
M -1e-05,2 L 2,2 L 2,3 L 0,3 z
" fill="lightgray" stroke="black" stroke-width="0.5"/>
</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("<svg></svg>"), [])
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'<svg><path d="\n{paths}\n" fill="lightgray"/></svg>'
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('<svg><path d="M .,. L 1,1 L 2,2 z"/></svg>')
self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable")
if __name__ == "__main__":
unittest.main()