host.camera_visualizer.tests.test_routes

Tests for camera visualizer route modules and field cache.

These test the Python route logic directly without starting an HTTP server. Run with: pytest host/camera_visualizer/tests/ -v

Generated by Claude.

  1"""
  2Tests for camera visualizer route modules and field cache.
  3
  4These test the Python route logic directly without starting an HTTP server.
  5Run with: pytest host/camera_visualizer/tests/ -v
  6
  7Generated by Claude.
  8"""
  9
 10import json
 11import os
 12import tempfile
 13import zipfile
 14
 15# Ensure project root is on path
 16import sys
 17sys.path.insert(0, os.path.abspath(
 18    os.path.join(os.path.dirname(__file__), '..', '..', '..')
 19))
 20
 21from host.camera_visualizer.routes import config, fields, cad, points
 22from host.camera_visualizer.field_cache import FieldCache
 23
 24
 25# ── Config routes ─────────────────────────────────────────────────────
 26
 27class TestConfigRoutes:
 28    def test_config_json_has_required_keys(self):
 29        cfg = json.loads(config.build_config_json())
 30        assert 'robot_frame' in cfg
 31        assert 'cameras' in cfg
 32        assert 'mechanisms' in cfg
 33
 34    def test_robot_frame_has_dimensions(self):
 35        cfg = json.loads(config.build_config_json())
 36        frame = cfg['robot_frame']
 37        assert 'width_inches' in frame
 38        assert 'length_inches' in frame
 39        assert 'width_meters' in frame
 40        assert 'length_meters' in frame
 41        assert frame['width_meters'] > 0
 42        assert frame['length_meters'] > 0
 43
 44    def test_cameras_have_required_fields(self):
 45        cfg = json.loads(config.build_config_json())
 46        for cam in cfg['cameras']:
 47            for key in ['name', 'x_inches', 'y_inches', 'z_inches',
 48                        'yaw_deg', 'pitch_deg', 'fov_deg']:
 49                assert key in cam, f"Missing '{key}' in camera {cam.get('name')}"
 50
 51    def test_mechanisms_have_required_fields(self):
 52        cfg = json.loads(config.build_config_json())
 53        for mech in cfg['mechanisms']:
 54            for key in ['name', 'type', 'x_meters', 'y_meters', 'z_meters']:
 55                assert key in mech, f"Missing '{key}' in mechanism {mech.get('name')}"
 56
 57    def test_build_page_injects_config(self):
 58        here = os.path.join(
 59            os.path.dirname(__file__), '..'
 60        )
 61        page = config.build_page(here)
 62        assert b'window.__ROBOT_CONFIG__' in page
 63        assert b'robot_frame' in page
 64        assert b'<html' in page
 65
 66
 67# ── Field tag routes ──────────────────────────────────────────────────
 68
 69class TestFieldTagRoutes:
 70    def test_field_tags_returns_tags(self):
 71        result = fields.get_field_tags()
 72        assert 'tags' in result
 73        assert len(result['tags']) > 0
 74
 75    def test_field_tags_have_positions(self):
 76        result = fields.get_field_tags()
 77        for tag in result['tags']:
 78            assert 'id' in tag
 79            assert 'x' in tag
 80            assert 'y' in tag
 81            assert 'z' in tag
 82            assert isinstance(tag['id'], int)
 83            assert tag['z'] > 0  # all tags are above ground
 84
 85    def test_field_tags_have_rotations(self):
 86        result = fields.get_field_tags()
 87        for tag in result['tags']:
 88            assert 'yaw_deg' in tag
 89
 90    def test_field_dimensions(self):
 91        result = fields.get_field_tags()
 92        assert 'field_length' in result
 93        assert 'field_width' in result
 94        # Standard FRC field is ~16.5m x ~8m
 95        assert 15 < result['field_length'] < 18
 96        assert 7 < result['field_width'] < 9
 97
 98    def test_tag_count_2026(self):
 99        """2026 field should have 32 tags."""
