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,51 @@
1
+ {
2
+ "schema_version": 3,
3
+ "build_timestamp": "2026-01-01T00:00:00+00:00",
4
+ "area": "District 01",
5
+ "drop_date": "2026-05-27",
6
+ "drop_label": "r110565",
7
+ "captures": [
8
+ "1",
9
+ "2",
10
+ "3",
11
+ "4",
12
+ "5"
13
+ ],
14
+ "capture_status": {
15
+ "1": "ok",
16
+ "2": "ok",
17
+ "3": "ok",
18
+ "4": "ok",
19
+ "5": "ok"
20
+ },
21
+ "row_counts": {
22
+ "draws": 60,
23
+ "draw_bindings": 60,
24
+ "passes": 60,
25
+ "shaders": 60,
26
+ "textures": 60,
27
+ "render_targets": 45,
28
+ "rt_event_timeline": 60,
29
+ "buffers": 60,
30
+ "programs": 60,
31
+ "samplers": 55,
32
+ "fbos": 60,
33
+ "events": 60,
34
+ "clears": 40,
35
+ "dispatches": 45,
36
+ "state_change_events": 60,
37
+ "indirect_args": 0,
38
+ "vertex_inputs": 60,
39
+ "resource_creation": 60,
40
+ "counters_per_event": 60,
41
+ "descriptor_access": 60,
42
+ "program_transitions": 60,
43
+ "frame_totals": 5,
44
+ "pixel_history": 60,
45
+ "vbo_samples": 60,
46
+ "ibo_samples": 60,
47
+ "post_vs_samples": 60,
48
+ "texture_samples": 60
49
+ },
50
+ "rotated_from": null
51
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "schema_version": 3,
3
+ "build_timestamp": "2026-01-01T00:00:00+00:00",
4
+ "area": "District 01",
5
+ "drop_date": "2026-05-28",
6
+ "drop_label": "r110600",
7
+ "captures": [
8
+ "1",
9
+ "2",
10
+ "3",
11
+ "4",
12
+ "5"
13
+ ],
14
+ "capture_status": {
15
+ "1": "ok",
16
+ "2": "ok",
17
+ "3": "ok",
18
+ "4": "ok",
19
+ "5": "ok"
20
+ },
21
+ "row_counts": {
22
+ "draws": 60,
23
+ "draw_bindings": 60,
24
+ "passes": 60,
25
+ "shaders": 60,
26
+ "textures": 60,
27
+ "render_targets": 45,
28
+ "rt_event_timeline": 60,
29
+ "buffers": 60,
30
+ "programs": 60,
31
+ "samplers": 55,
32
+ "fbos": 60,
33
+ "events": 60,
34
+ "clears": 40,
35
+ "dispatches": 45,
36
+ "state_change_events": 60,
37
+ "indirect_args": 0,
38
+ "vertex_inputs": 60,
39
+ "resource_creation": 60,
40
+ "counters_per_event": 60,
41
+ "descriptor_access": 60,
42
+ "program_transitions": 60,
43
+ "frame_totals": 5,
44
+ "pixel_history": 60,
45
+ "vbo_samples": 60,
46
+ "ibo_samples": 60,
47
+ "post_vs_samples": 60,
48
+ "texture_samples": 60
49
+ },
50
+ "rotated_from": null
51
+ }
@@ -0,0 +1,171 @@
1
+ """Generate the bundled synthetic `_data/` test fixture from a real ingest.
2
+
3
+ Derives a tiny, content-scrubbed dataset from a real `_data/` tree so the golden-parity
4
+ suite has something to render in CI without committing real captures (ADR-8). NOT run in
5
+ CI — run by hand to (re)generate the fixture, then commit the result + refreshed golden.
6
+
7
+ Scrubbing policy (ADR-8 + ADR-6):
8
+ * area name -> generic ("District 01")
9
+ * marker/pass paths -> KEYWORD-PRESERVING (keeps the draw_class keyword so the classifier
10
+ still produces the same class distribution; strips game content)
11
+ * labels / names / file paths -> generic per-row tokens
12
+ * counter names, formats, enums, blend/depth numerics -> KEPT (not content; load-bearing,
13
+ e.g. 'GPU Duration' drives pass_gpu)
14
+ * row counts truncated to --rows per table
15
+
16
+ Usage (from the venv):
17
+ python -m bobframes.tests.make_synthetic \
18
+ --src "c:/Users/vsiva/Downloads/RDC mainline r110565 25-05-2026/_data" \
19
+ --out bobframes/tests/data/synthetic --rows 60
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+
27
+ import pyarrow as pa
28
+ import pyarrow.parquet as pq
29
+
30
+ from .. import schemas
31
+
32
+ SYNTH_AREA = "District 01"
33
+ FIXED_TS = "2026-01-01T00:00:00+00:00"
34
+
35
+ # Two synthetic drops (different build labels/dates) so trend/sparkline reports have >1 point.
36
+ DROPS = [
37
+ {"date": "2026-05-27", "label": "r110565"},
38
+ {"date": "2026-05-28", "label": "r110600"},
39
+ ]
40
+
41
+ # Derived tables are regenerated by render-only (derive/pass_class_breakdown/texture_usage); skip.
42
+ SKIP_TABLES = {"pass_class_breakdown", "texture_usage"}
43
+
44
+ # String columns that carry game content -> generic per-row token.
45
+ GENERIC_SCRUB = {
46
+ "draw_name", "label", "src_file_path", "declared_label",
47
+ "attribute_name", "usage_name",
48
+ }
49
+ # Marker/pass-path columns -> keyword-preserving (keeps draw_class coverage).
50
+ MARKER_SCRUB = {
51
+ "marker_path", "marker_path_norm", "parent_pass_path", "parent_pass_path_norm",
52
+ "parent_marker_path",
53
+ }
54
+ _STRING_TYPES = (pa.string(), pa.large_string())
55
+
56
+
57
+ def _marker_token(mp: str) -> str:
58
+ """Return a class-preserving stand-in for a marker path (mirrors _classify_draw order)."""
59
+ s = (mp or "").lower()
60
+ if "shadow" in s:
61
+ return "shadow"
62
+ if "prepass" in s or "depthonly" in s:
63
+ return "prepass"
64
+ if "slate" in s or "/ui" in s or s.endswith("ui"):
65
+ return "slate" # -> ui branch
66
+ if "postprocess" in s or "tonemap" in s or "bloom" in s or "eyeadapt" in s:
67
+ return "postprocess"
68
+ if "decal" in s:
69
+ return "decal"
70
+ if "translucen" in s:
71
+ return "translucent"
72
+ if "basepass" in s:
73
+ return "basepass"
74
+ return "scene" # no keyword -> class falls to blend/depth (preserved)
75
+
76
+
77
+ def _scrub_table(t: pa.Table, area: str, date: str, label: str) -> pa.Table:
78
+ n = t.num_rows
79
+ field_type = {f.name: f.type for f in t.schema}
80
+ out = {}
81
+ for name in t.column_names:
82
+ ft = field_type[name]
83
+ if name == "area":
84
+ out[name] = pa.array([area] * n, type=ft)
85
+ elif name == "drop_date":
86
+ out[name] = pa.array([date] * n, type=ft)
87
+ elif name == "drop_label":
88
+ out[name] = pa.array([label] * n, type=ft)
89
+ elif name in MARKER_SCRUB and ft in _STRING_TYPES:
90
+ orig = t.column(name).to_pylist()
91
+ out[name] = pa.array(
92
+ [f"Frame 1/{_marker_token(v)}/p{i}" if v is not None else None
93
+ for i, v in enumerate(orig)],
94
+ type=ft,
95
+ )
96
+ elif name in GENERIC_SCRUB and ft in _STRING_TYPES:
97
+ orig = t.column(name).to_pylist()
98
+ out[name] = pa.array(
99
+ [f"{name}_{i}" if v is not None else None for i, v in enumerate(orig)],
100
+ type=ft,
101
+ )
102
+ else:
103
+ out[name] = t.column(name)
104
+ # Preserve column order exactly (schema regression checks names/order).
105
+ return pa.table({name: out[name] for name in t.column_names})
106
+
107
+
108
+ def _build_drop(src_area_dir: str, src_drop: str, out_drop_dir: str,
109
+ date: str, label: str) -> dict:
110
+ os.makedirs(out_drop_dir, exist_ok=True)
111
+ src_drop_dir = os.path.join(src_area_dir, src_drop)
112
+ row_counts: dict[str, int] = {}
113
+ captures: set[str] = set()
114
+ for stem in schemas.TABLES:
115
+ if stem in SKIP_TABLES:
116
+ continue
117
+ src = os.path.join(src_drop_dir, f"{stem}.parquet")
118
+ if not os.path.exists(src):
119
+ continue
120
+ t = pq.read_table(src)
121
+ t = t.slice(0, ARGS.rows)
122
+ t = _scrub_table(t, SYNTH_AREA, date, label)
123
+ pq.write_table(t, os.path.join(out_drop_dir, f"{stem}.parquet"), compression="snappy")
124
+ row_counts[stem] = t.num_rows
125
+ if "capture" in t.column_names:
126
+ captures.update(str(c) for c in t.column("capture").to_pylist() if c is not None)
127
+ caps = sorted(captures, key=lambda x: (len(x), x))
128
+ manifest = {
129
+ "schema_version": schemas.SCHEMA_VERSION,
130
+ "build_timestamp": FIXED_TS,
131
+ "area": SYNTH_AREA,
132
+ "drop_date": date,
133
+ "drop_label": label,
134
+ "captures": caps,
135
+ "capture_status": {c: "ok" for c in caps},
136
+ "row_counts": row_counts,
137
+ "rotated_from": None,
138
+ }
139
+ with open(os.path.join(out_drop_dir, "_manifest.json"), "w", encoding="utf-8") as f:
140
+ json.dump(manifest, f, indent=2)
141
+ f.write("\n")
142
+ return {"label": label, "tables": len(row_counts), "captures": caps}
143
+
144
+
145
+ def main() -> int:
146
+ src = ARGS.src
147
+ areas = sorted(d for d in os.listdir(src) if os.path.isdir(os.path.join(src, d)))
148
+ if len(areas) < len(DROPS):
149
+ raise SystemExit(f"need >= {len(DROPS)} source areas, found {len(areas)} in {src}")
150
+ out_area = os.path.join(ARGS.out, "_data", SYNTH_AREA)
151
+ for spec, src_area in zip(DROPS, areas):
152
+ src_area_dir = os.path.join(src, src_area)
153
+ src_drops = sorted(d for d in os.listdir(src_area_dir)
154
+ if os.path.isdir(os.path.join(src_area_dir, d)))
155
+ if not src_drops:
156
+ raise SystemExit(f"no drops under {src_area_dir}")
157
+ out_drop = os.path.join(out_area, f"{spec['date']}_{spec['label']}")
158
+ info = _build_drop(src_area_dir, src_drops[0], out_drop, spec["date"], spec["label"])
159
+ print(f" {SYNTH_AREA}/{spec['date']}_{spec['label']} <- {src_area}/{src_drops[0]} "
160
+ f"({info['tables']} tables, captures={info['captures']})")
161
+ print(f"synthetic _data written under {os.path.join(ARGS.out, '_data')}")
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ ap = argparse.ArgumentParser(prog="bobframes.tests.make_synthetic")
167
+ ap.add_argument("--src", required=True, help="real _data/ tree to derive from")
168
+ ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "data", "synthetic"))
169
+ ap.add_argument("--rows", type=int, default=60, help="max rows per table")
170
+ ARGS = ap.parse_args()
171
+ raise SystemExit(main())
@@ -0,0 +1,199 @@
1
+ """End-to-end smoke test (c15 rewrite — G-12).
2
+
3
+ Two modes, no hardcoded area/label/date (the old `Chor bazar` / `r110565` / `2026-05-27`
4
+ constants + the `__file__`-walked project root are gone):
5
+
6
+ bobframes smoke render-only against the bundled synthetic `_data/` fixture
7
+ (no `.rdc`, no qrenderdoc/GPU). Runs everywhere, incl. CI.
8
+ bobframes smoke --data <DIR> full ingest + render against a real capture root; auto-selects
9
+ area + latest drop via discovery.find_drops. Needs Windows +
10
+ RenderDoc; self-hosted / nightly only.
11
+
12
+ Both modes assert: outputs exist, every Parquet matches schemas.expected_columns, entity tables
13
+ carry a populated stable_key, catalog is rebuilt, and every emitted HTML is lint-clean.
14
+
15
+ Run standalone: python -m bobframes.tests.smoke [--data DIR]
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+
27
+ import pyarrow.parquet as papq
28
+
29
+ from .. import discovery, lint, paths, schemas
30
+
31
+
32
+ def _fail(msg: str) -> int:
33
+ print(f'FAIL: {msg}')
34
+ return 1
35
+
36
+
37
+ def _lint_html(root: str) -> int:
38
+ """Lint every emitted HTML under `root` (skipping the parquet cache). Returns hit count."""
39
+ from . import _render_util as u
40
+ hits = 0
41
+ for rel in u.rendered_html_files(root):
42
+ for lineno, label, snip in lint.lint_file(os.path.join(root, rel)):
43
+ print(f' LINT {rel}:{lineno}: [{label}] {snip}')
44
+ hits += 1
45
+ return hits
46
+
47
+
48
+ def _assert_drop_parquet(out_dir: str, check_csv: bool) -> int:
49
+ """Schema match + stable_key population for one drop's `_data` dir. Returns error count.
50
+
51
+ `check_csv` asserts each Parquet has its `.csv` sidecar — true only for the full-ingest path;
52
+ the committed synthetic fixture is Parquet-only (ADR-8), so render-only skips it.
53
+ """
54
+ errors = 0
55
+ for stem in schemas.TABLES:
56
+ pq = os.path.join(out_dir, f'{stem}.parquet')
57
+ if not os.path.exists(pq):
58
+ continue
59
+ if check_csv and not os.path.exists(os.path.join(out_dir, f'{stem}.csv')):
60
+ print(f' {stem}: .csv missing alongside .parquet')
61
+ errors += 1
62
+ cols = list(papq.read_schema(pq).names)
63
+ expected = list(schemas.expected_columns(stem))
64
+ if cols != expected:
65
+ print(f' {stem}: cols={cols} != expected={expected}')
66
+ errors += 1
67
+ if schemas.is_entity_table(stem):
68
+ t = papq.read_table(pq, columns=['stable_key'])
69
+ if t.num_rows and not any(t.column('stable_key').to_pylist()):
70
+ print(f' {stem}: stable_key all empty across {t.num_rows} rows')
71
+ errors += 1
72
+ return errors
73
+
74
+
75
+ # --- render-only (default) ---------------------------------------------------
76
+
77
+ def _render_only_smoke() -> int:
78
+ from . import _render_util as u
79
+
80
+ with tempfile.TemporaryDirectory(prefix='bobframes-smoke-') as td:
81
+ dest = os.path.join(td, 'root')
82
+ print(f'1. render-only against bundled synthetic -> {dest}')
83
+ try:
84
+ u.render_fresh(dest)
85
+ except RuntimeError as e:
86
+ return _fail(str(e))
87
+
88
+ print('2. html emitted')
89
+ html = u.rendered_html_files(dest)
90
+ if not os.path.exists(paths.root_index_html(dest)):
91
+ return _fail('root index.html missing')
92
+ drills = [r for r in html if r.endswith('/index.html') and 'drill' in r]
93
+ reports = [r for r in html if r.startswith('_reports/') and 'drill' not in r]
94
+ if not drills:
95
+ return _fail('no per-drop drill index.html emitted')
96
+ if not reports:
97
+ return _fail('no _reports/*.html emitted')
98
+
99
+ print('3. schema + stable_key (synthetic _data)')
100
+ errors = 0
101
+ for drop in discovery.find_drops(dest):
102
+ out_dir = paths.drop_data_dir(dest, drop.area, os.path.basename(drop.drop_dir))
103
+ errors += _assert_drop_parquet(out_dir, check_csv=False)
104
+ if errors:
105
+ return _fail(f'{errors} parquet schema/stable_key error(s)')
106
+
107
+ print('4. catalog')
108
+ if not os.path.exists(paths.catalog_parquet(dest)):
109
+ return _fail('catalog parquet missing')
110
+
111
+ print('5. lint')
112
+ if _lint_html(dest):
113
+ return _fail('lint hits in rendered HTML')
114
+
115
+ print(f'OK: render-only smoke - {len(drills)} drop(s), {len(html)} HTML pages, lint clean')
116
+ return 0
117
+
118
+
119
+ # --- full ingest (--data) ----------------------------------------------------
120
+
121
+ def _full_smoke(data_dir: str, pixel_grid: int) -> int:
122
+ data_dir = os.path.abspath(data_dir)
123
+ drops = discovery.find_drops(data_dir)
124
+ if not drops:
125
+ return _fail(f'no drops with .rdc found under {data_dir}')
126
+ print(f'1. discovered {len(drops)} drop(s): ' +
127
+ ', '.join(f'{d.area}/{os.path.basename(d.drop_dir)}' for d in drops))
128
+
129
+ print('2. ingest (full pipeline, --force)')
130
+ rc = subprocess.run(
131
+ [sys.executable, '-m', 'bobframes.run', '--root', data_dir, '--force',
132
+ '--pixel-grid', str(pixel_grid)],
133
+ ).returncode
134
+ if rc != 0:
135
+ return _fail(f'pipeline exited rc={rc}')
136
+
137
+ print('3. per-drop outputs')
138
+ errors = 0
139
+ for drop in drops:
140
+ dated = os.path.basename(drop.drop_dir)
141
+ out_dir = paths.drop_data_dir(data_dir, drop.area, dated)
142
+ tmp_dir = paths.drop_data_dir_tmp(data_dir, drop.area, dated)
143
+ if not os.path.isdir(out_dir):
144
+ errors += 1
145
+ print(f' {out_dir} missing')
146
+ continue
147
+ if os.path.isdir(tmp_dir):
148
+ errors += 1
149
+ print(f' {tmp_dir} should be gone after atomic commit')
150
+ errors += _assert_drop_parquet(out_dir, check_csv=True)
151
+
152
+ mf = os.path.join(out_dir, '_manifest.json')
153
+ with open(mf, encoding='utf-8') as f:
154
+ m = json.load(f)
155
+ if m.get('schema_version') != schemas.SCHEMA_VERSION:
156
+ errors += 1
157
+ print(f' manifest schema_version={m.get("schema_version")} != {schemas.SCHEMA_VERSION}')
158
+ if not all(s == 'ok' for s in m.get('capture_status', {}).values()):
159
+ errors += 1
160
+ print(f' capture_status not all ok: {m.get("capture_status")}')
161
+
162
+ drill = paths.drop_drill_dir(data_dir, drop.area, dated)
163
+ if not os.path.exists(os.path.join(drill, 'index.html')):
164
+ errors += 1
165
+ print(f' drill index.html missing at {drill}')
166
+ if errors:
167
+ return _fail(f'{errors} ingest output error(s)')
168
+
169
+ print('4. catalog + root index')
170
+ if not os.path.exists(paths.catalog_parquet(data_dir)):
171
+ return _fail('catalog parquet missing')
172
+ if not os.path.exists(paths.root_index_html(data_dir)):
173
+ return _fail('root index.html missing')
174
+
175
+ print('5. lint')
176
+ if _lint_html(data_dir):
177
+ return _fail('lint hits in rendered HTML')
178
+
179
+ print(f'OK: full smoke - {len(drops)} drop(s) ingested + rendered, lint clean')
180
+ return 0
181
+
182
+
183
+ def main(data: str | None = None, pixel_grid: int = 4) -> int:
184
+ if data:
185
+ return _full_smoke(data, pixel_grid)
186
+ return _render_only_smoke()
187
+
188
+
189
+ def _parse(argv: list[str]) -> argparse.Namespace:
190
+ ap = argparse.ArgumentParser(prog='bobframes.tests.smoke',
191
+ description='render-only (default) or full ingest (--data) smoke')
192
+ ap.add_argument('--data', help='capture root for full ingest (default: bundled synthetic)')
193
+ ap.add_argument('--pixel-grid', type=int, default=4)
194
+ return ap.parse_args(argv)
195
+
196
+
197
+ if __name__ == '__main__':
198
+ a = _parse(sys.argv[1:])
199
+ sys.exit(main(data=a.data, pixel_grid=a.pixel_grid))
@@ -0,0 +1,19 @@
1
+ """Determinism: rendering the same data twice produces byte-identical HTML (after masking the
2
+ build timestamp). Catches dict-ordering, set-iteration, and other nondeterminism regressions."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from . import _render_util as u
8
+
9
+
10
+ def test_render_is_deterministic(tmp_path):
11
+ a = u.render_fresh(str(tmp_path / "a"))
12
+ b = u.render_fresh(str(tmp_path / "b"))
13
+
14
+ fa, fb = u.rendered_html_files(a), u.rendered_html_files(b)
15
+ assert fa == fb, f"page set differs between runs: {set(fa) ^ set(fb)}"
16
+ for rel in fa:
17
+ ca = u.normalize(open(os.path.join(a, rel), encoding="utf-8").read())
18
+ cb = u.normalize(open(os.path.join(b, rel), encoding="utf-8").read())
19
+ assert ca == cb, f"nondeterministic output: {rel}"