bobframes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. bobframes/__init__.py +3 -0
  2. bobframes/_version.py +1 -0
  3. bobframes/catalog.py +154 -0
  4. bobframes/cli.py +266 -0
  5. bobframes/derive_post_merge.py +365 -0
  6. bobframes/derives/__init__.py +0 -0
  7. bobframes/derives/pass_class_breakdown.py +102 -0
  8. bobframes/derives/texture_usage.py +121 -0
  9. bobframes/discovery.py +132 -0
  10. bobframes/global_entities.py +99 -0
  11. bobframes/html/__init__.py +0 -0
  12. bobframes/html/template.py +1056 -0
  13. bobframes/lint.py +114 -0
  14. bobframes/manifest.py +127 -0
  15. bobframes/parquetize.py +282 -0
  16. bobframes/parsers/__init__.py +0 -0
  17. bobframes/parsers/derive_program_transitions.py +73 -0
  18. bobframes/parsers/parse_init_state.py +675 -0
  19. bobframes/paths.py +111 -0
  20. bobframes/probes/__init__.py +0 -0
  21. bobframes/probes/whatif.py +165 -0
  22. bobframes/qrd_harness.py +119 -0
  23. bobframes/query_examples.py +222 -0
  24. bobframes/rdcmd.py +72 -0
  25. bobframes/replay/__init__.py +26 -0
  26. bobframes/replay/replay_main.py +2305 -0
  27. bobframes/reports/__init__.py +0 -0
  28. bobframes/reports/_dashboard.py +425 -0
  29. bobframes/reports/ab.py +88 -0
  30. bobframes/reports/base.py +114 -0
  31. bobframes/reports/cache.py +147 -0
  32. bobframes/reports/chrome.py +1306 -0
  33. bobframes/reports/cli.py +99 -0
  34. bobframes/reports/delta.py +167 -0
  35. bobframes/reports/discovery.py +118 -0
  36. bobframes/reports/draws_by_class.py +165 -0
  37. bobframes/reports/formatters.py +122 -0
  38. bobframes/reports/instancing_opportunities.py +276 -0
  39. bobframes/reports/orchestrator.py +59 -0
  40. bobframes/reports/overdraw.py +293 -0
  41. bobframes/reports/pass_gpu.py +190 -0
  42. bobframes/reports/shader_hotlist.py +240 -0
  43. bobframes/reports/trend_table.py +444 -0
  44. bobframes/resource_labels.py +162 -0
  45. bobframes/run.py +480 -0
  46. bobframes/schemas.py +426 -0
  47. bobframes/stable_keys.py +83 -0
  48. bobframes/tests/__init__.py +0 -0
  49. bobframes/tests/_render_util.py +84 -0
  50. bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
  51. bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
  52. bobframes/tests/data/golden/_reports/index.html +264 -0
  53. bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
  54. bobframes/tests/data/golden/_reports/overdraw.html +275 -0
  55. bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
  56. bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
  57. bobframes/tests/data/golden/_reports/trend_table.html +390 -0
  58. bobframes/tests/data/golden/index.html +1175 -0
  59. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
  60. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
  61. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
  62. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
  63. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
  64. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
  65. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
  66. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
  67. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
  68. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
  69. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
  70. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
  71. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
  72. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
  73. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
  74. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
  75. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
  76. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
  77. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
  78. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
  79. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
  80. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
  81. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
  82. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
  83. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
  84. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
  85. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
  86. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
  87. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
  88. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
  89. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
  90. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
  91. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
  92. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
  93. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
  94. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
  95. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
  96. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
  97. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
  98. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
  99. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
  100. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
  101. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
  102. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
  103. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
  104. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
  105. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
  106. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
  107. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
  108. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
  109. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
  110. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
  111. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
  112. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
  113. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
  114. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
  115. bobframes/tests/make_synthetic.py +171 -0
  116. bobframes/tests/smoke.py +199 -0
  117. bobframes/tests/test_determinism.py +19 -0
  118. bobframes/tests/test_discovery.py +97 -0
  119. bobframes/tests/test_hardening.py +142 -0
  120. bobframes/tests/test_parity.py +22 -0
  121. bobframes/tests/test_perf.py +18 -0
  122. bobframes/tests/test_replay_drift.py +115 -0
  123. bobframes/tests/test_schemas.py +26 -0
  124. bobframes/tests/test_schemas_unit.py +55 -0
  125. bobframes/tests/test_stable_keys.py +61 -0
  126. bobframes-0.1.0.dist-info/METADATA +144 -0
  127. bobframes-0.1.0.dist-info/RECORD +130 -0
  128. bobframes-0.1.0.dist-info/WHEEL +4 -0
  129. bobframes-0.1.0.dist-info/entry_points.txt +2 -0
  130. bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,97 @@
