# 测试 > 适用:改动 `osmassets/osm.py`、`osmassets/geom.py`,或往里加新的纯函数。 --- ## 怎么跑 ```bash python3 -m unittest discover blender/tests ``` **不需要 Blender**,用系统 Python 就行。当前 51 个用例,运行约 0.01 秒。 这是全仓唯一的自动化测试。快到没有理由不在每次改动后跑一遍。 能这么跑的前提是 `osmassets` 的[依赖分层](./module-structure.md)—— `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.** 具体做法是在测试里写清楚**为什么**是这个数: ```python 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) ``` 反例——**不要这样写**: ```python 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`。** 约定是"返回空/零"而不是抛异常—— 一个坏多边形不该中断整片区域的构建。 ### 除零守卫要指名道姓 覆盖某个具体守卫时,注释写清楚针对哪一行: ```python 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 可复现 ```python 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 校验](../guides/artifact-parity-guide.md)永久性地红。 任何带随机的新函数都要收 `seed` 参数并配这个测试(`test_pure.py:135-138`)。 ### 绕向无关的性质要两个方向都测 `sample_ring_boundary` 的 `inset` 对顺时针和逆时针都必须往内缩: ```python 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`)。 ### 跨边界的连续性要单独测 真实几何常见的坑是"分段处理时每段各自重新开始": ```python 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 校验**:结构摘要比对,而不是单元测试。 见[产物一致性指南](../guides/artifact-parity-guide.md)。 **推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`, 放进 `geom.py` 就立刻获得测试覆盖的资格。 ### 以 MeshBatch 为边界的静态设施测试 少数 bpy 要素模块的价值在于确定性地向 `MeshBatch` 追加顶点与面,而不是调用 bpy API 本身。对这类模块(例如 `osmassets/traffic_signals.py`),应在 `blender/tests/` 用假的 `osmassets.mesh.MeshBatch` 导入模块,断言有效输入的装配数量和关键几何方向。这样可覆盖 “校验函数意外返回空、所有要素被静默跳过”这一类错误,不必依赖可用的 Blender 进程。 测试必须在本文件列出的 `python3 -m unittest discover blender/tests` 命令下独立运行;测试 文件自己添加 `blender/` 到 `sys.path`,不能依赖其他测试的导入顺序。 --- ## 反模式 | 反模式 | 后果 | |---|---| | 把当前输出粘成期望值 | 实现有 bug 时测试照样绿 | | 新几何函数不配退化输入测试 | 真实数据一进来就崩 | | 退化输入抛异常而不是返回空 | 一个坏多边形中断整片区域 | | 带随机的函数不收 `seed` | parity 校验永久红 | | mock 掉 OSM 解析 | 测不到它唯一的价值 | | 在测试里 import `bpy` 侧模块 | 整个套件无法运行 | | 只测一种绕向 | 反向多边形进来才发现 |