6.4 KiB
测试
适用:改动
osmassets/osm.py、osmassets/geom.py,或往里加新的纯函数。
怎么跑
python3 -m unittest discover blender/tests
不需要 Blender,用系统 Python 就行。当前 51 个用例,运行约 0.01 秒。
这是全仓唯一的自动化测试。快到没有理由不在每次改动后跑一遍。
能这么跑的前提是 osmassets 的依赖分层——
test_pure.py:19 手动把 blender/ 塞进 sys.path,然后只 import 纯 Python 模块。
最重要的一条:期望值必须从几何推导
test_pure.py:9-11 写得很直白:
The expected values are derived from the geometry, not captured from the implementation — a test that just records current output would ratify a bug.
具体做法是在测试里写清楚为什么是这个数:
def test_half_outside_polygon_is_cut_at_the_boundary(self):
clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0)
self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped))
# A 10x10 square clipped to half its width is a 5x10 rectangle.
self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6)
反例——不要这样写:
def test_clip(self):
# 跑一遍把输出粘过来
self.assertEqual(clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0),
[(0.0, 0.0), (5.0, 0.0), (5.0, 10.0), (0.0, 10.0)])
这种测试在实现正确时和实现错误时同样会通过。它固化的是当前行为,不是需求。
自检:把被测的那个特性从实现里删掉,测试还能过吗?能过就是无效测试。
每个几何函数都要覆盖退化输入
这是本套测试最系统的部分。geom.py 的函数会收到真实 OSM 数据里的各种畸形几何,
所以每个函数都有一个 test_degenerate_input:
| 退化情形 | 例子 |
|---|---|
| 空输入 | clip_polygon([], ...) → [] (:73) |
| 点数不足成面 | clip_polygon([(0,0),(1,1)], ...) → [] (:74) |
| 完全在裁剪框外 | clip_polygon(SQUARE, 20,30,20,30) → [] (:70) |
| 零长线段 | sample_tree_row 跳过而非除零 (:203) |
| 重复顶点 | distance_to_ring 不除零 (:178) |
| 参数为零 | sample_ring_boundary(SQUARE, spacing=0.0) → [] (:123) |
| 单点输入 | sample_polygon_interior([(0,0)], ...) → [] (:146) |
加新几何函数就配一个 test_degenerate_input。 约定是"返回空/零"而不是抛异常——
一个坏多边形不该中断整片区域的构建。
除零守卫要指名道姓
覆盖某个具体守卫时,注释写清楚针对哪一行:
def test_axis_aligned_edge_does_not_divide_by_zero(self):
# A vertical edge crossing the x clip plane exercises the b[0] == a[0]
# guard in the intersection lambdas.
这样守卫被误删时,失败的测试能直接说明它保护的是什么(test_pure.py:76-78)。
其余几条约定
随机采样必须验 seed 可复现
def test_seed_is_deterministic(self):
first = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
second = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
self.assertEqual(first, second)
不可复现的采样会让 parity 校验永久性地红。
任何带随机的新函数都要收 seed 参数并配这个测试(test_pure.py:135-138)。
绕向无关的性质要两个方向都测
sample_ring_boundary 的 inset 对顺时针和逆时针都必须往内缩:
ccw = sample_ring_boundary(SQUARE, spacing=10.0, inset=1.0)
cw = sample_ring_boundary(list(reversed(SQUARE)), spacing=10.0, inset=1.0)
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in ccw))
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in cw))
同理 polygon_area 对绕向不敏感、signed_polygon_area 敏感,两者分别验
(test_pure.py:88-91, 109-115)。
跨边界的连续性要单独测
真实几何常见的坑是"分段处理时每段各自重新开始":
def test_spacing_carries_across_segment_joins(self):
# Two 3m segments with 4m spacing: the second sample must land 1m into
# the second segment, not restart at its origin.
(test_pure.py:190-196)
解析器的容错语义要写死
parse_osm 的两档行为必须都有测试(test_pure.py:343-356):
- 坏节点跳过,不致命:
lon='oops'的节点被忽略,其余照常解析 - 缺
bounds直接抛RuntimeError:没有 bounds 就无法建立投影,继续下去毫无意义
分档理由见 generate_scene.py:9-12:OSM 导出可能带上区域外的 relation 成员,
所以范围只认 bounds 元素,不用全部节点算包围盒。
用真实格式的 fixture
OSM_SAMPLE(test_pure.py:285)是一段真的 OSM XML,故意塞进了坏节点、
action='delete' 的 way、引用了不存在节点的 way。写进临时文件再解析,
tearDown 里 os.unlink。
别 mock 解析器。 解析器的价值就在于处理真实世界的脏数据。
命名
- 类名 = 被测函数的驼峰 +
Test:ClipPolygonTest、SampleTreeRowTest - 方法名是陈述句,说明这个行为是什么,不是
test_case_1:test_polygon_keeps_only_the_exterior_ring、test_trailing_point_is_skipped_when_it_would_double_plant
方法名读起来就是这个函数的规格说明。
测不到的部分怎么办
bpy 层(mesh.py、materials.py、tree.py、两个入口脚本)没有单元测试,
也不打算有——它们需要真实的 Blender 运行时。
这一层的回归防线是 parity 校验:结构摘要比对,而不是单元测试。 见产物一致性指南。
推论:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 bpy,
放进 geom.py 就立刻获得测试覆盖的资格。
反模式
| 反模式 | 后果 |
|---|---|
| 把当前输出粘成期望值 | 实现有 bug 时测试照样绿 |
| 新几何函数不配退化输入测试 | 真实数据一进来就崩 |
| 退化输入抛异常而不是返回空 | 一个坏多边形中断整片区域 |
带随机的函数不收 seed |
parity 校验永久红 |
| mock 掉 OSM 解析 | 测不到它唯一的价值 |
在测试里 import bpy 侧模块 |
整个套件无法运行 |
| 只测一种绕向 | 反向多边形进来才发现 |