OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_document.py

184 lines · python ref:
"""
Tests for the `.ifsjson` loader.

Every failure mode here is silent: a document that loads but maps parameters,
weights or the camera wrongly renders a fractal, just not the right one. So the
tests target the specific confusions the format invites, and the shipped templates
are loaded as a corpus so a real document cannot quietly stop working.

Run: python3 -m unittest discover -s blender
"""

import os
import unittest

import ifs_document
from ifs_codegen import load_library
from ifs_document import DocumentError, from_dict

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES = os.path.join(REPO, "WpfDisplay", "Library", "Templates")
LIBRARY = load_library(os.path.join(REPO, "Transforms"),
                       os.path.join(REPO, "WpfDisplay", "Library", "Includes"))


def doc(**overrides):
    base = {
        "Iterators": [
            {"Transform": {"Item1": "Affine", "Item2": "1.1"},
             "Vec3Params": {"Translate": {"X": 1.0, "Y": 2.0, "Z": 3.0}},
             "StartWeight": 1.0, "BaseWeight": 1.0},
        ],
        "Camera": {"Position": {"X": 0.0, "Y": 0.0, "Z": -2.5}},
        "ImageResolution": "800, 600",
    }
    base.update(overrides)
    return from_dict(base, LIBRARY)


class ParameterMapping(unittest.TestCase):
    def test_parameters_follow_the_plugins_order_not_the_documents(self):
        # The kernel indexes real_params[index + N] by the PLUGIN's declaration
        # order. Affine declares Translate, Scale, Rotate; a document listing them
        # in another order must still map positionally.
        loaded = from_dict({
            "Iterators": [{
                "Transform": {"Item1": "Affine", "Item2": "1.1"},
                "Vec3Params": {
                    "Rotate": {"X": 9.0, "Y": 9.0, "Z": 9.0},
                    "Translate": {"X": 1.0, "Y": 1.0, "Z": 1.0},
                    "Scale": {"X": 5.0, "Y": 5.0, "Z": 5.0},
                },
                "StartWeight": 1.0}],
        }, LIBRARY)

        it = loaded.iterators[0]
        self.assertEqual((1.0, 1.0, 1.0), it.vec3_params[0])  # Translate
        self.assertEqual((5.0, 5.0, 5.0), it.vec3_params[1])  # Scale
        self.assertEqual((9.0, 9.0, 9.0), it.vec3_params[2])  # Rotate

    def test_a_missing_parameter_falls_back_to_the_plugin_default(self):
        # Affine reads @Scale unconditionally; defaulting it to the plugin's 1 1 1
        # is what stops an older document from rendering nothing.
        it = doc().iterators[0]
        self.assertEqual(3, len(it.vec3_params))
        self.assertEqual((1.0, 1.0, 1.0), it.vec3_params[1])

    def test_parameter_indices_accumulate_across_iterators(self):
        loaded = from_dict({
            "Iterators": [
                {"Transform": {"Item1": "Affine", "Item2": "1.1"}, "StartWeight": 1.0},
                {"Transform": {"Item1": "Affine", "Item2": "1.1"}, "StartWeight": 1.0},
            ]}, LIBRARY)

        self.assertEqual([0, 3], [i.vec3_params_index for i in loaded.iterators])

    def test_an_unknown_transform_is_reported_not_ignored(self):
        with self.assertRaises(DocumentError) as caught:
            from_dict({"Iterators": [
                {"Transform": {"Item1": "NoSuchTransform", "Item2": "1"},
                 "StartWeight": 1.0}]}, LIBRARY)
        self.assertIn("NoSuchTransform", str(caught.exception))