100        result = fields.get_field_tags()
101        assert len(result['tags']) == 32
102
103
104# ── CAD routes ────────────────────────────────────────────────────────
105
106class TestCadRoutes:
107    def test_list_cad_models_with_real_dir(self):
108        cad_dir = os.path.join(
109            os.path.dirname(__file__), '..', '..', 'cad_tools', 'models'
110        )
111        models = cad.list_cad_models(cad_dir)
112        # May or may not have models, but should be a list
113        assert isinstance(models, list)
114        for m in models:
115            assert 'name' in m
116            assert 'url' in m
117            assert 'size' in m
118
119    def test_list_cad_models_empty_dir(self):
120        with tempfile.TemporaryDirectory() as tmpdir:
121            models = cad.list_cad_models(tmpdir)
122            assert models == []
123
124    def test_list_cad_models_nonexistent_dir(self):
125        models = cad.list_cad_models('/nonexistent/path')
126        assert models == []
127
128    def test_serve_cad_file_rejects_bad_extension(self):
129        with tempfile.TemporaryDirectory() as tmpdir:
130            # Create a .py file that should be rejected
131            with open(os.path.join(tmpdir, 'evil.py'), 'w') as f:
132                f.write('import os')
133            data, mime = cad.serve_cad_file(tmpdir, 'evil.py')
134            assert data is None
135
136    def test_serve_cad_file_rejects_path_traversal(self):
137        with tempfile.TemporaryDirectory() as tmpdir:
138            data, mime = cad.serve_cad_file(tmpdir, '../../../etc/passwd')
139            assert data is None
140
141    def test_serve_cad_file_missing(self):
142        with tempfile.TemporaryDirectory() as tmpdir:
143            data, mime = cad.serve_cad_file(tmpdir, 'nonexistent.glb')
144            assert data is None
145
146    def test_serve_cad_file_glb(self):
147        with tempfile.TemporaryDirectory() as tmpdir:
148            test_data = b'\x00\x01\x02\x03'
149            with open(os.path.join(tmpdir, 'test.glb'), 'wb') as f:
150                f.write(test_data)
151            data, mime = cad.serve_cad_file(tmpdir, 'test.glb')
152            assert data == test_data
153            assert mime == 'model/gltf-binary'
154
155    def test_serve_cad_file_gltf(self):
156        with tempfile.TemporaryDirectory() as tmpdir:
157            test_data = b'{"asset":{}}'
158            with open(os.path.join(tmpdir, 'test.gltf'), 'wb') as f:
159                f.write(test_data)
160            data, mime = cad.serve_cad_file(tmpdir, 'test.gltf')
161            assert data == test_data
162            assert mime == 'model/gltf+json'
163
164
165# ── Points routes ─────────────────────────────────────────────────────
166
167class TestPointRoutes:
168    def test_load_points_empty(self):
169        result = points.load_points('/nonexistent/dir', 'default')
170        data = json.loads(result)
171        assert data['points'] == []
172
173    def test_save_and_load_roundtrip(self):
174        with tempfile.TemporaryDirectory() as tmpdir:
175            test_data = {"name": "test", "points": [
176                {"id": 1, "name": "test", "x": 1.5, "y": 2.5, "z": 0.5}
177            ]}
178            points.save_points(tmpdir, 'myset', json.dumps(test_data).encode())
179            loaded = json.loads(points.load_points(tmpdir, 'myset'))
180            assert loaded == test_data
181
182    def test_save_creates_directories(self):
183        with tempfile.TemporaryDirectory() as tmpdir:
184            pts_dir = os.path.join(tmpdir, 'sub', 'points')
185            points.save_points(pts_dir, 'test', b'{}')
186            assert os.path.isdir(pts_dir)
187
188    def test_list_point_sets_empty(self):
189        result = points.list_point_sets('/nonexistent/dir')
190        assert result == []
191
192    def test_list_point_sets(self):
193        with tempfile.TemporaryDirectory() as tmpdir:
194            points.save_points(tmpdir, 'set_a',
195                               json.dumps({"name": "Set A", "points": [{"id": 1}]}).encode())
196            points.save_points(tmpdir, 'set_b',
197                               json.dumps({"name": "Set B", "points": []}).encode())
198            sets = points.list_point_sets(tmpdir)
199            assert len(sets) == 2
200            names = [s['name'] for s in sets]
201            assert 'Set A' in names
202            assert 'Set B' in names
203            # Check count
204            a = next(s for s in sets if s['name'] == 'Set A')
205            assert a['count'] == 1
206
207
208# ── Field cache ───────────────────────────────────────────────────────
209
210class TestFieldCache:
211    def test_field_list_not_empty(self):
212        cache = FieldCache('/tmp/test')
213        fields = cache.get_field_list()
214        assert len(fields) >= 4
215
216    def test_field_list_has_robot_view(self):
217        cache = FieldCache('/tmp/test')
218        ids = [f['id'] for f in cache.get_field_list()]
219        assert 'robot-view' in ids
220
221    def test_field_list_has_2026(self):
222        cache = FieldCache('/tmp/test')
223        ids = [f['id'] for f in cache.get_field_list()]
224        assert '2026-field' in ids
225
226    def test_field_list_has_evergreen(self):
227        cache = FieldCache('/tmp/test')
228        ids = [f['id'] for f in cache.get_field_list()]
229        assert 'evergreen' in ids
230
231    def test_field_list_has_cat_box(self):
232        cache = FieldCache('/tmp/test')
233        ids = [f['id'] for f in cache.get_field_list()]
234        assert 'cat-box' in ids
235
236    def test_extract_evergreen_returns_none(self):
237        """Evergreen is builtin, no zip entry."""
238        cache = FieldCache('/tmp/test')
239        config, glb = cache.extract_field('evergreen')
240        assert config is None
241
242    def test_get_field_info_valid(self):
243        cache = FieldCache('/tmp/test')
244        info = cache.get_field_info('2026-field')
245        assert info is not None
246        assert info['name'] == '2026 Field'
247        assert 'zipEntry' in info
248
249    def test_get_field_info_invalid(self):
250        cache = FieldCache('/tmp/test')
251        assert cache.get_field_info('nonexistent') is None
252
253    def test_extract_builtin_returns_none(self):
254        """Builtin scenes (robot-view, cat-box) have no zip entry."""
255        cache = FieldCache('/tmp/test')
256        config, glb = cache.extract_field('robot-view')
257        assert config is None
258        assert glb is None
259
260    def test_extract_field_from_test_bundle(self):
261        """Create a minimal test bundle and verify extraction."""
262        with tempfile.TemporaryDirectory() as tmpdir:
263            cache_dir = os.path.join(tmpdir, 'fields')
264            bundle_path = os.path.join(tmpdir, 'bundle.zip')
265
266            # Build a fake inner zip with config.json and model.glb
267            inner_buf = _make_test_field_zip(
268                config={"name": "Test Field", "widthInches": 651},
269                glb_data=b'fake-glb-data',
270            )
271
272            # Build outer bundle containing the inner zip
273            with zipfile.ZipFile(bundle_path, 'w') as outer:
274                outer.writestr('Field3d_2026FRCFieldV1.zip', inner_buf)
275
276            cache = FieldCache(cache_dir, bundle_path)
277            cfg, glb = cache.extract_field('2026-field')
278
279            assert cfg is not None
280            assert cfg['name'] == 'Test Field'
281            assert glb == b'fake-glb-data'
282
283    def test_extract_field_caches_locally(self):
284        """Second extraction should read from local cache, not bundle."""
285        with tempfile.TemporaryDirectory() as tmpdir:
286            cache_dir = os.path.join(tmpdir, 'fields')
287            bundle_path = os.path.join(tmpdir, 'bundle.zip')
288
289            inner_buf = _make_test_field_zip(
290                config={"name": "Cached"},
291                glb_data=b'glb-bytes',
292            )
293            with zipfile.ZipFile(bundle_path, 'w') as outer:
294                outer.writestr('Field3d_2026FRCFieldV1.zip', inner_buf)
295
296            cache = FieldCache(cache_dir, bundle_path)
297            cache.extract_field('2026-field')
298
299            # Delete the bundle — cache should still work
300            os.remove(bundle_path)
301
302            cfg, glb = cache.extract_field('2026-field')
303            assert cfg['name'] == 'Cached'
304            assert glb == b'glb-bytes'
305
306
307def _make_test_field_zip(config: dict, glb_data: bytes) -> bytes:
308    """Create a minimal field zip (config.json + model.glb) in memory."""
309    import io
310    buf = io.BytesIO()
311    with zipfile.ZipFile(buf, 'w') as z:
312        z.writestr('config.json', json.dumps(config))
313        z.writestr('model.glb', glb_data)
314    return buf.getvalue()
class TestConfigRoutes:
28class TestConfigRoutes:
29    def test_config_json_has_required_keys(self):
30        cfg = json.loads(config.build_config_json())
31        assert 'robot_frame' in cfg
32        assert 'cameras' in cfg
33        assert 'mechanisms' in cfg
34
35    def test_robot_frame_has_dimensions(self):
36        cfg = json.loads(config.build_config_json())
37        frame = cfg['robot_frame']
38        assert 'width_inches' in frame
39        assert 'length_inches' in frame
40        assert 'width_meters' in frame
41        assert 'length_meters' in frame
42        assert frame['width_meters'] > 0
43        assert frame['length_meters'] > 0
44
45    def test_cameras_have_required_fields(self):
46        cfg = json.loads(config.build_config_json())
47        for cam in cfg['cameras']:
48            for key in ['name', 'x_inches', 'y_inches', 'z_inches',
49                        'yaw_deg', 'pitch_deg', 'fov_deg']:
50                assert key in cam, f"Missing '{key}' in camera {cam.get('name')}"
51
52    def test_mechanisms_have_required_fields(self):
53        cfg = json.loads(config.build_config_json())
54        for mech in cfg['mechanisms']:
55            for key in ['name', 'type', 'x_meters', 'y_meters', 'z_meters']:
56                assert key in mech, f"Missing '{key}' in mechanism {mech.get('name')}"
57
58    def test_build_page_injects_config(self):
59        here = os.path.join(
60            os.path.dirname(__file__), '..'
61        )
62        page = config.build_page(here)
63        assert b'window.__ROBOT_CONFIG__' in page
64        assert b'robot_frame' in page
65        assert b'<html' in page
def test_config_json_has_required_keys(self):
29    def test_config_json_has_required_keys(self):
30        cfg = json.loads(config.build_config_json())
31        assert 'robot_frame' in cfg
32        assert 'cameras' in cfg
33        assert 'mechanisms' in cfg
def test_robot_frame_has_dimensions(self):
35    def test_robot_frame_has_dimensions(self):
36        cfg = json.loads(config.build_config_json())
37        frame = cfg['robot_frame']
38        assert 'width_inches' in frame
39        assert 'length_inches' in frame
40        assert 'width_meters' in frame
41        assert 'length_meters' in frame
42        assert frame['width_meters'] > 0
43        assert frame['length_meters'] > 0
def test_cameras_have_required_fields(self):
45    def test_cameras_have_required_fields(self):
46        cfg = json.loads(config.build_config_json())
47        for cam in cfg['cameras']:
48            for key in ['name', 'x_inches', 'y_inches', 'z_inches',
49                        'yaw_deg', 'pitch_deg', 'fov_deg']:
50                assert key in cam, f"Missing '{key}' in camera {cam.get('name')}"
def test_mechanisms_have_required_fields(self):
52    def test_mechanisms_have_required_fields(self):
53        cfg = json.loads(config.build_config_json())
54        for mech in cfg['mechanisms']:
55            for key in ['name', 'type', 'x_meters', 'y_meters', 'z_meters']:
56                assert key in mech, f"Missing '{key}' in mechanism {mech.get('name')}"
def test_build_page_injects_config(self):
58    def test_build_page_injects_config(self):
59        here = os.path.join(
60            os.path.dirname(__file__), '..'
61        )
62        page = config.build_page(here)
63        assert b'window.__ROBOT_CONFIG__' in page
64        assert b'robot_frame' in page
65        assert b'<html' in page
class TestFieldTagRoutes:
 70class TestFieldTagRoutes:
 71    def test_field_tags_returns_tags(self):
 72        result = fields.get_field_tags()
 73        assert 'tags' in result
 74        assert len(result['tags']) > 0
 75
 76    def test_field_tags_have_positions(self):
 77        result = fields.get_field_tags()
 78        for tag in result['tags']:
 79            assert 'id' in tag
 80            assert 'x' in tag
 81            assert 'y' in tag
 82            assert 'z' in tag
 83            assert isinstance(tag['id'], int)
 84            assert tag['z'] > 0  # all tags are above ground
 85
 86    def test_field_tags_have_rotations(self):
 87        result = fields.get_field_tags()
 88        for tag in result['tags']:
 89            assert 'yaw_deg' in tag
 90
 91    def test_field_dimensions(self):
 92        result = fields.get_field_tags()
 93        assert 'field_length' in result
 94        assert 'field_width' in result
 95        # Standard FRC field is ~16.5m x ~8m
 96        assert 15 < result['field_length'] < 18
 97        assert 7 < result['field_width'] < 9
 98
 99    def test_tag_count_2026(self):
