Files
2026-08-12 04:47:48 -04:00

223 lines
8.2 KiB
Python

# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
# ./blender.bin --background --python tests/python/bl_global_undo.py ..
__all__ = (
"main",
)
import contextlib
import io
import os
import platform
import sys
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from bl_blendfile_utils import TestBlendLibLinkHelper
NO_UNDO_ID_TYPES = {bpy.types.Brush, bpy.types.WindowManager, bpy.types.WorkSpace, bpy.types.Screen}
class IDStaticRepresentation:
"""
A fully Blender-independant representation of a data-block.
"""
def __init__(self, id_data, full=False):
self.id_type = id_data.id_type
self.name = id_data.name
self.library = id_data.library.filepath if id_data.library else ""
self.session_uid = id_data.session_uid
self.pointer = id_data.as_pointer()
self.content = self.extract_content(id_data) if full else {}
@staticmethod
def extract_content(id_data):
"""
Generate a key-value (dict) representation of ID content, using RNA self-introspection mechanism.
"""
# TODO.
return {}
def as_invariant_key(self):
"""Key value that should remain stable and be unique to an ID, even across e.g. blendfile reload."""
return (self.id_type, self.name, self.library)
def as_session_key(self):
"""
Key value that should remain stable and be unique to an ID, during a single session.
Not stable across e.g. blendfile reload.
"""
return self.session_uid
def __eq__(self, other):
if self.id_type != other.id_type:
return False
if self.name != other.name:
return False
if self.library != other.library:
return False
if self.content != other.content:
# Only consider content as different if it is defined in both ID representations.
if self.content and other.content:
return False
return True
def __hash__(self):
return hash(self.as_invariant_key())
class TestGlobalUndo(TestBlendLibLinkHelper):
def __init__(self, args):
super().__init__(args)
def link(self, lib_filepath, collections_names=[]):
operation_name = "link"
with bpy.data.libraries.load(lib_filepath, link=True) as (lib_in, lib_out):
for cn in collections_names:
lib_out.collections.append(lib_in.collections[cn])
@staticmethod
def get_all_ids():
return {IDStaticRepresentation(id_data) for id_data in bpy.data.all_ids}
def test_global_undo_empty(self):
self.reset_blender()
all_ids = self.get_all_ids()
# NOTE: The two undo pushes are necessary to be able to undo, since the first undo push creates the
# initial state for memfile undo (it is not initialized by default in background mode).
bpy.ops.ed.undo_push()
bpy.ops.ed.undo_push()
bpy.ops.ed.undo_push()
# Here all IDs remain present at all time, so their address themselves should remain unchanged.
bpy.ops.ed.undo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
bpy.ops.ed.undo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
bpy.ops.ed.redo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
bpy.ops.ed.redo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
bpy.ops.ed.undo_history(item=0)
self.assertEqual({id_data.pointer: id_data for id_data in all_ids},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
bpy.ops.ed.undo_history(item=2)
self.assertEqual({id_data.pointer: id_data for id_data in all_ids},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
def test_global_undo_local_data(self):
self.reset_blender()
all_ids_init = self.get_all_ids()
# NOTE: The two undo pushes are necessary to be able to undo, since the first undo push creates the
# initial state for memfile undo (it is not initialized by default in background mode).
bpy.ops.ed.undo_push()
self.gen_library_data_()
object_location_init = tuple(bpy.data.objects[0].location)
bpy.ops.ed.undo_push()
bpy.data.objects[0].location.x += 1.0
object_location_final = tuple(bpy.data.objects[0].location)
bpy.ops.ed.undo_push()
all_ids_final_before_undoredo = self.get_all_ids()
bpy.ops.ed.undo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids_final_before_undoredo},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
self.assertEqual(tuple(bpy.data.objects[0].location), object_location_init)
bpy.ops.ed.redo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids_final_before_undoredo},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
self.assertEqual(tuple(bpy.data.objects[0].location), object_location_final)
bpy.ops.ed.undo()
bpy.ops.ed.undo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids_init},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
# Undoing removed all IDs generated by the call to `gen_library_data_()`, so when redoing,
# their invariant and session identifiers whould rmain unchanged,
# but their pointer address should be different (as redo will re-allocate them).
bpy.ops.ed.redo()
all_ids_final_after_undoredo = self.get_all_ids()
self.assertNotEqual({id_data.pointer: id_data for id_data in all_ids_final_before_undoredo},
{id_data.pointer: id_data for id_data in all_ids_final_after_undoredo})
self.assertEqual({id_data.as_session_key(): id_data for id_data in all_ids_final_before_undoredo},
{id_data.as_session_key(): id_data for id_data in all_ids_final_after_undoredo})
self.assertEqual({id_data.as_invariant_key(): id_data for id_data in all_ids_final_before_undoredo},
{id_data.as_invariant_key(): id_data for id_data in all_ids_final_after_undoredo})
self.assertEqual(tuple(bpy.data.objects[0].location), object_location_init)
# Second redo should re-use all exisitng IDs, even though some of their data is modified.
bpy.ops.ed.redo()
self.assertEqual({id_data.pointer: id_data for id_data in all_ids_final_after_undoredo},
{id_data.pointer: id_data for id_data in self.get_all_ids()})
self.assertEqual(tuple(bpy.data.objects[0].location), object_location_final)
# TODO: add undo testing of linking, making local, linking as packed data, making liboverrides, etc.
TESTS = (
TestGlobalUndo,
)
def argparse_create():
import argparse
# When --help or no args are given, print this help
description = (
"Test basic global (aka memfile) undo and redo component.\n"
"\n"
"IMPORTANT: This test should be run in Blender with `--log \"*undo*\" --log-level debug`")
parser = argparse.ArgumentParser(description=description)
parser.add_argument(
"--src-test-dir",
dest="src_test_dir",
default=".",
help="Where to find test/data root directory",
required=True,
)
parser.add_argument(
"--output-dir",
dest="output_dir",
help="Where to output temp saved blendfiles",
required=True,
)
return parser
def main():
args = argparse_create().parse_args()
for Test in TESTS:
Test(args).run_all_tests()
if __name__ == '__main__':
import sys
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
main()