McRogueFace/tests/regression/issue_341_metrics_counters_test.py
John McCardle c72ab22999 fix(metrics): report counters Python can actually observe
get_metrics() returned 0 for draw_calls, ui_elements, visible_elements and
every grid counter. Two distinct defects behind one symptom.

Ordering. draw_calls/ui_elements/visible_elements WERE incremented, in
PyScene::render() -- but render runs at step 6 of the frame, after every
Python callback, while the counters were zeroed at step 1. A script calling
get_metrics() from a timer or scene update was structurally incapable of ever
observing a nonzero value.

Dead code. grid_cells_rendered, entities_rendered, total_entities,
grid_render_time and entity_render_time were never incremented ANYWHERE in
src/ -- the increments were lost in the GridView/chunk refactor and only
fovOverlayTime survived. They are re-instrumented in UIGridView::render().

Fix both by splitting the metrics along the seam that actually exists:
rendering is orthogonal to simulation and costs zero clock time (a screenshot
draws arbitrary state without advancing time). So render counters are cleared
and published by the render pass (beginRender/endRender, in both doFrame and
the on-demand renderScene), and simulation timings by the sim pass
(beginSimFrame/endSimFrame). get_metrics() reads the published values, so a
step()-only run cannot wipe the counters from the last render, and a render
cannot disturb the clock.

Riders found while in here:
- ScopedTimer ASSIGNS, so with two grid views on screen the second view's time
  silently replaced the first's. Added ScopedAccumTimer for per-frame totals
  accumulated across objects; fovOverlayTime had this bug already.
- fps was inflated for the first 60 frames: frameTimeHistory is zero-filled but
  the mean always divided by the full 60. Now divides by frames actually seen.
- frame_time is milliseconds; the docstring said seconds. Fixed the docstring,
  not the value -- the Gauntlet already reads it as ms.

closes #341
2026-07-14 06:37:33 -04:00

133 lines
4.9 KiB
Python

"""
Regression test for issue #341.
get_metrics() reported 0 for every render counter:
* draw_calls / ui_elements / visible_elements WERE incremented -- but only inside
render(), which runs after every Python callback, while the counters were zeroed
at the top of the frame. Python was structurally unable to observe a nonzero
value. Render counters are now published at the end of the render pass.
* grid_cells_rendered / entities_rendered / total_entities / grid_render_time /
entity_render_time were never incremented ANYWHERE -- dead code since the
GridView refactor. Re-instrumented in UIGridView::render().
Also locks two riders: frame_time is milliseconds (the docstring claimed seconds),
and fps is no longer inflated by the zero-filled history buffer on early frames.
Model under test (#350): step() is the clock and never renders; rendering is
orthogonal and costs zero simulation time. So we drive time with step() and force a
render with a screenshot, then read the metrics that render published.
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
import tempfile
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
scene = mcrfpy.Scene("issue341")
ui = scene.children
# A grid with a layer + entities, so the grid counters have something to count.
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320))
layer = mcrfpy.TileLayer("ground", 0)
grid.add_layer(layer)
for x in range(20):
for y in range(20):
layer.set((x, y), 0)
for i in range(6):
grid.entities.append(mcrfpy.Entity(grid_pos=(i, i)))
ui.append(grid)
# Plain UI elements, so ui_elements / draw_calls have something to count.
for i in range(4):
ui.append(mcrfpy.Frame(pos=(400 + i * 20, 10), size=(15, 15)))
ui.append(mcrfpy.Caption(pos=(400, 200), text="metrics"))
mcrfpy.current_scene = scene
# Advance the clock a few times. step() must NOT render, so this alone must leave
# the render counters untouched.
for _ in range(5):
mcrfpy.step(0.016)
print("1. step() advances the sim clock but renders nothing")
m = mcrfpy.get_metrics()
check("current_frame advanced under step()", m["current_frame"] >= 5,
f"got {m['current_frame']}")
check("frame_time is set by step()", m["frame_time"] > 0, f"got {m['frame_time']}")
check("draw_calls still 0 (step does not render)", m["draw_calls"] == 0,
f"got {m['draw_calls']}")
# Force a render. This costs zero simulation time and publishes the render counters.
shot = os.path.join(tempfile.gettempdir(), "issue341_metrics.png")
frame_before = mcrfpy.get_metrics()["current_frame"]
automation.screenshot(shot)
m = mcrfpy.get_metrics()
print("2. Rendering costs zero simulation time")
check("current_frame did not advance from rendering",
m["current_frame"] == frame_before,
f"{frame_before} -> {m['current_frame']}")
print("3. Render counters are observable from Python (were always 0)")
check("draw_calls > 0", m["draw_calls"] > 0, f"got {m['draw_calls']}")
check("ui_elements > 0", m["ui_elements"] > 0, f"got {m['ui_elements']}")
check("visible_elements > 0", m["visible_elements"] > 0, f"got {m['visible_elements']}")
print("4. Grid counters are re-instrumented (were dead code)")
check("grid_cells_rendered > 0", m["grid_cells_rendered"] > 0,
f"got {m['grid_cells_rendered']}")
check("entities_rendered > 0", m["entities_rendered"] > 0,
f"got {m['entities_rendered']}")
check("total_entities == 6", m["total_entities"] == 6, f"got {m['total_entities']}")
check("entities_rendered <= total_entities",
m["entities_rendered"] <= m["total_entities"])
# A cell "render" is counted per cell per layer drawn: the 20x20 grid is fully on
# screen, so it is exactly 400 * (number of layers).
expected_cells = 400 * len(grid.layers)
check(f"grid_cells_rendered == 400 * {len(grid.layers)} layers",
m["grid_cells_rendered"] == expected_cells,
f"got {m['grid_cells_rendered']}, expected {expected_cells}")
print("5. frame_time is in milliseconds, not seconds")
ft = m["frame_time"]
# step(0.016) -> 16ms. In seconds this would read 0.016 and fail the lower bound.
check("frame_time ~= 16ms for step(0.016)", 10.0 <= ft <= 25.0, f"got {ft}")
print("6. fps is sane on early frames (was inflated by the zero-filled history)")
fps = m["fps"]
check("fps > 0", fps > 0, f"got {fps}")
# Pre-fix, dividing by a mostly-zero 60-slot buffer inflated this enormously.
# step(0.016) is 62.5 fps; allow slack but nothing absurd.
check("fps is not absurdly inflated", fps < 1000, f"got {fps}")
print("7. Timing breakdowns are present")
check("grid_render_time >= 0", m["grid_render_time"] >= 0.0)
check("entity_render_time >= 0", m["entity_render_time"] >= 0.0)
if os.path.exists(shot):
os.remove(shot)
print()
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)