100        """2026 field should have 32 tags."""
101        result = fields.get_field_tags()
102        assert len(result['tags']) == 32
def test_field_tags_returns_tags(self):
71    def test_field_tags_returns_tags(self):
72        result = fields.get_field_tags()
73        assert 'tags' in result
74        assert len(result['tags']) > 0
def test_field_tags_have_positions(self):
76    def test_field_tags_have_positions(self):
77        result = fields.get_field_tags()
78        for tag in result['tags']:
79            assert 'id' in tag
80            assert 'x' in tag
81            assert 'y' in tag
82            assert 'z' in tag
83            assert isinstance(tag['id'], int)
84            assert tag['z'] > 0  # all tags are above ground
def test_field_tags_have_rotations(self):
86    def test_field_tags_have_rotations(self):
87        result = fields.get_field_tags()
88        for tag in result['tags']:
89            assert 'yaw_deg' in tag
def test_field_dimensions(self):
91    def test_field_dimensions(self):
92        result = fields.get_field_tags()
93        assert 'field_length' in result
94        assert 'field_width' in result
95        # Standard FRC field is ~16.5m x ~8m
96        assert 15 < result['field_length'] < 18
97        assert 7 < result['field_width'] < 9
def test_tag_count_2026(self):
 99    def test_tag_count_2026(self):
