|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Unit tests for build.py's deterministic pieces — the DSL parser, text wrapping, coordinate |
| 4 | +formatting, and the hint geometry/arrow math. These guard the class of regression mapcheck |
| 5 | +CANNOT see: e.g. `arrow=` silently no longer parsed, a flipped sign in the angle math, or |
| 6 | +broken side/stage inheritance. No Pillow and no draw.io needed (text metrics are injected), |
| 7 | +so it runs anywhere in a fraction of a second. |
| 8 | +
|
| 9 | + python tools/mapgen/test_build.py # or: python -m unittest -v |
| 10 | +""" |
| 11 | +import math, os, sys, tempfile, unittest |
| 12 | + |
| 13 | +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 14 | +import build |
| 15 | + |
| 16 | + |
| 17 | +def parse(dsl_text): |
| 18 | + """Run build.parse_dsl over an in-memory DSL string via a temp file.""" |
| 19 | + with tempfile.NamedTemporaryFile("w", suffix=".dsl", delete=False, encoding="utf-8") as f: |
| 20 | + f.write(dsl_text) |
| 21 | + path = f.name |
| 22 | + try: |
| 23 | + return build.parse_dsl(path) |
| 24 | + finally: |
| 25 | + os.unlink(path) |
| 26 | + |
| 27 | + |
| 28 | +DSL = """\ |
| 29 | +# a comment, ignored |
| 30 | +spine center=root |
| 31 | +
|
| 32 | +[a] side=left grade=middle |
| 33 | + [a1] stage=2 |
| 34 | + [a2] |
| 35 | +[b] side=right grade=senior stage=1 |
| 36 | +[c] |
| 37 | +
|
| 38 | +frame [f] title=libs contains=a |
| 39 | +hint [h1] angle=90 dist=50 arrow=top -> a1, a2 |
| 40 | +hint [h2] -> b |
| 41 | +""" |
| 42 | + |
| 43 | + |
| 44 | +class ParseDsl(unittest.TestCase): |
| 45 | + def setUp(self): |
| 46 | + self.nodes, self.order, self.hints, self.frames, self.spine = parse(DSL) |
| 47 | + |
| 48 | + def test_order_and_hierarchy(self): |
| 49 | + self.assertEqual(self.order, ["a", "a1", "a2", "b", "c"]) |
| 50 | + self.assertEqual(self.nodes["a"]["children"], ["a1"]) |
| 51 | + self.assertEqual(self.nodes["a1"]["children"], ["a2"]) |
| 52 | + self.assertEqual(self.nodes["a1"]["parent"], "a") |
| 53 | + self.assertEqual(self.nodes["a2"]["parent"], "a1") |
| 54 | + self.assertIsNone(self.nodes["a"]["parent"]) |
| 55 | + |
| 56 | + def test_depth_from_indentation(self): |
| 57 | + self.assertEqual(self.nodes["a"]["depth"], 0) |
| 58 | + self.assertEqual(self.nodes["a1"]["depth"], 1) |
| 59 | + self.assertEqual(self.nodes["a2"]["depth"], 2) |
| 60 | + |
| 61 | + def test_grade_default_and_explicit(self): |
| 62 | + self.assertEqual(self.nodes["a"]["grade"], "middle") |
| 63 | + self.assertEqual(self.nodes["b"]["grade"], "senior") |
| 64 | + self.assertEqual(self.nodes["a2"]["grade"], "junior") # default |
| 65 | + |
| 66 | + def test_side_inheritance_and_default(self): |
| 67 | + self.assertEqual(self.nodes["a"]["side"], "left") |
| 68 | + self.assertEqual(self.nodes["a1"]["side"], "left") # inherited |
| 69 | + self.assertEqual(self.nodes["a2"]["side"], "left") # inherited two levels |
| 70 | + self.assertEqual(self.nodes["b"]["side"], "right") |
| 71 | + self.assertEqual(self.nodes["c"]["side"], "right") # default when no parent |
| 72 | + |
| 73 | + def test_stage_inheritance(self): |
| 74 | + self.assertIsNone(self.nodes["a"]["stage"]) |
| 75 | + self.assertEqual(self.nodes["a1"]["stage"], 2) |
| 76 | + self.assertEqual(self.nodes["a2"]["stage"], 2) # inherited |
| 77 | + self.assertEqual(self.nodes["b"]["stage"], 1) |
| 78 | + |
| 79 | + def test_spine(self): |
| 80 | + self.assertEqual(self.spine["center"], "root") |
| 81 | + self.assertIsNone(self.spine["hubx"]) |
| 82 | + self.assertIsNone(self.spine["gate"]) |
| 83 | + |
| 84 | + def test_frame(self): |
| 85 | + self.assertEqual(len(self.frames), 1) |
| 86 | + self.assertEqual(self.frames[0]["id"], "f") |
| 87 | + self.assertEqual(self.frames[0]["titleKey"], "libs") |
| 88 | + self.assertEqual(self.frames[0]["contains"], ["a"]) |
| 89 | + |
| 90 | + def test_hint_attributes(self): |
| 91 | + h1, h2 = self.hints |
| 92 | + self.assertEqual(h1["id"], "h1") |
| 93 | + self.assertEqual(h1["targets"], ["a1", "a2"]) |
| 94 | + self.assertEqual(h1["angle"], 90.0) |
| 95 | + self.assertEqual(h1["dist"], 50.0) |
| 96 | + self.assertEqual(h1["arrow"], "top") |
| 97 | + |
| 98 | + def test_hint_optional_fields_default_none(self): |
| 99 | + _, h2 = self.hints |
| 100 | + self.assertEqual(h2["targets"], ["b"]) |
| 101 | + self.assertIsNone(h2["angle"]) |
| 102 | + self.assertIsNone(h2["dist"]) |
| 103 | + self.assertIsNone(h2["arrow"]) # -> the required-arrow validation flags this |
| 104 | + |
| 105 | + def test_negative_angle_parses(self): |
| 106 | + _, _, hints, _, _ = parse("spine center=r\n[r]\nhint [h] angle=-15 dist=10 arrow=left -> r\n") |
| 107 | + self.assertEqual(hints[0]["angle"], -15.0) |
| 108 | + |
| 109 | + |
| 110 | +class TreeHelpers(unittest.TestCase): |
| 111 | + def setUp(self): |
| 112 | + self.nodes = parse(DSL)[0] |
| 113 | + |
| 114 | + def test_descendants(self): |
| 115 | + self.assertEqual(build.descendants(self.nodes, "a"), ["a", "a1", "a2"]) |
| 116 | + self.assertEqual(build.descendants(self.nodes, "a2"), ["a2"]) |
| 117 | + |
| 118 | + def test_section_of_is_depth1_ancestor(self): |
| 119 | + self.assertEqual(build.section_of(self.nodes, "a2"), "a1") |
| 120 | + self.assertEqual(build.section_of(self.nodes, "a1"), "a1") |
| 121 | + self.assertEqual(build.section_of(self.nodes, "a"), "a") |
| 122 | + |
| 123 | + |
| 124 | +class Num(unittest.TestCase): |
| 125 | + def test_integers_have_no_decimal(self): |
| 126 | + self.assertEqual(build.num(5), "5") |
| 127 | + self.assertEqual(build.num(5.0), "5") |
| 128 | + self.assertEqual(build.num(-12.0), "-12") |
| 129 | + self.assertEqual(build.num(0), "0") |
| 130 | + |
| 131 | + def test_fractions_trimmed(self): |
| 132 | + self.assertEqual(build.num(5.5), "5.5") |
| 133 | + self.assertEqual(build.num(5.25), "5.25") |
| 134 | + self.assertEqual(build.num(-3.5), "-3.5") |
| 135 | + self.assertEqual(build.num(15494.5), "15494.5") |
| 136 | + |
| 137 | + |
| 138 | +class XmlEsc(unittest.TestCase): |
| 139 | + def test_escapes(self): |
| 140 | + self.assertEqual(build.xml_esc('a & b < c > "d"'), 'a & b < c > "d"') |
| 141 | + |
| 142 | + def test_ampersand_first(self): # must escape & before it can double-escape < |
| 143 | + self.assertEqual(build.xml_esc("<&>"), "<&>") |
| 144 | + |
| 145 | + |
| 146 | +class IsCjk(unittest.TestCase): |
| 147 | + def test_ranges(self): |
| 148 | + self.assertTrue(build.is_cjk("中")) |
| 149 | + self.assertTrue(build.is_cjk("(")) # fullwidth paren |
| 150 | + self.assertFalse(build.is_cjk("a")) |
| 151 | + self.assertFalse(build.is_cjk(" ")) |
| 152 | + self.assertFalse(build.is_cjk("+")) |
| 153 | + |
| 154 | + |
| 155 | +class Wrap(unittest.TestCase): |
| 156 | + measure = staticmethod(lambda s: len(s) * 10) # 10px per character |
| 157 | + |
| 158 | + def test_latin_wraps_on_spaces(self): |
| 159 | + self.assertEqual(build.wrap("hello world", 100, self.measure), ["hello", "world"]) |
| 160 | + |
| 161 | + def test_latin_keeps_words_whole(self): |
| 162 | + self.assertEqual(build.wrap("aa bb cc", 60, self.measure), ["aa bb", "cc"]) |
| 163 | + |
| 164 | + def test_overlong_token_not_broken(self): |
| 165 | + self.assertEqual(build.wrap("hello", 20, self.measure), ["hello"]) # can't fit, stays whole |
| 166 | + |
| 167 | + def test_cjk_wraps_per_character(self): |
| 168 | + self.assertEqual(build.wrap("中文字", 20, self.measure), ["中文", "字"]) |
| 169 | + |
| 170 | + |
| 171 | +class FacingSide(unittest.TestCase): |
| 172 | + def test_dominant_axis(self): |
| 173 | + self.assertEqual(build.facing_side(100, 0), "right") |
| 174 | + self.assertEqual(build.facing_side(-100, 0), "left") |
| 175 | + self.assertEqual(build.facing_side(0, 100), "bottom") |
| 176 | + self.assertEqual(build.facing_side(0, -100), "top") |
| 177 | + |
| 178 | + def test_ties_go_horizontal(self): |
| 179 | + self.assertEqual(build.facing_side(50, 50), "right") |
| 180 | + self.assertEqual(build.facing_side(-50, 50), "left") |
| 181 | + |
| 182 | + def test_side_anchor_table(self): |
| 183 | + self.assertEqual(build.SIDE_ANCHOR["left"], (0, 0.5)) |
| 184 | + self.assertEqual(build.SIDE_ANCHOR["right"], (1, 0.5)) |
| 185 | + self.assertEqual(build.SIDE_ANCHOR["top"], (0.5, 0)) |
| 186 | + self.assertEqual(build.SIDE_ANCHOR["bottom"], (0.5, 1)) |
| 187 | + |
| 188 | + |
| 189 | +class HintXy(unittest.TestCase): |
| 190 | + def test_right(self): |
| 191 | + x, cy = build.hint_xy(100, 200, 0, 50, 20) # 0deg = +x |
| 192 | + self.assertAlmostEqual(x, 100 + 50 - 10) # centre 150, minus half-width |
| 193 | + self.assertAlmostEqual(cy, 200) |
| 194 | + |
| 195 | + def test_up(self): |
| 196 | + x, cy = build.hint_xy(100, 200, 90, 50, 20) # 90deg = up -> cy decreases |
| 197 | + self.assertAlmostEqual(x, 90) |
| 198 | + self.assertAlmostEqual(cy, 150) |
| 199 | + |
| 200 | + def test_left(self): |
| 201 | + x, cy = build.hint_xy(100, 200, 180, 50, 20) |
| 202 | + self.assertAlmostEqual(x, 100 - 50 - 10) |
| 203 | + self.assertAlmostEqual(cy, 200) |
| 204 | + |
| 205 | + def test_down(self): |
| 206 | + x, cy = build.hint_xy(100, 200, 270, 50, 20) # 270deg = down -> cy increases |
| 207 | + self.assertAlmostEqual(x, 90) |
| 208 | + self.assertAlmostEqual(cy, 250) |
| 209 | + |
| 210 | + |
| 211 | +if __name__ == "__main__": |
| 212 | + unittest.main(verbosity=2) |
0 commit comments