OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_codegen.py

174 lines · python ref:
"""
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()