Skip to content

Commit d5de389

Browse files
Added unit tests for the mapgen engine
1 parent 4b396e1 commit d5de389

5 files changed

Lines changed: 252 additions & 18 deletions

File tree

.github/workflows/map-check.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ jobs:
2626
with:
2727
python-version: "3.12"
2828

29+
- name: Unit-test the generator
30+
run: python tools/mapgen/test_build.py # parser + geometry math; no Pillow/draw.io needed
31+
2932
- name: Install dependencies
3033
run: |
3134
python -m pip install --quiet Pillow

TODO.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
1. unit tests?
2-
3.1 Probably also engine refactoring will be required to clean it up and make more maintainable
3-
41
1. Добавить испанский

tools/mapgen/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ file — open it in draw.io to inspect, but edits there are lost on regeneration
6363
Flags: `--langs en,ru,zh`, `--outdir <dir>`, `--drawio-cli <path>`, `--font <path>` (metrics
6464
font, default `msyh.ttc` / Noto CJK), `--font-family "<name>"` (written into the map).
6565

66+
**Tests:** `python tools/mapgen/test_build.py` covers the deterministic pieces — the DSL
67+
parser (attributes + side/stage inheritance), text wrapping, coordinate formatting, and the
68+
hint geometry/arrow math — with no draw.io or Pillow needed (metrics are injected). It runs
69+
in CI. The layout functions (`assign_y`/`layout_x`/centering) live inside `build_lang` and
70+
aren't unit-tested yet; the map-level guard for those is `mapcheck` (overlaps + map-vs-DSL).
71+
6672
## DSL grammar
6773

6874
A line-based text format. `#` starts a comment. Blank lines ignored.

tools/mapgen/build.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"]
3131

3232

33+
def read_text(path):
34+
with open(path, encoding="utf-8") as f:
35+
return f.read()
36+
37+
3338
def num(v):
3439
"""Format a coordinate: integers without a decimal, else trimmed to 3 places."""
3540
v = float(v)
@@ -40,6 +45,25 @@ def xml_esc(s):
4045
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))
4146

4247

48+
# draw.io exit/entry anchor (x, y in 0..1) for each box edge
49+
SIDE_ANCHOR = {"left": (0, 0.5), "right": (1, 0.5), "top": (0.5, 0), "bottom": (0.5, 1)}
50+
51+
52+
def facing_side(dx, dy):
53+
"""Which box edge faces a point offset by (dx, dy) — the dominant axis wins.
54+
Used for a hint arrow's target entry (and, in extract, the default start side)."""
55+
if abs(dx) >= abs(dy):
56+
return "right" if dx >= 0 else "left"
57+
return "bottom" if dy >= 0 else "top"
58+
59+
60+
def hint_xy(tcx, tcy, angle, dist, width):
61+
"""Top-left x and centre y of a hint box placed at polar (angle deg, dist px) from the
62+
mean target centre (tcx, tcy). 0deg = right, 90deg = up (screen y grows downward)."""
63+
rad = math.radians(angle)
64+
return tcx + dist * math.cos(rad) - width / 2, tcy - dist * math.sin(rad)
65+
66+
4367
# ---- text measurement (Pillow; same approach as tools/mapcheck) ----
4468
def make_measure(font_path):
4569
from PIL import ImageFont # imported lazily so check.py can reuse the DSL parser without Pillow
@@ -89,7 +113,7 @@ def wrap(text, maxw, measure):
89113
def parse_dsl(path):
90114
nodes, order, stack, hints, frames, spine = {}, [], {}, [], [], None
91115
import re
92-
for ln in open(path, encoding="utf-8").read().splitlines():
116+
for ln in read_text(path).splitlines():
93117
t = ln.strip()
94118
if t == "" or t.startswith("#"):
95119
continue
@@ -144,7 +168,7 @@ def load_chrome(path):
144168
chrome = []
145169
if not os.path.exists(path):
146170
return chrome
147-
for l in open(path, encoding="utf-8").read().splitlines():
171+
for l in read_text(path).splitlines():
148172
if l.strip() == "" or l.startswith("#"):
149173
continue
150174
p = l.split("\t") # role, id, x, y, w, h, link, style
@@ -174,7 +198,7 @@ def build_lang(lang, dsl, chrome, args, measure, drawio_dir):
174198
if not os.path.exists(tr_path):
175199
print(f" {lang} SKIPPED (no {lang}.tsv)"); return
176200
tr = {}
177-
for l in open(tr_path, encoding="utf-8").read().splitlines():
201+
for l in read_text(tr_path).splitlines():
178202
if l.strip() == "":
179203
continue
180204
p = l.split("\t", 1)
@@ -299,11 +323,7 @@ def layout_x(nid, x, direction):
299323
tcy = sum(cys) / len(cys) if cys else 0.0
300324
ang = hn["angle"] if hn["angle"] is not None else 0.0
301325
dst = hn["dist"] if hn["dist"] is not None else HINT_GAP + hn["width"] / 2
302-
rad = math.radians(ang)
303-
hcx = tcx + dst * math.cos(rad)
304-
hcy = tcy - dst * math.sin(rad)
305-
hn["x"] = hcx - hn["width"] / 2
306-
hn["cy"] = hcy
326+
hn["x"], hn["cy"] = hint_xy(tcx, tcy, ang, dst, hn["width"])
307327