class WeightsAndXaos(unittest.TestCase):
    def test_start_weight_drives_reset_not_the_xaos_rows(self):
        # StartWeight -> reset_prob/reset_alias; BaseWeight modulates xaos. Mixing
        # them up renders a differently-weighted fractal, not an error.
        loaded = from_dict({"Iterators": [
            {"Transform": {"Item1": "Affine", "Item2": "1.1"},
             "StartWeight": 3.0, "BaseWeight": 1.0},
            {"Transform": {"Item1": "Affine", "Item2": "1.1"},
             "StartWeight": 1.0, "BaseWeight": 7.0},
        ]}, LIBRARY)

        self.assertEqual([1.0, 7.0], [i.base_weight for i in loaded.iterators])
        # A 3:1 start split cannot leave both reset_probs at 1.
        self.assertNotEqual(loaded.iterators[0].reset_prob,
                            loaded.iterators[1].reset_prob)

    def test_all_zero_start_weights_is_rejected(self):
        # The engine returns early and renders nothing; failing loudly is better.
        with self.assertRaises(DocumentError):
            from_dict({"Iterators": [
                {"Transform": {"Item1": "Affine", "Item2": "1.1"}, "StartWeight": 0.0}
            ]}, LIBRARY)

    def test_xaos_is_read_as_source_by_destination(self):
        loaded = from_dict({
            "Iterators": [
                {"Transform": {"Item1": "Affine", "Item2": "1.1"}, "StartWeight": 1.0},
                {"Transform": {"Item1": "Affine", "Item2": "1.1"}, "StartWeight": 1.0},
            ],
            "xaos": [[0.0, 2.0], [3.0, 0.0]],
        }, LIBRARY)

        self.assertEqual({1: 2.0}, loaded.iterators[0].weight_to)
        self.assertEqual({0: 3.0}, loaded.iterators[1].weight_to)

    def test_a_missing_xaos_means_fully_connected(self):
        loaded = doc()
        self.assertEqual({0: 1.0}, loaded.iterators[0].weight_to)

    def test_a_mis_sized_xaos_is_rejected(self):
        with self.assertRaises(DocumentError):
            from_dict({
                "Iterators": [
                    {"Transform": {"Item1": "Affine", "Item2": "1.1"}, "StartWeight": 1.0}],
                "xaos": [[1.0, 0.0], [0.0, 1.0]],
            }, LIBRARY)


class CameraAndMetadata(unittest.TestCase):
    def test_identity_orientation_looks_along_positive_z(self):
        camera = doc().camera
        self.assertAlmostEqual(1.0, camera.forward[2], places=6)
        self.assertAlmostEqual(0.0, camera.forward[0], places=6)

    def test_orientation_rotates_the_direction_vectors(self):
        # 180 degrees about Y: forward flips to -Z.
        loaded = doc(Camera={"Position": {"X": 0.0, "Y": 0.0, "Z": 0.0},
                             "Orientation": {"X": 0.0, "Y": 1.0, "Z": 0.0, "W": 0.0}})
        self.assertAlmostEqual(-1.0, loaded.camera.forward[2], places=6)

    def test_resolution_parses_the_serialised_string(self):
        self.assertEqual((800, 600), (doc().width, doc().height))

    def test_legacy_palette_key_is_accepted(self):
        # Documents predating the palette-manager merge store "Colors", and every
        # shipped template still does.
        loaded = doc(Palette={"Colors": [{"X": 1.0, "Y": 0.0, "Z": 0.0, "W": 1.0}]})
        self.assertEqual([(1.0, 0.0, 0.0, 1.0)], loaded.palette_keys)

    def test_palette_is_sampled_into_the_gradient_the_kernel_indexes(self):
        loaded = doc(Palette={"Colors": [{"X": 1.0, "Y": 0.0, "Z": 0.0, "W": 1.0},
                                         {"X": 0.0, "Y": 0.0, "Z": 1.0, "W": 1.0}]})
        ramp = loaded.palette(150)

        self.assertEqual(150, len(ramp))
        self.assertAlmostEqual(1.0, ramp[0][0], places=5)
        self.assertAlmostEqual(1.0, ramp[-1][2], places=5)


class ShippedTemplates(unittest.TestCase):
    """The real corpus. A new plugin or a format change must not break these."""

    def test_every_template_loads(self):
        if not os.path.isdir(TEMPLATES):
            raise unittest.SkipTest("templates not found")
        names = sorted(n for n in os.listdir(TEMPLATES) if n.endswith(".ifsjson"))
        self.assertGreaterEqual(len(names), 5)

        for name in names:
            with self.subTest(template=name):
                loaded = ifs_document.load(os.path.join(TEMPLATES, name), LIBRARY)
                self.assertGreater(len(loaded.iterators), 0)
                self.assertGreater(loaded.width, 0)
                for it in loaded.iterators:
                    self.assertIn(it.tf_id, range(len(LIBRARY.plugins)))
                    self.assertGreaterEqual(it.reset_prob, 0.0)


if __name__ == "__main__":
    unittest.main()