"""
Tests for the `.ifstf` -> GLSL codegen.
The cases below are not arbitrary: each pins one rule of
`TransformPlugin.FromString` that a reasonable "cleanup" of the Python would
break. Getting any of them wrong produces a kernel that COMPILES and renders the
wrong fractal, which is the failure mode worth defending against.
Equivalence with the C# parser was established separately by dumping all 46
plugins from the real `TransformPlugin.FromString` and diffing name, version,
rewritten source, and both parameter maps — 0 mismatches. That comparison needs
the .NET SDK, so it is not part of this suite; these tests pin the same behaviour
in a form that runs anywhere.
Run: python3 -m unittest discover -s blender
"""
import os
import unittest
from ifs_codegen import (
PluginError,
generate_transforms_block,
load_library,
parse_plugin,
)
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")
HEADER = "@Name: T\n@Version: 1\n"
class ParsingRules(unittest.TestCase):
def test_longest_parameter_name_is_substituted_first(self):
# @A must not clobber the prefix of @AB. The C# sorts by descending name
# length precisely for this; substituting in declaration order corrupts it.
p = parse_plugin(HEADER + "@A: 1.0\n@AB: 2.0\np = @AB + @A;\n")
self.assertEqual(
"p = (real_params[iter.real_params_index + 1]) "
"+ (real_params[iter.real_params_index + 0]);",
p.source_code,
)
def test_parameter_index_is_declaration_order_not_substitution_order(self):
# Substitution runs longest-first, but the INDEX is the order the params
# were declared. Conflating the two silently swaps parameter values.
p = parse_plugin(HEADER + "@Short: 1.0\n@AMuchLongerName: 2.0\nx = @Short;\n")
self.assertEqual("x = (real_params[iter.real_params_index + 0]);", p.source_code)
def test_real_and_vec3_have_separate_index_spaces(self):
p = parse_plugin(
HEADER + "@R1: 1.0\n@V1: 1 2 3\n@R2: 2.0\n@V2: 4 5 6\nx = @R2 + @V2;\n"
)
self.assertIn("real_params[iter.real_params_index + 1]", p.source_code)
self.assertIn("vec3_params[iter.vec3_params_index + 1]", p.source_code)
self.assertEqual(["R1", "R2"], list(p.real_params))
self.assertEqual(["V1", "V2"], list(p.vec3_params))
def test_vec3_default_needs_exactly_two_spaces(self):
p = parse_plugin(HEADER + "@V: 1 2 3\n")
self.assertEqual({"V": (1.0, 2.0, 3.0)}, p.vec3_params)
def test_unparseable_default_is_dropped_and_left_unsubstituted(self):
# The C# silently ignores a default that is neither a real nor a vec3, so
# the reference survives into the GLSL and fails at COMPILE time - which is
# loud, and better than inventing an index for it.
p = parse_plugin(HEADER + "@Weird: not_a_number\nx = @Weird;\n")
self.assertEqual({}, p.real_params)
self.assertEqual({}, p.vec3_params)
self.assertEqual("x = @Weird;", p.source_code)
def test_duplicate_field_keeps_the_first_and_removes_every_line(self):
# DistinctBy keeps the first value; removal is by string equality, so both
# occurrences leave the body.
p = parse_plugin(HEADER + "@A: 1.0\n@A: 9.0\nx = @A;\n")
self.assertEqual({"A": 1.0}, p.real_params)
self.assertEqual("x = (real_params[iter.real_params_index + 0]);", p.source_code)
def test_lines_are_trimmed_and_blank_lines_dropped(self):
# Split drops empty entries BEFORE trimming, so a whitespace-only line
# survives as an empty line while a truly empty one vanishes.
p = parse_plugin(HEADER + " indented();\n\n \nx();\n")
self.assertEqual("indented();\n\nx();", p.source_code)
def test_missing_required_fields_is_an_error(self):
with self.assertRaises(PluginError):
parse_plugin("@Name: T\nx();\n")
with self.assertRaises(PluginError):
parse_plugin("@Version: 1\nx();\n")
def test_metadata_fields_are_not_parameters(self):
p = parse_plugin(
"@Name: T\n@Version: 1\n@Description: d\n@Tags: Alpha, BETA \n"
"@Reference: http://example.com\n@Use: helper.glsl\n@Use: other.glsl\nx();\n"
)
self.assertEqual({}, p.real_params)
self.assertEqual(["alpha", "beta"], p.tags)
self.assertEqual(["helper.glsl", "other.glsl"], p.include_uses)
self.assertEqual("x();", p.source_code)
def test_default_value_may_carry_trailing_properties(self):
# "@P: 0.5, min 0, max 1" - only the part before the first comma is the value.
p = parse_plugin(HEADER + "@P: 0.5, min 0, max 1\n")
self.assertEqual({"P": 0.5}, p.real_params)
class KernelAssembly(unittest.TestCase):
def test_branch_chain_matches_the_engines_format(self):
# Byte-for-byte the shape of RendererGL.InitComputeProgram's interpolated
# string, leading and trailing newlines included.
a = parse_plugin(HEADER + "a();\n")
b = parse_plugin(HEADER + "b();\n")
self.assertEqual(
"\nif (iter.tfId == 0)\n{\na();\n}\n"
"\nif (iter.tfId == 1)\n{\nb();\n}\n",
generate_transforms_block([a, b]),
)
class RealLibrary(unittest.TestCase):
"""Against the shipped plugin library, so a new plugin cannot break the port silently."""
@classmethod
def setUpClass(cls):
if not os.path.isdir(TRANSFORMS):
raise unittest.SkipTest("Transforms/ not found")
cls.library = load_library(TRANSFORMS, INCLUDES)
def test_every_shipped_plugin_parses(self):
self.assertEqual(46, len(self.library.plugins))
self.assertEqual([], self.library.skipped)
def test_parameter_totals(self):
self.assertEqual(89, sum(len(p.real_params) for p in self.library.plugins))
self.assertEqual(19, sum(len(p.vec3_params) for p in self.library.plugins))
def test_no_parameter_reference_is_left_unsubstituted(self):
# An unsubstituted @Name means a param was dropped, and the kernel would
# fail to compile. Catch it here, per-plugin, rather than in a 1000-line file.
for plugin in self.library.plugins:
for line in plugin.source_code.splitlines():
code = line.split("//")[0]
self.assertNotIn("@", code, f"{plugin.name}: unsubstituted param in {line!r}")
def test_transform_names_are_unique(self):
# Documents reference transforms by NAME, so a duplicate would make loading
# ambiguous and silently pick one.
names = [p.name for p in self.library.plugins]
self.assertEqual(len(names), len(set(names)))
def test_tf_ids_are_dense_and_stable(self):
ids = self.library.tf_ids
self.assertEqual(list(range(len(self.library.plugins))), sorted(ids.values()))
self.assertEqual(ids, load_library(TRANSFORMS, INCLUDES).tf_ids)
def test_generated_block_contains_one_branch_per_plugin(self):
block = generate_transforms_block(self.library.plugins)
for index, plugin in enumerate(self.library.plugins):
self.assertIn(f"if (iter.tfId == {index})", block, plugin.name)
if __name__ == "__main__":
unittest.main()