308328
# ---- emit mxGraph XML ----
309329
sb = ['<mxfile host="mapgen"><diagram name="frag" id="frag"><mxGraphModel dx="0" dy="0" '
@@ -430,16 +450,12 @@ def frame_cell(cid, members, title):
430450
# hint arrows: from a box edge (start side) to each target's facing edge
431451
for hn in hints:
432452
bcx, bcy = hn["x"] + hn["width"] / 2, hn["cy"]
433-
ex, ey = {"left": (0, 0.5), "right": (1, 0.5), "top": (0.5, 0), "bottom": (0.5, 1)}[hn["arrow"]]
453+
ex, ey = SIDE_ANCHOR[hn["arrow"]]
434454
for tid in hn["targets"]:
435455
t = nodes.get(tid)
436456
if not t:
437457
continue
438-
bx, by = bcx - (t["x"] + t["width"] / 2), bcy - t["cy"] # target -> box: entry faces the box
439-
if abs(bx) >= abs(by):
440-
en, eny = (1 if bx >= 0 else 0), 0.5
441-
else:
442-
en, eny = 0.5, (1 if by >= 0 else 0)
458+
en, eny = SIDE_ANCHOR[facing_side(bcx - (t["x"] + t["width"] / 2), bcy - t["cy"])] # entry faces the box
443459
st = (f"edgeStyle=none;html=0;strokeColor=#000000;strokeWidth=1;startArrow=none;endArrow=block;endFill=1;"
444460
f"curved=1;exitX={ex};exitY={ey};exitDx=0;exitDy=0;entryX={en};entryY={eny};entryDx=0;entryDy=0;")
445461
sb.append(f'<mxCell id="a_{hn["id"]}_{tid}" parent="1" edge="1" source="{hn["id"]}" target="{tid}" '
@@ -460,7 +476,7 @@ def frame_cell(cid, members, title):
460476
break
461477
time.sleep(0.25)
462478
if os.path.exists(svg):
463-
tt = open(svg, encoding="utf-8").read()
479+
tt = read_text(svg)
464480
tt = tt.replace("background: transparent; background-color: transparent;",
465481
"background: #ffffff; background-color: light-dark(#ffffff, #121212);")
466482
with open(svg, "w", encoding="utf-8", newline="") as f:

tools/mapgen/test_build.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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 &amp; b &lt; c &gt; &quot;d&quot;')
141+
142+
def test_ampersand_first(self): # must escape & before it can double-escape &lt;
143+
self.assertEqual(build.xml_esc("<&>"), "&lt;&amp;&gt;")
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

Comments
 (0)