1
+ """Unit tests for discovery (c15, doc's `unit_discovery.py`).
2
+
3
+ Covers the area/dated-drop walking that drives ingest + smoke: latest-drop selection, the
4
+ area/label/capture filters, the capture sort order, skip rules, and parse_single_drop_arg
5
+ (the correct name — not the doc's earlier `_parse_drop_dirname`).
6
+
7
+ Named `test_*` for default pytest discovery (no `python_files` override).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import pytest
14
+
15
+ from .. import discovery
16
+ from ..discovery import Drop
17
+
18
+
19
+ def _touch(path: str) -> None:
20
+ os.makedirs(os.path.dirname(path), exist_ok=True)
21
+ open(path, 'w', encoding='utf-8').close()
22
+
23
+
24
+ def _make_root(tmp_path) -> str:
25
+ root = str(tmp_path / 'proj')
26
+ # Town: two dated drops; the newer one has three captures with non-lexical numeric names.
27
+ _touch(os.path.join(root, 'Town', '2026-05-27_old', '1.rdc'))
28
+ for cap in ('1', '2', '10'):
29
+ _touch(os.path.join(root, 'Town', '2026-05-28_new', f'{cap}.rdc'))
30
+ # Bay: a single undated-label drop (label absent).
31
+ _touch(os.path.join(root, 'Bay', '2026-01-01', 'cap.rdc'))
32
+ # Noise that must be ignored: underscore (output) dir is not an area.
33
+ _touch(os.path.join(root, '_data', 'District 01', '2026-05-28_new', 'x.parquet'))
34
+ return root
35
+
36
+
37
+ def test_find_drops_picks_latest_per_area(tmp_path):
38
+ drops = {d.area: d for d in discovery.find_drops(_make_root(tmp_path))}
39
+ assert set(drops) == {'Town', 'Bay'} # '_data' skipped
40
+ assert drops['Town'].drop_date == '2026-05-28' # newest dated drop
41
+ assert drops['Town'].drop_label == 'new'
42
+ assert drops['Bay'].drop_label == '' # label optional
43
+
44
+
45
+ def test_latest_empty_drop_skips_area_no_fallback(tmp_path):
46
+ # The newest dated dir has no .rdc; find_drops skips the whole area rather than falling
47
+ # back to the older drop that does have captures.
48
+ root = str(tmp_path / 'proj')
49
+ _touch(os.path.join(root, 'Town', '2026-05-27_old', '1.rdc'))
50
+ os.makedirs(os.path.join(root, 'Town', '2026-05-28_new'), exist_ok=True)
51
+ assert discovery.find_drops(root) == []
52
+
53
+
54
+ def test_capture_sort_is_length_then_lexical(tmp_path):
55
+ town = next(d for d in discovery.find_drops(_make_root(tmp_path)) if d.area == 'Town')
56
+ assert town.captures == ('1', '2', '10') # NOT lexical ('1','10','2')
57
+
58
+
59
+ def test_filters(tmp_path):
60
+ root = _make_root(tmp_path)
61
+ assert [d.area for d in discovery.find_drops(root, area_filter='Bay')] == ['Bay']
62
+ assert discovery.find_drops(root, label_filter='nope') == []
63
+ capped = discovery.find_drops(root, area_filter='Town', capture_filter='2')
64
+ assert capped[0].captures == ('2',)
65
+ # A capture filter that doesn't match the area's drop drops it entirely.
66
+ assert discovery.find_drops(root, area_filter='Town', capture_filter='999') == []
67
+
68
+
69
+ def test_drop_without_rdc_is_skipped(tmp_path):
70
+ root = str(tmp_path / 'proj')
71
+ os.makedirs(os.path.join(root, 'Area', '2026-05-28_x'), exist_ok=True)
72
+ assert discovery.find_drops(root) == []
73
+
74
+
75
+ def test_find_drops_missing_root_raises(tmp_path):
76
+ with pytest.raises(FileNotFoundError):
77
+ discovery.find_drops(str(tmp_path / 'nope'))
78
+
79
+
80
+ def test_parse_single_drop_arg_ok(tmp_path):
81
+ root = _make_root(tmp_path)
82
+ d = discovery.parse_single_drop_arg('Town/2026-05-28_new', root)
83
+ assert isinstance(d, Drop)
84
+ assert (d.area, d.drop_date, d.drop_label) == ('Town', '2026-05-28', 'new')
85
+ assert d.captures == ('1', '2', '10')
86
+ # Trailing separators + backslashes normalize identically.
87
+ assert discovery.parse_single_drop_arg('Town\\2026-05-28_new\\', root).drop_dir == d.drop_dir
88
+
89
+
90
+ def test_parse_single_drop_arg_errors(tmp_path):
91
+ root = _make_root(tmp_path)
92
+ with pytest.raises(ValueError):
93
+ discovery.parse_single_drop_arg('justone', root) # no <area>/<drop>
94
+ with pytest.raises(ValueError):
95
+ discovery.parse_single_drop_arg('Town/not-a-date', root) # not a dated folder
96
+ with pytest.raises(FileNotFoundError):
97
+ discovery.parse_single_drop_arg('Town/2026-05-30_ghost', root) # dir absent
@@ -0,0 +1,142 @@
1
+ """c03 reliability hardening — mocked-subprocess unit tests (DECISIONS ADR-6).
2
+
3
+ CI has no GPU/RenderDoc, so the ingest-path hardening branches (process-tree kill on timeout,
4
+ replay-failure skip, atomic writes, stderr surfacing, key versioning) get no coverage from the
5
+ golden-parity gate. These drive each branch with fakes instead of real subprocesses.
6
+
7
+ Named `test_hardening.py` (not the c03 doc's `unit_hardening.py`) so the default-config
8
+ `pytest bobframes/tests` collects it — the repo defines no pytest `python_files` override.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import os
14
+ import subprocess
15
+
16
+ import pyarrow as pa
17
+ import pytest
18
+
19
+ from .. import manifest, parquetize, qrd_harness, rdcmd, run, stable_keys
20
+ from ..discovery import Drop
21
+
22
+
23
+ # --- R-4: replay timeout reaps the process tree ------------------------------
24
+
25
+ def test_qrd_timeout_kills_process_tree(monkeypatch):
26
+ class _FakeTimeoutPopen:
27
+ def __init__(self, *a, **k):
28
+ self.pid = 4242
29
+ self.returncode = None
30
+
31
+ def communicate(self, timeout=None):
32
+ raise subprocess.TimeoutExpired(cmd='qrenderdoc', timeout=timeout)
33
+
34
+ def wait(self, timeout=None):
35
+ self.returncode = -1
36
+ return -1
37
+
38
+ killed: list[list[str]] = []
39
+
40
+ def _rec_run(cmd, *a, **k):
41
+ killed.append(cmd)
42
+ return type('R', (), {'returncode': 0})()
43
+
44
+ monkeypatch.setattr(qrd_harness, 'find_qrenderdoc', lambda: 'qrenderdoc.exe')
45
+ monkeypatch.setattr(qrd_harness.subprocess, 'Popen', _FakeTimeoutPopen)
46
+ monkeypatch.setattr(qrd_harness.subprocess, 'run', _rec_run)
47
+
48
+ rc, elapsed = qrd_harness.run('replay_main.py', payload_args=['a', 'b'],
49
+ log_path=None, timeout_s=1)
50
+
51
+ assert rc == -1
52
+ assert ['taskkill', '/T', '/F', '/PID', '4242'] in killed
53
+
54
+
55
+ # --- R-6: one capture's replay failure is skipped, not fatal ------------------
56
+
57
+ def test_replay_failure_skips_and_records_status(monkeypatch, tmp_path):
58
+ def _fake_run(script, payload_args, log_path=None, timeout_s=600.0):
59
+ capture = payload_args[1]
60
+ return (1, 0.1) if capture == '2' else (0, 0.1)
61
+
62
+ monkeypatch.setattr(run.qrd_harness, 'run', _fake_run)
63
+
64
+ drop = Drop(area='A', drop_date='2026-01-01', drop_label='x',
65
+ drop_dir=str(tmp_path / 'drop'), captures=('1', '2'))
66
+ stage_root = str(tmp_path / 'stage')
67
+
68
+ statuses = run._do_replay(drop, stage_root, pixel_grid=4)
69
+
70
+ assert statuses == {'1': 'ok', '2': 'replay_failed'}
71
+
72
+
73
+ # --- R-1: manifest write is atomic (no partial file on mid-write crash) -------
74
+
75
+ def test_write_manifest_atomic_no_partial(monkeypatch, tmp_path):
76
+ def _boom(*a, **k):
77
+ raise RuntimeError('disk full mid-write')
78
+
79
+ monkeypatch.setattr(manifest.json, 'dump', _boom)
80
+
81
+ with pytest.raises(RuntimeError):
82
+ manifest.write_manifest(str(tmp_path), {'schema_version': 3})
83
+
84
+ assert not os.path.exists(tmp_path / '_manifest.json')
85
+ assert not os.path.exists(tmp_path / '_manifest.json.tmp')
86
+
87
+
88
+ # --- R-2: Parquet+CSV pair rolls back both tmps if either write fails ---------
89
+
90
+ def test_write_pair_rolls_back_on_csv_failure(monkeypatch, tmp_path):
91
+ def _boom(*a, **k):
92
+ raise RuntimeError('csv writer exploded')
93
+
94
+ monkeypatch.setattr(parquetize.pacsv, 'write_csv', _boom)
95
+
96
+ tbl = pa.table({'x': [1, 2, 3]})
97
+ with pytest.raises(RuntimeError):
98
+ parquetize._write_pair(tbl, str(tmp_path), 'foo')
99
+
100
+ for leftover in ('foo.parquet', 'foo.csv', 'foo.parquet.tmp', 'foo.csv.tmp'):
101
+ assert not os.path.exists(tmp_path / leftover), f'left behind: {leftover}'
102
+
103
+
104
+ # --- R-8: convert timeout surfaces stderr before re-raising -------------------
105
+
106
+ def test_convert_timeout_logs_stderr(monkeypatch, capsys):
107
+ def _raise_timeout(*a, **k):
108
+ raise subprocess.TimeoutExpired(cmd=['renderdoccmd'], timeout=1,
109
+ output='', stderr='boom-stderr-tail')
110
+
111
+ monkeypatch.setattr(rdcmd, 'find_renderdoccmd', lambda: 'renderdoccmd.exe')
112
+ monkeypatch.setattr(rdcmd.subprocess, 'run', _raise_timeout)
113
+
114
+ with pytest.raises(subprocess.TimeoutExpired):
115
+ rdcmd.convert('a.rdc', 'b.xml', fmt='xml', timeout_s=1)
116
+
117
+ assert 'boom-stderr-tail' in capsys.readouterr().err
118
+
119
+
120
+ # --- R-7: parse surfaces stderr even when the return code is 0 ----------------
121
+
122
+ def test_parse_one_returns_stderr_on_success(monkeypatch):
123
+ def _fake_run(cmd, *a, **k):
124
+ return type('P', (), {'returncode': 0, 'stdout': 'okout\n', 'stderr': 'warnmsg\n'})()
125
+
126
+ monkeypatch.setattr(run.subprocess, 'run', _fake_run)
127
+
128
+ capture, elapsed, status, stderr = run._parse_one(
129
+ ('x.zip.xml', 'root/_data/_stage/cap', 'A', '2026-01-01', 'x', 'cap'))
130
+
131
+ assert status == 'okout'
132
+ assert stderr == 'warnmsg'
133
+
134
+
135
+ # --- H-27 / G-11: stable keys carry a version byte ---------------------------
136
+
137
+ def test_stable_key_version_prefix():
138
+ assert stable_keys.KEY_VERSION == 1
139
+ bare = hashlib.sha256(b'x').hexdigest()
140
+ versioned = hashlib.sha256(bytes([1]) + b'x').hexdigest()
141
+ assert stable_keys.shader_key('x') != bare
142
+ assert stable_keys.shader_key('x') == versioned
@@ -0,0 +1,22 @@
1
+ """Golden-snapshot parity: rendering the synthetic fixture reproduces the frozen golden HTML
2
+ (byte-identical after masking the build timestamp). The backbone gate for every refactor."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from . import _render_util as u
8
+
9
+
10
+ def test_render_matches_golden(tmp_path):
11
+ dest = u.render_fresh(str(tmp_path / "root"))
12
+
13
+ actual_files = u.rendered_html_files(dest)
14
+ golden_files = u.rendered_html_files(u.GOLDEN_DIR)
15
+ assert actual_files == golden_files, (
16
+ f"rendered page set changed vs golden: {set(actual_files) ^ set(golden_files)}"
17
+ )
18
+
19
+ for rel in golden_files:
20
+ golden = u.normalize(open(os.path.join(u.GOLDEN_DIR, rel), encoding="utf-8").read())
21
+ actual = u.normalize(open(os.path.join(dest, rel), encoding="utf-8").read())
22
+ assert actual == golden, f"output diverged from golden: {rel}"
@@ -0,0 +1,18 @@
1
+ """Perf guard: a full render-only of the synthetic fixture stays well under budget. Catches gross
2
+ regressions, not micro-changes. The budget is generous (subprocess incl. interpreter + pyarrow
3
+ import + render of ~60-row tables); tighten if it ever proves too loose."""
4
+ from __future__ import annotations
5
+
6
+ import time
7
+
8
+ from . import _render_util as u
9
+
10
+ PERF_BUDGET_S = 15.0
11
+
12
+
13
+ def test_render_perf(tmp_path):
14
+ root = u.setup_root(str(tmp_path / "root"))
15
+ t0 = time.monotonic()
16
+ u.render(root)
17
+ dt = time.monotonic() - t0
18
+ assert dt < PERF_BUDGET_S, f"render took {dt:.2f}s (budget {PERF_BUDGET_S}s)"
@@ -0,0 +1,115 @@
1
+ """c13 — replay-schema drift guardrail (H-6, supports D-2).
2
+
3
+ ``replay/replay_main.py`` runs inside qrenderdoc's embedded Python and cannot reliably import the
4
+ host ``bobframes`` package, so it duplicates the schema column tuples from ``schemas.py``. Nothing
5
+ else fails the build when a ``schemas.py`` edit isn't mirrored into that copy, so this test diffs the
6
+ two literals and fails on drift — the duplication is safe to keep precisely because of this guard.
7
+
8
+ Per DECISIONS ADR-5 + ADR-9: match the ``*_COLS`` *suffix* (the original ``_COLS_`` *prefix* matched
9
+ zero and passed vacuously), map abbreviated vars to schema stems via an explicit alias, skip the
10
+ shared ``ID_COLS`` base, and assert a minimum table count so a future rename can't silently
11
+ re-disable the guard.
12
+
13
+ ADR-9 correction (verified empirically): replay defines 20 ``*_COLS`` tables (not 21), and three of
14
+ them — ``events`` / ``draws`` / ``passes`` — legitimately omit four columns that
15
+ ``derive_post_merge.py`` computes host-side *after* replay. So the guard compares each replay tuple
16
+ against its schema tuple **minus a pinned set of host-derived columns**: that still catches any
17
+ raw-column add/remove/reorder and any new *unpinned* schema-only column, while staying green today.
18
+
19
+ Named ``test_replay_drift.py`` (not the c13 doc's ``replay_drift.py``) so default pytest collects it —
20
+ the repo defines no ``python_files`` override. Same reason as c03's ``test_hardening.py``.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ from pathlib import Path
26
+
27
+ import bobframes
28
+
29
+ from .. import schemas
30
+
31
+ _REPLAY_MAIN = Path(bobframes.__file__).resolve().parent / "replay" / "replay_main.py"
32
+
33
+ # var (sans _COLS) -> schemas stem; identity-lowercase unless listed.
34
+ _REPLAY_STEM = {
35
+ "RT": "render_targets",
36
+ "RT_TIMELINE": "rt_event_timeline",
37
+ "STATE_CHANGE": "state_change_events",
38
+ "COUNTERS": "counters_per_event",
39
+ }
40
+
41
+ # Columns schemas.py carries that the replay stage legitimately does NOT emit: they are derived
42
+ # host-side in derive_post_merge.py (see its module docstring) after the raw CSVs land. Keyed by
43
+ # schema stem. Anything schema-only and NOT listed here is treated as drift.
44
+ _DERIVED_COLS = {
45
+ "events": ("parent_marker_path_norm",),
46
+ "draws": ("parent_pass_path_norm", "draw_class"),
47
+ "passes": ("marker_path_norm",),
48
+ }
49
+
50
+ _EXPECTED_REPLAY_TABLES = 20 # ADR-9: empirically 20 (ADR-5 estimated 21).
51
+
52
+
53
+ def _resolve_tuple(node: ast.AST, names: dict[str, tuple]) -> tuple:
54
+ """Resolve a column-tuple expression: a literal tuple, a bare Name referring to an already-seen
55
+ tuple (e.g. ``ID_COLS``), or any chain of ``+`` concatenations of those."""
56
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
57
+ return _resolve_tuple(node.left, names) + _resolve_tuple(node.right, names)
58
+ if isinstance(node, ast.Name):
59
+ return names[node.id]
60
+ if isinstance(node, ast.Tuple):
61
+ return tuple(elt.value for elt in node.elts)
62
+ raise AssertionError(f"unexpected node in column tuple: {ast.dump(node)}")
63
+
64
+
65
+ def _extract_col_tuples(tree: ast.Module, suffix: str, skip: set[str]) -> dict[str, tuple]:
66
+ """Parse top-level ``<NAME>_COLS = (...)`` assignments into full literal tuples.
67
+
68
+ Tracks every resolved ``*_COLS`` name (incl. the skipped base) so later concatenations like
69
+ ``ID_COLS + (...)`` resolve, but returns only names that end with ``suffix`` and aren't in
70
+ ``skip``.
71
+ """
72
+ names: dict[str, tuple] = {}
73
+ result: dict[str, tuple] = {}
74
+ for node in tree.body:
75
+ if not (isinstance(node, ast.Assign) and len(node.targets) == 1
76
+ and isinstance(node.targets[0], ast.Name)):
77
+ continue
78
+ var = node.targets[0].id
79
+ if not var.endswith(suffix):
80
+ continue
81
+ names[var] = _resolve_tuple(node.value, names)
82
+ if var not in skip:
83
+ result[var] = names[var]
84
+ return result
85
+
86
+
87
+ def test_replay_main_schema_in_sync():
88
+ tree = ast.parse(_REPLAY_MAIN.read_text(encoding="utf-8"))
89
+ replay_tables = _extract_col_tuples(tree, suffix="_COLS", skip={"ID_COLS"})
90
+
91
+ assert len(replay_tables) >= _EXPECTED_REPLAY_TABLES, (
92
+ f"guard must not match near-zero: found {len(replay_tables)} replay *_COLS tables, "
93
+ f"expected >= {_EXPECTED_REPLAY_TABLES} (a rename may have silently disabled the guard)"
94
+ )
95
+
96
+ for var, cols in replay_tables.items():
97
+ base = var[: -len("_COLS")]
98
+ stem = _REPLAY_STEM.get(base, base.lower())
99
+ try:
100
+ expected = schemas.expected_columns(stem)
101
+ except KeyError:
102
+ raise AssertionError(f"{var}: no schema stem {stem!r} in schemas.TABLES")
103
+
104
+ derived = _DERIVED_COLS.get(stem, ())
105
+ # Pinned derived columns must really be in the schema, or the allowlist is masking a typo /
106
+ # a genuine raw column.
107
+ for d in derived:
108
+ assert d in expected, f"_DERIVED_COLS[{stem!r}] lists {d!r}, absent from schemas.{stem}"
109
+
110
+ expected_raw = tuple(c for c in expected if c not in derived)
111
+ assert cols == expected_raw, (
112
+ f"{var} drifted from schemas.{stem}: "
113
+ f"replay_only={[c for c in cols if c not in expected_raw]} "
114
+ f"schema_only={[c for c in expected_raw if c not in cols]}"
115
+ )
@@ -0,0 +1,26 @@
1
+ """Schema regression: every synthetic Parquet's column list matches schemas.expected_columns(stem)
2
+ exactly (catches alphabetization drift, dropped columns, dtype-name slips)."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import pyarrow.parquet as pq
8
+
9
+ from .. import schemas
10
+ from . import _render_util as u
11
+
12
+
13
+ def test_synthetic_parquet_schemas():
14
+ checked = 0
15
+ for dirpath, _dirs, files in os.walk(u.SYNTHETIC_DATA):
16
+ for fn in files:
17
+ if not fn.endswith(".parquet"):
18
+ continue
19
+ stem = fn[: -len(".parquet")]
20
+ if stem.startswith("_"): # _catalog, _global_entities — not per-table
21
+ continue
22
+ expected = schemas.expected_columns(stem)
23
+ actual = tuple(pq.read_schema(os.path.join(dirpath, fn)).names)
24
+ assert actual == expected, f"{fn}: symmetric diff {set(actual) ^ set(expected)}"
25
+ checked += 1
26
+ assert checked >= 27, f"expected to check >= 27 tables, checked {checked}"
@@ -0,0 +1,55 @@
1
+ """Unit tests for the schemas module helpers (c15, doc's `unit_schemas.py`).
2
+
3
+ Distinct from test_schemas.py (the parity-tier check that emitted Parquet matches the schema): this
4
+ pins the pure-Python contracts of schemas.py itself — expected_columns round-trips, the ID_COLS
5
+ prefix invariant, dtype inference totality, and no duplicate columns.
6
+
7
+ Named `test_schemas_unit` (not the c15 doc's `unit_schemas.py`) to avoid colliding with the existing
8
+ test_schemas.py and to satisfy default pytest discovery (no `python_files` override).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+
14
+ from .. import schemas
15
+
16
+
17
+ def test_schema_version_is_int():
18
+ assert isinstance(schemas.SCHEMA_VERSION, int)
19
+
20
+
21
+ def test_expected_columns_roundtrips_every_stem():
22
+ for stem, (cols, size, entity) in schemas.TABLES.items():
23
+ assert schemas.expected_columns(stem) == cols
24
+ assert schemas.is_entity_table(stem) is entity
25
+ assert schemas.size_class(stem) == size
26
+ assert size in ('large', 'small')
27
+
28
+
29
+ def test_every_table_starts_with_id_cols():
30
+ for stem, cols in ((s, schemas.expected_columns(s)) for s in schemas.TABLES):
31
+ assert cols[:len(schemas.ID_COLS)] == schemas.ID_COLS, stem
32
+
33
+
34
+ def test_no_duplicate_columns_within_a_table():
35
+ for stem in schemas.TABLES:
36
+ cols = schemas.expected_columns(stem)
37
+ assert len(cols) == len(set(cols)), f'{stem} has duplicate columns'
38
+
39
+
40
+ def test_expected_columns_unknown_stem_raises():
41
+ with pytest.raises(KeyError):
42
+ schemas.expected_columns('does_not_exist')
43
+
44
+
45
+ def test_infer_dtype_total_and_spot_checks():
46
+ # Every column across every table infers one of the four supported dtypes.
47
+ for stem in schemas.TABLES:
48
+ for col in schemas.expected_columns(stem):
49
+ assert schemas.infer_dtype(col) in ('int', 'float', 'bool', 'str'), (stem, col)
50
+ # Spot-checks across the inference buckets.
51
+ assert schemas.infer_dtype('event_id') == 'int'
52
+ assert schemas.infer_dtype('gpu_duration_s') == 'float'
53
+ assert schemas.infer_dtype('is_rt') == 'bool'
54
+ assert schemas.infer_dtype('format') == 'str'
55
+ assert schemas.infer_dtype('totally_unknown_column') == 'str' # default
@@ -0,0 +1,61 @@
1
+ """Unit tests for stable_keys (c15, doc's `unit_keys.py`).
2
+
3
+ Pins the load-bearing key contracts: version prefix, GLSL normalization, determinism, the
4
+ empty-string contract for unknown inputs, and order-invariance of the composite keys. The exact
5
+ SHA256 version-prefix byte is already asserted in test_hardening.py — here we cover the surrounding
6
+ behavior so a key-derivation rule change can't silently shift results.
7
+
8
+ Named `test_*` (not the c15 doc's `unit_keys.py`) so default pytest collects it — no `python_files`
9
+ override in pyproject.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from .. import stable_keys as sk
14
+
15
+
16
+ def test_key_version_is_one():
17
+ assert sk.KEY_VERSION == 1
18
+
19
+
20
+ def test_normalize_glsl_strips_and_collapses():
21
+ src = "void main(){\n // line comment\n /* block */ x=1; \n\n\n\n y=2;\n}\n"
22
+ out = sk.normalize_glsl(src)
23
+ assert "//" not in out and "/*" not in out
24
+ assert "\n\n\n" not in out # blank-line runs collapsed to at most one blank line
25
+ assert not out.endswith(" ") # trailing ws stripped
26
+ assert sk.normalize_glsl(out) == out # idempotent
27
+ assert sk.normalize_glsl("") == ""
28
+
29
+
30
+ def test_shader_key_determinism_and_comment_insensitivity():
31
+ a = sk.normalize_glsl("float f(){ return 1.0; } // v1")
32
+ b = sk.normalize_glsl("float f(){ return 1.0; } /* different comment */")
33
+ assert sk.shader_key(a) == sk.shader_key(b) # comments don't change the key
34
+ assert sk.shader_key(a) == sk.shader_key(a) # deterministic
35
+ assert sk.shader_key("float g(){ return 2.0; }") != sk.shader_key(a)
36
+ assert len(sk.shader_key(a)) == 64 # sha256 hexdigest
37
+
38
+
39
+ def test_empty_input_contract():
40
+ assert sk.shader_key("") == ""
41
+ assert sk.program_key([]) == ""
42
+ assert sk.program_key(["", ""]) == "" # only empties -> empty
43
+ assert sk.fbo_key([]) == ""
44
+ assert sk.texture_key("lbl", None, 10, 10, 1, 1, 1) == "" # fmt None
45
+ assert sk.texture_key("lbl", "RGBA8", None, 10, 1, 1, 1) == "" # width None
46
+ assert sk.sampler_key(None, "LIN", "R", "R", "R", 1, "NONE", "ALW") == ""
47
+ assert sk.buffer_key("h", 0, "t") == "" # size <= 0
48
+ assert sk.buffer_key("h", -5, "t") == ""
49
+
50
+
51
+ def test_composite_keys_order_invariant_and_filter_empties():
52
+ assert sk.program_key(["a", "b"]) == sk.program_key(["b", "a"])
53
+ assert sk.program_key(["a", "", "b"]) == sk.program_key(["b", "a"]) # empties dropped
54
+ assert sk.fbo_key(["x", "y"]) == sk.fbo_key(["y", "x"])
55
+ assert sk.program_key(["a", "b"]) != sk.program_key(["a", "c"])
56
+
57
+
58
+ def test_distinct_payloads_distinct_keys():
59
+ t1 = sk.texture_key("color", "RGBA8", 256, 256, 1, 1, 1)
60
+ t2 = sk.texture_key("color", "RGBA8", 512, 256, 1, 1, 1)
61
+ assert t1 and t2 and t1 != t2