100        """2026 field should have 32 tags."""
101        result = fields.get_field_tags()
102        assert len(result['tags']) == 32

2026 field should have 32 tags.

class TestCadRoutes:
107class TestCadRoutes:
108    def test_list_cad_models_with_real_dir(self):
109        cad_dir = os.path.join(
110            os.path.dirname(__file__), '..', '..', 'cad_tools', 'models'
111        )
112        models = cad.list_cad_models(cad_dir)
113        # May or may not have models, but should be a list
114        assert isinstance(models, list)
115        for m in models:
116            assert 'name' in m
117            assert 'url' in m
118            assert 'size' in m
119
120    def test_list_cad_models_empty_dir(self):
121        with tempfile.TemporaryDirectory() as tmpdir:
122            models = cad.list_cad_models(tmpdir)
123            assert models == []
124
125    def test_list_cad_models_nonexistent_dir(self):
126        models = cad.list_cad_models('/nonexistent/path')
127        assert models == []
128
129    def test_serve_cad_file_rejects_bad_extension(self):
130        with tempfile.TemporaryDirectory() as tmpdir:
131            # Create a .py file that should be rejected
132            with open(os.path.join(tmpdir, 'evil.py'), 'w') as f:
133                f.write('import os')
134            data, mime = cad.serve_cad_file(tmpdir, 'evil.py')
135            assert data is None
136
137    def test_serve_cad_file_rejects_path_traversal(self):
138        with tempfile.TemporaryDirectory() as tmpdir:
139            data, mime = cad.serve_cad_file(tmpdir, '../../../etc/passwd')
140            assert data is None
141
142    def test_serve_cad_file_missing(self):
143        with tempfile.TemporaryDirectory() as tmpdir:
144            data, mime = cad.serve_cad_file(tmpdir, 'nonexistent.glb')
145            assert data is None
146
147    def test_serve_cad_file_glb(self):
148        with tempfile.TemporaryDirectory() as tmpdir:
149            test_data = b'\x00\x01\x02\x03'
150            with open(os.path.join(tmpdir, 'test.glb'), 'wb') as f:
151                f.write(test_data)
152            data, mime = cad.serve_cad_file(tmpdir, 'test.glb')
153            assert data == test_data
154            assert mime == 'model/gltf-binary'
155
156    def test_serve_cad_file_gltf(self):
157        with tempfile.TemporaryDirectory() as tmpdir:
158            test_data = b'{"asset":{}}'
159            with open(os.path.join(tmpdir, 'test.gltf'), 'wb') as f:
160                f.write(test_data)
161            data, mime = cad.serve_cad_file(tmpdir, 'test.gltf')
162            assert data == test_data
163            assert mime == 'model/gltf+json'
def test_list_cad_models_with_real_dir(self):
108    def test_list_cad_models_with_real_dir(self):
109        cad_dir = os.path.join(
110            os.path.dirname(__file__), '..', '..', 'cad_tools', 'models'
111        )
112        models = cad.list_cad_models(cad_dir)
113        # May or may not have models, but should be a list
114        assert isinstance(models, list)
115        for m in models:
116            assert 'name' in m
117            assert 'url' in m
118            assert 'size' in m
def test_list_cad_models_empty_dir(self):
120    def test_list_cad_models_empty_dir(self):
121        with tempfile.TemporaryDirectory() as tmpdir:
122            models = cad.list_cad_models(tmpdir)
123            assert models == []
def test_list_cad_models_nonexistent_dir(self):
125    def test_list_cad_models_nonexistent_dir(self):
126        models = cad.list_cad_models('/nonexistent/path')
127        assert models == []
def test_serve_cad_file_rejects_bad_extension(self):
129    def test_serve_cad_file_rejects_bad_extension(self):
130        with tempfile.TemporaryDirectory() as tmpdir:
131            # Create a .py file that should be rejected
132            with open(os.path.join(tmpdir, 'evil.py'), 'w') as f:
133                f.write('import os')
134            data, mime = cad.serve_cad_file(tmpdir, 'evil.py')
135            assert data is None
def test_serve_cad_file_rejects_path_traversal(self):
137    def test_serve_cad_file_rejects_path_traversal(self):
138        with tempfile.TemporaryDirectory() as tmpdir:
139            data, mime = cad.serve_cad_file(tmpdir, '../../../etc/passwd')
140            assert data is None
def test_serve_cad_file_missing(self):
142    def test_serve_cad_file_missing(self):
143        with tempfile.TemporaryDirectory() as tmpdir:
144            data, mime = cad.serve_cad_file(tmpdir, 'nonexistent.glb')
145            assert data is None
def test_serve_cad_file_glb(self):
147    def test_serve_cad_file_glb(self):
148        with tempfile.TemporaryDirectory() as tmpdir:
149            test_data = b'\x00\x01\x02\x03'
150            with open(os.path.join(tmpdir, 'test.glb'), 'wb') as f:
151                f.write(test_data)
152            data, mime = cad.serve_cad_file(tmpdir, 'test.glb')
153            assert data == test_data
154            assert mime == 'model/gltf-binary'
def test_serve_cad_file_gltf(self):
156    def test_serve_cad_file_gltf(self):
157        with tempfile.TemporaryDirectory() as tmpdir:
158            test_data = b'{"asset":{}}'
159            with open(os.path.join(tmpdir, 'test.gltf'), 'wb') as f:
160                f.write(test_data)
161            data, mime = cad.serve_cad_file(tmpdir, 'test.gltf')
162            assert data == test_data
163            assert mime == 'model/gltf+json'
class TestPointRoutes:
168class TestPointRoutes:
169    def test_load_points_empty(self):
170        result = points.load_points('/nonexistent/dir', 'default')
171        data = json.loads(result)
172        assert data['points'] == []
173
174    def test_save_and_load_roundtrip(self):
175        with tempfile.TemporaryDirectory() as tmpdir:
176            test_data = {"name": "test", "points": [
177                {"id": 1, "name": "test", "x": 1.5, "y": 2.5, "z": 0.5}
178            ]}
179            points.save_points(tmpdir, 'myset', json.dumps(test_data).encode())
180            loaded = json.loads(points.load_points(tmpdir, 'myset'))
181            assert loaded == test_data
182
183    def test_save_creates_directories(self):
184        with tempfile.TemporaryDirectory() as tmpdir:
185            pts_dir = os.path.join(tmpdir, 'sub', 'points')
186            points.save_points(pts_dir, 'test', b'{}')
187            assert os.path.isdir(pts_dir)
188
189    def test_list_point_sets_empty(self):
190        result = points.list_point_sets('/nonexistent/dir')
191        assert result == []
192
193    def test_list_point_sets(self):
194        with tempfile.TemporaryDirectory() as tmpdir:
195            points.save_points(tmpdir, 'set_a',
196                               json.dumps({"name": "Set A", "points": [{"id": 1}]}).encode())
197            points.save_points(tmpdir, 'set_b',
198                               json.dumps({"name": "Set B", "points": []}).encode())
199            sets = points.list_point_sets(tmpdir)
200            assert len(sets) == 2
201            names = [s['name'] for s in sets]
202            assert 'Set A' in names
203            assert 'Set B' in names
204            # Check count
205            a = next(s for s in sets if s['name'] == 'Set A')
206            assert a['count'] == 1
def test_load_points_empty(self):
169    def test_load_points_empty(self):
170        result = points.load_points('/nonexistent/dir', 'default')
171        data = json.loads(result)
172        assert data['points'] == []
def test_save_and_load_roundtrip(self):
174    def test_save_and_load_roundtrip(self):
175        with tempfile.TemporaryDirectory() as tmpdir:
176            test_data = {"name": "test", "points": [
177                {"id": 1, "name": "test", "x": 1.5, "y": 2.5, "z": 0.5}
178            ]}
179            points.save_points(tmpdir, 'myset', json.dumps(test_data).encode())
180            loaded = json.loads(points.load_points(tmpdir, 'myset'))
181            assert loaded == test_data
def test_save_creates_directories(self):
183    def test_save_creates_directories(self):
184        with tempfile.TemporaryDirectory() as tmpdir:
185            pts_dir = os.path.join(tmpdir, 'sub', 'points')
186            points.save_points(pts_dir, 'test', b'{}')
187            assert os.path.isdir(pts_dir)
def test_list_point_sets_empty(self):
189    def test_list_point_sets_empty(self):
190        result = points.list_point_sets('/nonexistent/dir')
191        assert result == []
def test_list_point_sets(self):
193    def test_list_point_sets(self):
194        with tempfile.TemporaryDirectory() as tmpdir:
195            points.save_points(tmpdir, 'set_a',
196                               json.dumps({"name": "Set A", "points": [{"id": 1}]}).encode())
197            points.save_points(tmpdir, 'set_b',
198                               json.dumps({"name": "Set B", "points": []}).encode())
199            sets = points.list_point_sets(tmpdir)
200            assert len(sets) == 2
201            names = [s['name'] for s in sets]
202            assert 'Set A' in names
203            assert 'Set B' in names
204            # Check count
205            a = next(s for s in sets if s['name'] == 'Set A')
206            assert a['count'] == 1
class TestFieldCache:
211class TestFieldCache:
212    def test_field_list_not_empty(self):
213        cache = FieldCache('/tmp/test')
214        fields = cache.get_field_list()
215        assert len(fields) >= 4
216
217    def test_field_list_has_robot_view(self):
218        cache = FieldCache('/tmp/test')
219        ids = [f['id'] for f in cache.get_field_list()]
220        assert 'robot-view' in ids
221
222    def test_field_list_has_2026(self):
223        cache = FieldCache('/tmp/test')
224        ids = [f['id'] for f in cache.get_field_list()]
225        assert '2026-field' in ids
226
227    def test_field_list_has_evergreen(self):
228        cache = FieldCache('/tmp/test')
229        ids = [f['id'] for f in cache.get_field_list()]
230        assert 'evergreen' in ids
231
232    def test_field_list_has_cat_box(self):
233        cache = FieldCache('/tmp/test')
234        ids = [f['id'] for f in cache.get_field_list()]
235        assert 'cat-box' in ids
236
237    def test_extract_evergreen_returns_none(self):
238        """Evergreen is builtin, no zip entry."""
239        cache = FieldCache('/tmp/test')
240        config, glb = cache.extract_field('evergreen')
241        assert config is None
242
243    def test_get_field_info_valid(self):
244        cache = FieldCache('/tmp/test')
245        info = cache.get_field_info('2026-field')
246        assert info is not None
247        assert info['name'] == '2026 Field'
248        assert 'zipEntry' in info
249
250    def test_get_field_info_invalid(self):
251        cache = FieldCache('/tmp/test')
252        assert cache.get_field_info('nonexistent') is None
253
254    def test_extract_builtin_returns_none(self):
255        """Builtin scenes (robot-view, cat-box) have no zip entry."""
256        cache = FieldCache('/tmp/test')
257        config, glb = cache.extract_field('robot-view')
258        assert config is None
259        assert glb is None
260
261    def test_extract_field_from_test_bundle(self):
262        """Create a minimal test bundle and verify extraction."""
263        with tempfile.TemporaryDirectory() as tmpdir:
264            cache_dir = os.path.join(tmpdir, 'fields')
265            bundle_path = os.path.join(tmpdir, 'bundle.zip')
266
267            # Build a fake inner zip with config.json and model.glb
268            inner_buf = _make_test_field_zip(
269                config={"name": "Test Field", "widthInches": 651},
270                glb_data=b'fake-glb-data',
271            )
272
273            # Build outer bundle containing the inner zip
274            with zipfile.ZipFile(bundle_path, 'w') as outer:
275                outer.writestr('Field3d_2026FRCFieldV1.zip', inner_buf)
276
277            cache = FieldCache(cache_dir, bundle_path)
278            cfg, glb = cache.extract_field('2026-field')
279
280            assert cfg is not None
281            assert cfg['name'] == 'Test Field'
282            assert glb == b'fake-glb-data'
283
284    def test_extract_field_caches_locally(self):
285        """Second extraction should read from local cache, not bundle."""
286        with tempfile.TemporaryDirectory() as tmpdir:
287            cache_dir = os.path.join(tmpdir, 'fields')
288            bundle_path = os.path.join(tmpdir, 'bundle.zip')
289
290            inner_buf = _make_test_field_zip(
291                config={"name": "Cached"},
292                glb_data=b'glb-bytes',
293            )
294            with zipfile.ZipFile(bundle_path, 'w') as outer:
295                outer.writestr('Field3d_2026FRCFieldV1.zip', inner_buf)
296
297            cache = FieldCache(cache_dir, bundle_path)
298            cache.extract_field('2026-field')
299
300            # Delete the bundle — cache should still work
301            os.remove(bundle_path)
302
303            cfg, glb = cache.extract_field('2026-field')
304            assert cfg['name'] == 'Cached'
305            assert glb == b'glb-bytes'
def test_field_list_not_empty(self):
212    def test_field_list_not_empty(self):
213        cache = FieldCache('/tmp/test')
214        fields = cache.get_field_list()
215        assert len(fields) >= 4
def test_field_list_has_robot_view(self):
217    def test_field_list_has_robot_view(self):
218        cache = FieldCache('/tmp/test')
219        ids = [f['id'] for f in cache.get_field_list()]
220        assert 'robot-view' in ids
def test_field_list_has_2026(self):
222    def test_field_list_has_2026(self):
223        cache = FieldCache('/tmp/test')
224        ids = [f['id'] for f in cache.get_field_list()]
225        assert '2026-field' in ids
def test_field_list_has_evergreen(self):
227    def test_field_list_has_evergreen(self):
228        cache = FieldCache('/tmp/test')
229        ids = [f['id'] for f in cache.get_field_list()]
230        assert 'evergreen' in ids
def test_field_list_has_cat_box(self):
232    def test_field_list_has_cat_box(self):
233        cache = FieldCache('/tmp/test')
234        ids = [f['id'] for f in cache.get_field_list()]
235        assert 'cat-box' in ids
def test_extract_evergreen_returns_none(self):
237    def test_extract_evergreen_returns_none(self):
238        """Evergreen is builtin, no zip entry."""
239        cache = FieldCache('/tmp/test')
240        config, glb = cache.extract_field('evergreen')
241        assert config is None

