Skip to content

Commit 07dfd70

Browse files
committed
tests: Text-node render validation harness
Headless llvmpipe golden+value harness for the Gfx::Text process: grabs the untouched-defaults frame (hard off-screen-default visibility assertion — guards the default-position fix), then live-edits text/font/size/position/scale/ color over OSC /script and asserts pixel VALUES per case (coverage, bbox ordering across sizes, centroid movement, channel dominance, blank empty string, unicode/CJK/tofu/2000-char) + golden refs (compare.py strict). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
1 parent 74a4669 commit 07dfd70

7 files changed

Lines changed: 531 additions & 0 deletions

File tree

tests/integration/CMakeLists.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,23 @@ set_tests_properties(test_timeline_scenario_ramp PROPERTIES
135135
TIMEOUT 600
136136
RUN_SERIAL TRUE
137137
ENVIRONMENT "OSSIA_SCORE=${SCORE_ROOT_BINARY_DIR}/ossia-score")
138+
139+
# TEXT node render validation (depends on the scene render path). One
140+
# headless llvmpipe app run: grabs the
141+
# Gfx::Text process with DEFAULT controls (off-screen-default visibility
142+
# regression), then live-edits text/font/size/position/scale/color over OSC
143+
# /script and asserts pixel VALUES per case (analyze.py: coverage, bbox
144+
# ordering across sizes, centroid movement, channel dominance, blank empty
145+
# string, unicode/CJK/tofu/2000-char edge cases) + golden refs for the
146+
# stable subset (text-render/refs/llvmpipe, compare.py strict; regenerate
147+
# with text-render.sh --update-refs — double-render self-consistency).
148+
# Self-serializes on /tmp/score-harness.lock; SKIPs (77) without deps.
149+
add_test(NAME test_text_render
150+
COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/text-render/text-render.sh"
151+
WORKING_DIRECTORY "${SCORE_ROOT_BINARY_DIR}")
152+
set_tests_properties(test_text_render PROPERTIES
153+
SKIP_RETURN_CODE 77
154+
TIMEOUT 900
155+
RUN_SERIAL TRUE
156+
ENVIRONMENT "OSSIA_SCORE=${SCORE_ROOT_BINARY_DIR}/ossia-score")
157+
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#!/usr/bin/env python3
2+
"""Pixel-value assertions for the TEXT node render sweep.
3+
4+
analyze.py <grab-dir>
5+
6+
<grab-dir> holds one 1280x720 PNG per case, produced by text-render.sh from
7+
a single live app run. Background is pure black; text is drawn white (or the
8+
case's color) so "text pixels" = pixels with luma > LUMA_T.
9+
10+
Assertions are VALUES, not just non-blankness:
11+
default untouched controls: currently XFAIL — known off-screen-default
12+
bug (default position (0.5,0.5) pushes the text above the
13+
screen; see the check body for file:line)
14+
default-pos0 position (0,0) with default string/font/size: MUST be visible
15+
base solid coverage; reference bbox/centroid for the movement cases
16+
sizes coverage(96pt) > coverage(48pt) > coverage(24pt), bbox grows
17+
font DejaVu Sans Mono vs Noto Sans rasterize differently
18+
color red case: R channel dominates G/B on text pixels
19+
position pos-right centroid right of pos-left; pos-down moves centroid
20+
DOWN in image coords (clip -y = screen down); y-cases share x
21+
scale 0.5 scale halves the bbox (within tolerance) + less coverage
22+
unicode/cjk render with real coverage (accented Latin + CJK glyphs)
23+
longstr ~2000 chars: more coverage than base, no crash
24+
tofu unassigned codepoints: no crash; frame present (tofu boxes or
25+
blank both acceptable)
26+
empty clean blank frame
27+
base-again bit-near-identical to base (recovery after edge cases +
28+
in-run determinism)
29+
30+
Exit 0 iff all assertions pass; prints one line per check.
31+
"""
32+
import sys
33+
import pathlib
34+
35+
import numpy as np
36+
from PIL import Image
37+
38+
LUMA_T = 40.0 # text-pixel threshold on BT.601 luma (bg is pure black)
39+
40+
failures = []
41+
checks = 0
42+
43+
44+
def check(name, cond, detail=""):
45+
global checks
46+
checks += 1
47+
status = "ok " if cond else "FAIL"
48+
print(f" [{status}] {name} {detail}")
49+
if not cond:
50+
failures.append(name)
51+
52+
53+
def load(d, name):
54+
p = d / f"{name}.png"
55+
if not p.is_file():
56+
return None
57+
return np.asarray(Image.open(p).convert("RGB"), dtype=np.float64)
58+
59+
60+
def luma(img):
61+
return img @ np.array([0.299, 0.587, 0.114])
62+
63+
64+
def stats(img):
65+
"""(count, bbox(x0,y0,x1,y1), centroid(cx,cy)) of text pixels."""
66+
mask = luma(img) > LUMA_T
67+
n = int(mask.sum())
68+
if n == 0:
69+
return 0, None, None
70+
ys, xs = np.nonzero(mask)
71+
return n, (xs.min(), ys.min(), xs.max(), ys.max()), (xs.mean(), ys.mean())
72+
73+
74+
def main():
75+
d = pathlib.Path(sys.argv[1])
76+
imgs = {}
77+
for name in ["default", "default-pos0", "base", "base-again", "size-small",
78+
"size-large", "font-sans", "color-red", "pos-left",
79+
"pos-right", "pos-down", "scale-half", "unicode", "cjk",
80+
"longstr", "tofu", "empty"]:
81+
imgs[name] = load(d, name)
82+
check(f"grab:{name}", imgs[name] is not None)
83+
84+
def S(n):
85+
return stats(imgs[n]) if imgs[n] is not None else (0, None, None)
86+
87+
# --- default visibility (the off-screen-default regression check) -----
88+
# Hard assertion: an untouched Text process MUST render visible text. This
89+
# guards the fix for the off-screen-default bug (the UBO default position
90+
# was {0.5,0.5} in Gfx/Graph/TextNode.hpp, shifting the quad +0.5 clip
91+
# up/right so the default text landed ~180px above the visible area; now
92+
# {0,0} = screen centre). Regressing it fails the suite.
93+
n_def, bb_def, _ = S("default")
94+
check("default-visible", n_def > 200,
95+
f"count={n_def} bbox={bb_def} (untouched Text must render on-screen)")
96+
97+
# default-pos0 sets ONLY position=(0,0), keeping the default string/font/
98+
# size: it must be visible, proving the default text/font/size DO
99+
# propagate and the position default alone causes the blank frame.
100+
n_dp, bb_dp, _ = S("default-pos0")
101+
check("default-pos0-visible", n_dp > 200,
102+
f"count={n_dp} bbox={bb_dp} (default text, repositioned on screen)")
103+
104+
# --- base reference ---------------------------------------------------
105+
n_base, bb_base, c_base = S("base")
106+
check("base-coverage", n_base > 500, f"count={n_base} bbox={bb_base}")
107+
108+
# --- point size ordering ---------------------------------------------
109+
n_s, bb_s, _ = S("size-small")
110+
n_l, bb_l, _ = S("size-large")
111+
check("size-count-order", n_s > 0 and n_l > n_base > n_s,
112+
f"24pt={n_s} 48pt={n_base} 96pt={n_l}")
113+
if bb_s and bb_l:
114+
w_s, w_l = bb_s[2] - bb_s[0], bb_l[2] - bb_l[0]
115+
h_s, h_l = bb_s[3] - bb_s[1], bb_l[3] - bb_l[1]
116+
check("size-bbox-grows", w_l > w_s * 1.5 and h_l > h_s * 1.5,
117+
f"w:{w_s}->{w_l} h:{h_s}->{h_l}")
118+
else:
119+
check("size-bbox-grows", False, "missing bbox")
120+
121+
# --- font family actually changes the raster -------------------------
122+
if imgs["font-sans"] is not None and imgs["base"] is not None:
123+
n_f, _, _ = S("font-sans")
124+
diff = int((np.abs(luma(imgs["font-sans"]) - luma(imgs["base"])) > LUMA_T).sum())
125+
check("font-differs", n_f > 500 and diff > 200,
126+
f"sans-count={n_f} differing-px={diff}")
127+
else:
128+
check("font-differs", False, "missing grabs")
129+
130+
# --- color control ----------------------------------------------------
131+
if imgs["color-red"] is not None:
132+
img = imgs["color-red"]
133+
mask = img[:, :, 0] > 128 # solidly-red pixels (avoid AA fringe)
134+
n_red = int(mask.sum())
135+
if n_red > 0:
136+
mr = img[:, :, 0][mask].mean()
137+
mg = img[:, :, 1][mask].mean()
138+
mb = img[:, :, 2][mask].mean()
139+
check("color-red-dominant", mr > 200 and mg < 60 and mb < 60,
140+
f"count={n_red} RGB=({mr:.0f},{mg:.0f},{mb:.0f})")
141+
else:
142+
check("color-red-dominant", False, "no red pixels")
143+
# white base must NOT pass the red predicate (sanity of the check)
144+
bimg = imgs["base"]
145+
bmask = bimg[:, :, 0] > 128
146+
check("color-base-is-white",
147+
bmask.sum() > 0 and bimg[:, :, 1][bmask].mean() > 200,
148+
f"base green-mean={bimg[:, :, 1][bmask].mean():.0f}")
149+
else:
150+
check("color-red-dominant", False, "missing grab")
151+
152+
# --- position control -------------------------------------------------
153+
n_pl, _, c_pl = S("pos-left")
154+
n_pr, _, c_pr = S("pos-right")
155+
n_pd, _, c_pd = S("pos-down")
156+
if c_pl and c_pr and c_base:
157+
# clip +x = screen right: right centroid must sit ~640px right of left
158+
# (1.0 clip delta = full 1280px width => 0.5+0.5 => 640px), and both
159+
# straddle base. Allow generous slack for clipped glyph edges.
160+
dx = c_pr[0] - c_pl[0]
161+
check("pos-x-order", c_pl[0] < c_base[0] < c_pr[0] and dx > 300,
162+
f"cx left={c_pl[0]:.0f} base={c_base[0]:.0f} right={c_pr[0]:.0f} dx={dx:.0f}")
163+
check("pos-x-keeps-y",
164+
abs(c_pl[1] - c_base[1]) < 40 and abs(c_pr[1] - c_base[1]) < 40,
165+
f"cy l/b/r={c_pl[1]:.0f}/{c_base[1]:.0f}/{c_pr[1]:.0f}")
166+
else:
167+
check("pos-x-order", False, f"counts l/r/base={n_pl}/{n_pr}/{n_base}")
168+
if c_pd and c_base:
169+
# clip -y = screen DOWN (image row index grows): centroid must move
170+
# down by ~0.25*720=180px; keep x roughly unchanged.
171+
dy = c_pd[1] - c_base[1]
172+
check("pos-y-down", dy > 100, f"cy base={c_base[1]:.0f} down={c_pd[1]:.0f} dy={dy:.0f}")
173+
check("pos-y-keeps-x", abs(c_pd[0] - c_base[0]) < 40,
174+
f"cx base={c_base[0]:.0f} down={c_pd[0]:.0f}")
175+
else:
176+
check("pos-y-down", False, f"count={n_pd}")
177+
178+
# --- scale -------------------------------------------------------------
179+
n_sc, bb_sc, _ = S("scale-half")
180+
if bb_sc and bb_base:
181+
w_b, w_h = bb_base[2] - bb_base[0], bb_sc[2] - bb_sc[0]
182+
ratio = w_h / max(w_b, 1)
183+
check("scale-half-bbox", 0.3 < ratio < 0.7 and n_sc < n_base,
184+
f"w {w_b}->{w_h} ratio={ratio:.2f} count {n_base}->{n_sc}")
185+
else:
186+
check("scale-half-bbox", False, f"count={n_sc}")
187+
188+
# --- unicode / cjk -----------------------------------------------------
189+
n_u, _, _ = S("unicode")
190+
check("unicode-coverage", n_u > 500, f"count={n_u}")
191+
n_c, _, _ = S("cjk")
192+
check("cjk-coverage", n_c > 500, f"count={n_c}")
193+
194+
# --- very long string --------------------------------------------------
195+
n_lo, _, _ = S("longstr")
196+
check("longstr-coverage", n_lo > n_base, f"count={n_lo} (base={n_base})")
197+
198+
# --- tofu: frame present, app alive (recovery asserted by base-again) --
199+
n_t, _, _ = S("tofu")
200+
check("tofu-rendered-frame", imgs["tofu"] is not None, f"count={n_t} (blank or boxes both ok)")
201+
202+
# --- empty string: clean blank ----------------------------------------
203+
n_e, _, _ = S("empty")
204+
check("empty-blank", imgs["empty"] is not None and n_e < 50, f"count={n_e}")
205+
206+
# --- recovery + in-run determinism ------------------------------------
207+
if imgs["base-again"] is not None and imgs["base"] is not None:
208+
d_max = float(np.abs(imgs["base-again"] - imgs["base"]).max())
209+
n_a, _, _ = S("base-again")
210+
check("base-recovery", d_max <= 4 and n_a > 500,
211+
f"max_abs={d_max} count={n_a} (vs base={n_base})")
212+
else:
213+
check("base-recovery", False, "missing grabs")
214+
215+
ok = not failures
216+
print(f"analyze: {checks - len(failures)}/{checks} ok"
217+
+ ("" if ok else f" FAILURES: {', '.join(failures)}"))
218+
return 0 if ok else 1
219+
220+
221+
if __name__ == "__main__":
222+
sys.exit(main())
17.7 KB
Loading
27.9 KB
Loading
22.3 KB
Loading
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// TEXT node render validation scene.
2+
//
3+
// Builds: Window device + one Gfx::Text process wired to Window:/ and
4+
// defines setCase(name) mutators that text-render.sh triggers over OSC
5+
// /script between grabs. All /script evaluations share the persistent
6+
// console QJSEngine (JS::ApplicationPlugin::m_consoleEngine) so the `var`
7+
// globals below persist across sends — same convention as
8+
// live-edit/common.js and timeline-scenarios/scenario-ramp.js.
9+
//
10+
// Inlet map of Gfx::Text::Model (Gfx/Text/Process.cpp):
11+
// 0 Text (LineEdit) 4 Position (XYSlider, domain [-5,5]^2)
12+
// 1 Font (LineEdit) 5 Scale X (FloatSlider)
13+
// 2 Point size (Float) 6 Scale Y (FloatSlider)
14+
// 3 Opacity (Float) 7 Color (HSVSlider, rgba vec4)
15+
//
16+
// The FIRST grab ("default") happens before any setCase call: it validates
17+
// that the DEFAULT controls ("Greetings from Oscar !", Monospace 28pt,
18+
// white, position (0.5,0.5), scale 1) produce VISIBLE text — the
19+
// off-screen-by-default regression check.
20+
//
21+
// `var` only — QML scopes const/let inside eval() (see live-edit/common.js).
22+
23+
var OUT_DIR = "/tmp/text-render";
24+
var UUID_TEXT = "88bd9718-2a36-42ba-8eab-da5f84e3978e"; // Gfx::Text::Model
25+
var UUID_WINDOW = "5a181207-7d40-4ad8-814e-879fcdf8cc31"; // Window device
26+
var FLICKS_PER_MS = 705600;
27+
28+
function llog(m) { console.log("[text-render] " + m); }
29+
30+
Score.createDevice("Window", UUID_WINDOW, {});
31+
var s = Score.find("Scenario.1");
32+
if (s) Score.remove(s);
33+
var g_root = Score.rootInterval();
34+
// Long enough that playback never ends during the sweep.
35+
Score.setIntervalDuration(g_root, 600000 * FLICKS_PER_MS);
36+
37+
var g_text = Score.createProcess(g_root, UUID_TEXT, "");
38+
if (!g_text) llog("SCENARIO-ERROR: createProcess(Text) returned null");
39+
else Score.setAddress(Score.outlet(g_text, 0), "Window:/");
40+
41+
function inl(i) { return Score.inlet(g_text, i); }
42+
43+
// Reference settings shared by most cases; each case starts from this and
44+
// overrides one dimension, so every case is self-contained (order-safe).
45+
function applyBase() {
46+
Score.setValue(inl(0), "OSSIA score text");
47+
Score.setValue(inl(1), "DejaVu Sans Mono");
48+
Score.setValue(inl(2), 48.0);
49+
Score.setValue(inl(3), 1.0);
50+
Score.setValue(inl(4), [0.0, 0.0]);
51+
Score.setValue(inl(5), 1.0);
52+
Score.setValue(inl(6), 1.0);
53+
Score.setValue(inl(7), [1.0, 1.0, 1.0, 1.0]);
54+
}
55+
56+
var CASES = {
57+
// "default" is NOT here: it is the untouched initial state.
58+
//
59+
// default-pos0 MUST run first (before applyBase touches the text): it sets
60+
// ONLY the position to (0,0), keeping the process-default string/font/size.
61+
// It isolates the known off-screen-default bug: the Position XYSlider is
62+
// built with the plain ControlInlet ctor (Gfx/Text/Process.cpp:50) so no
63+
// init value is pushed and the UBO default position {0.5,0.5}
64+
// (Gfx/Graph/TextNode.hpp:29) shifts the text ~180px above the screen top.
65+
"default-pos0": function() { Score.setValue(inl(4), [0.0, 0.0]); },
66+
"base": function() { applyBase(); },
67+
"base-again": function() { applyBase(); }, // recovery + in-run determinism
68+
"size-small": function() { applyBase(); Score.setValue(inl(2), 24.0); },
69+
"size-large": function() { applyBase(); Score.setValue(inl(2), 96.0); },
70+
"font-sans": function() { applyBase(); Score.setValue(inl(1), "Noto Sans"); },
71+
"color-red": function() { applyBase(); Score.setValue(inl(7), [1.0, 0.0, 0.0, 1.0]); },
72+
"pos-left": function() { applyBase(); Score.setValue(inl(4), [-0.5, 0.0]); },
73+
"pos-right": function() { applyBase(); Score.setValue(inl(4), [0.5, 0.0]); },
74+
"pos-down": function() { applyBase(); Score.setValue(inl(4), [0.0, -0.5]); },
75+
"scale-half": function() { applyBase(); Score.setValue(inl(5), 0.5); Score.setValue(inl(6), 0.5); },
76+
"unicode": function() { applyBase(); Score.setValue(inl(0), "Héllö wörld ÀÉÎÕÜ çæœß"); },
77+
"cjk": function() { applyBase(); Score.setValue(inl(1), "Noto Sans CJK JP");
78+
Score.setValue(inl(0), "日本語のテキスト"); },
79+
// Codepoints no font provides (unassigned): must not crash; tofu or blank ok.
80+
"tofu": function() { applyBase(); Score.setValue(inl(0), "\u0378\u0379\u0380\uFFFF"); },
81+
"empty": function() { applyBase(); Score.setValue(inl(0), ""); },
82+
"longstr": function() {
83+
applyBase();
84+
Score.setValue(inl(2), 28.0);
85+
var s = "";
86+
for (var i = 0; i < 40; i++)
87+
s += "The quick brown fox jumps over the lazy dog " + i + " — ";
88+
Score.setValue(inl(0), s); // ~2000 chars
89+
}
90+
};
91+
92+
function setCase(name) {
93+
try {
94+
var f = CASES[name];
95+
if (!f) { llog("CASE-ERROR: unknown case " + name); return; }
96+
f();
97+
llog("case " + name + " applied");
98+
} catch (e) {
99+
llog("CASE-ERROR " + name + ": " + e);
100+
}
101+
}
102+
103+
// Called by text-render.sh right before /exit: a just-saved (clean) document
104+
// skips the "save changes?" QMessageBox that aborts under the offscreen QPA.
105+
function finalizeRun() {
106+
try { Score.saveAs(OUT_DIR + "/text-final.score"); llog("final saved"); }
107+
catch (e) { llog("FINAL-ERROR: " + e); }
108+
}
109+
110+
Score.saveAs(OUT_DIR + "/text-init.score"); // readiness marker
111+
llog("ready");

0 commit comments

Comments
 (0)