"""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 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"]]) 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), ""), } 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.parts[part] 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) 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) 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) 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") 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) if __name__ == "__main__": unittest.main()