Evergreen is builtin, no zip entry.

def test_get_field_info_valid(self):
243    def test_get_field_info_valid(self):
244        cache = FieldCache('/tmp/test')
245        info = cache.get_field_info('2026-field')
246        assert info is not None
247        assert info['name'] == '2026 Field'
248        assert 'zipEntry' in info
def test_get_field_info_invalid(self):
250    def test_get_field_info_invalid(self):
251        cache = FieldCache('/tmp/test')
252        assert cache.get_field_info('nonexistent') is None
def test_extract_builtin_returns_none(self):
254    def test_extract_builtin_returns_none(self):
255        """Builtin scenes (robot-view, cat-box) have no zip entry."""
256        cache = FieldCache('/tmp/test')
257        config, glb = cache.extract_field('robot-view')
258        assert config is None
259        assert glb is None

Builtin scenes (robot-view, cat-box) have no zip entry.

def test_extract_field_from_test_bundle(self):
261    def test_extract_field_from_test_bundle(self):
262        """Create a minimal test bundle and verify extraction."""
263        with tempfile.TemporaryDirectory() as tmpdir:
264            cache_dir = os.path.join(tmpdir, 'fields')
265            bundle_path = os.path.join(tmpdir, 'bundle.zip')
266
267            # Build a fake inner zip with config.json and model.glb
268            inner_buf = _make_test_field_zip(
269                config={"name": "Test Field", "widthInches": 651},
270                glb_data=b'fake-glb-data',
271            )
272
273            # Build outer bundle containing the inner zip
274            with zipfile.ZipFile(bundle_path, 'w') as outer:
275                outer.writestr('Field3d_2026FRCFieldV1.zip', inner_buf)
276
277            cache = FieldCache(cache_dir, bundle_path)
278            cfg, glb = cache.extract_field('2026-field')
279
280            assert cfg is not None
281            assert cfg['name'] == 'Test Field'
282            assert glb == b'fake-glb-data'

Create a minimal test bundle and verify extraction.

def test_extract_field_caches_locally(self):
284    def test_extract_field_caches_locally(self):
285        """Second extraction should read from local cache, not bundle."""
286        with tempfile.TemporaryDirectory() as tmpdir:
287            cache_dir = os.path.join(tmpdir, 'fields')
288            bundle_path = os.path.join(tmpdir, 'bundle.zip')
289
290            inner_buf = _make_test_field_zip(
291                config={"name": "Cached"},
292                glb_data=b'glb-bytes',
293            )
294            with zipfile.ZipFile(bundle_path, 'w') as outer:
295                outer.writestr('Field3d_2026FRCFieldV1.zip', inner_buf)
296
297            cache = FieldCache(cache_dir, bundle_path)
298            cache.extract_field('2026-field')
299
300            # Delete the bundle — cache should still work
301            os.remove(bundle_path)
302
303            cfg, glb = cache.extract_field('2026-field')
304            assert cfg['name'] == 'Cached'
305            assert glb == b'glb-bytes'

Second extraction should read from local cache, not bundle.