"""
Tests for the Blender resource-layout rewrite.
The rewrite is a set of regex substitutions over the engine's kernel, which is a
file we do not own — upstream can change it. So the risk is not that a substitution
does the wrong thing, it is that a substitution silently stops matching and leaves
the original construct in place. `rewrite()` reports that in `RewriteReport.missing`,
and these tests assert both halves: every substitution applied, and nothing that
Blender cannot bind is left behind.
The generated kernel COMPILES in Blender 5.2 (`gpu.shader.create_from_info`,
RTX 2070, OpenGL). That check needs a Blender binary so it is not part of this
suite; `test_compiles_in_blender` runs it when BLENDER is set and reports a skip
otherwise rather than passing quietly.
Run: python3 -m unittest discover -s blender
"""
import os
import subprocess
import sys
import tempfile
import unittest
from ifs_codegen import apply_portability_fixes, generate_kernel, load_library
from ifs_layout import LAYOUT, create_info_source, rewrite
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TRANSFORMS = os.path.join(REPO, "Transforms")
INCLUDES = os.path.join(REPO, "WpfDisplay", "Library", "Includes")
TEMPLATE = os.path.join(REPO, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader")
def build() -> tuple[str, str]:
library = load_library(TRANSFORMS, INCLUDES)
with open(TEMPLATE, "r", encoding="utf-8-sig") as handle:
kernel = generate_kernel(handle.read(), library)
kernel, _ = apply_portability_fixes(kernel)
body, typedefs, report = rewrite(kernel)
if not report.ok:
raise AssertionError("rewrite incomplete: " + "; ".join(report.missing))
return body, typedefs
class Rewrite(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not os.path.isfile(TEMPLATE):
raise unittest.SkipTest("kernel template not found")
cls.body, cls.typedefs = build()
def test_every_substitution_applied(self):
# build() raises if any is missing; this pins the count so a NEW construct
# added upstream is noticed rather than quietly unhandled.
library = load_library(TRANSFORMS, INCLUDES)
with open(TEMPLATE, "r", encoding="utf-8-sig") as handle:
kernel = generate_kernel(handle.read(), library)
kernel, _ = apply_portability_fixes(kernel)
_body, _typedefs, report = rewrite(kernel)
self.assertEqual([], report.missing)
self.assertEqual(14, len(report.applied), report.applied)
def test_nothing_blender_cannot_bind_remains(self):
for construct in (
"buffer histogram_buffer", # SSBO: GPUStorageBuf does not exist
"buffer points_buffer",
"layout(std140", # Blender writes its own layout qualifiers
"#version",
"local_size_x",
"state[gid]",
"alias_tables[",
):
self.assertNotIn(construct, self.body, construct)
for line in self.body.splitlines():
self.assertFalse(line.startswith("uniform "),
f"loose uniform survived: {line!r}")
def test_accesses_were_rewritten(self):
self.assertEqual(4, self.body.count("imageAtomicAdd(histogram_img"))
self.assertIn("texelFetch(alias_tables_tex", self.body)
self.assertIn("p = load_state(gid);", self.body)
self.assertIn("store_state(gid, p);", self.body)
def test_typedefs_are_self_contained(self):
# typedef_source is emitted BEFORE compute_source, so anything the structs
# reference must be in there too - the size defines and the shared structs.
for needed in ("MAX_ITERATORS", "MAX_PALETTE_COLORS", "MAX_PARAMS",
"struct camera_params", "struct Iterator", "struct p_state",
"struct FrameParams", "struct SettingsParams"):
self.assertIn(needed, self.typedefs, needed)
# ...and must NOT be duplicated in the body, which would redefine them.
self.assertNotIn("struct camera_params", self.body)
def test_ubo_defines_preserve_the_original_spelling(self):
# The body still says `iterators[i]`; the #define points it at the struct.
for alias in ("#define iterators iterators_buf.data",
"#define palette palette_buf.data",
"#define real_params real_params_buf.data",
"#define vec3_params vec3_params_buf.data",
"#define seed frame.seed"):
self.assertIn(alias, self.body, alias)
def test_image_budget_is_within_blenders_static_limit(self):
# max_images is 8 and a DECLARED-but-unused image still consumes a slot,
# so the budget cannot be reclaimed by leaving one out.
images = [r for r in LAYOUT if r.kind == "image"]
self.assertLessEqual(len(images), 8)
self.assertEqual(list(range(len(images))), sorted(r.slot for r in images))
def test_host_declaration_requests_readable_writable_images(self):
# Blender's default image qualifier is write-only, which makes imageLoad and
# imageAtomicAdd fail to compile. Found by compiling, so pinned here.
source = create_info_source()
for name in ("histogram_img", "state_img"):
line = next(l for l in source.splitlines() if name in l)
self.assertIn("'READ'", line, name)
self.assertIn("'WRITE'", line, name)
def test_host_declaration_names_match_the_glsl(self):
source = create_info_source()
for res in LAYOUT:
expected = res.instance or res.name
self.assertIn(repr(expected), source, expected)
class BlenderCompile(unittest.TestCase):
"""The real gate: Blender's own compiler accepts the reshaped kernel."""
def test_compiles_in_blender(self):
blender = os.environ.get("BLENDER")
if not blender or not os.path.isfile(blender):
print("SKIPPED test_compiles_in_blender: set BLENDER=/path/to/blender",
file=sys.stderr)
return
if not os.path.isfile(TEMPLATE):
raise unittest.SkipTest("kernel template not found")
body, typedefs = build()
with tempfile.TemporaryDirectory() as tmp:
body_path = os.path.join(tmp, "body.glsl")
type_path = os.path.join(tmp, "typedefs.glsl")
script = os.path.join(tmp, "compile.py")
for path, text in ((body_path, body), (type_path, typedefs)):
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
with open(script, "w", encoding="utf-8") as handle:
handle.write(
"import sys, gpu\n"
"gpu.init()\n"
"BODY = open(sys.argv[-2]).read()\n"
"TYPEDEFS = open(sys.argv[-1]).read()\n"
+ create_info_source() + "\n"
"print('COMPILE OK')\n")
result = subprocess.run(
[blender, "-b", "--factory-startup", "-noaudio", "--python", script,
"--", body_path, type_path],
capture_output=True, text=True, timeout=600,
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")})
self.assertIn("COMPILE OK", result.stdout,
(result.stdout + result.stderr)[-4000:])
if __name__ == "__main__":
unittest.main()