""" Entry point for tests. To run: PYTHONPATH=tests python3.6 tests/parallel_render.py test <path to blender> <path to ffmpeg executable> """ from itertools import product from time import sleep from unittest import mock import logging import os import shutil import struct import subprocess import sys import unittest BLENDER_EXECUTABLE = None LOGGER = logging.getLogger('tests_runtime') class BlenderTest(unittest.TestCase): FFMPEG_EXECUTABLE = None def setUp(self): import bpy bpy.ops.wm.read_factory_settings() self.assertEqual(bpy.ops.preferences.addon_enable(module='parallel_render'), {'FINISHED'}) self.assertEqual(bpy.ops.script.reload(), {'FINISHED'}) # Now that we have coverage enabled reload modules # to go through load/unload functions LOGGER.info('bpy.utls.script_paths: %s', bpy.utils.script_paths()) LOGGER.info('cwd: %s', os.getcwd()) self.bpy = bpy import parallel_render parallel_render.subprocess_stdout = open(os.devnull, 'w') def tearDown(self): import parallel_render parallel_render.subprocess_stdout.close() parallel_render.subprocess_stdout = sys.stdout class MessageChannelTest(BlenderTest): def test_unexpected_end(self): import parallel_render conn = mock.MagicMock() channel = parallel_render.MessageChannel(conn) with self.assertRaises(Exception): channel.recv() conn.recv.side_effect = [struct.pack(channel.MSG_SIZE_FMT, 0), None] self.assertEqual(channel.recv(), None) class TemporaryProjectTest(BlenderTest): @mock.patch('os.path.exists') def test_temporary_project_file(self, exists): import parallel_render exists.return_value = False with self.assertRaises(Exception): with parallel_render.TemporaryProjectCopy() as test: pass class TestingFrameworkTest(unittest.TestCase): @mock.patch.object(logging, 'basicConfig') def test_subprocess_main(self, basicConfig): import parallel_render import coverage environ = { 'COVERAGE_PROCESS_START': 'something', 'PYTHONPATH': 'path', } sys = mock.MagicMock() sys.path = [] sys.argv = ['--', 'render'] with mock.patch.object(os, 'environ', environ): with mock.patch.object(coverage, 'process_startup') as process_startup: with mock.patch.object(parallel_render, 'render') as render: with mock.patch.object(parallel_render, 'sys', sys): parallel_render.main() self.assertTrue(render.called) self.assertTrue(basicConfig.called) self.assertTrue(process_startup.called) class RangesTest(BlenderTest): def test_parts(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.parts = 4 scene.frame_start = 12 scene.frame_end = 134 self.assertEqual( [ (12, 41), (42, 72), (73, 103), (104, 134), ], list(parallel_render.get_ranges_parts(scene)), ) def test_tiny_parts(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.parts = 4 scene.frame_start = 8 scene.frame_end = 11 self.assertEqual( [ (8, 11), ], list(parallel_render.get_ranges_parts(scene)), ) def test_small_parts(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.parts = 4 scene.frame_start = 8 scene.frame_end = 13 self.assertEqual( [ (8, 8), (9, 10), (11, 11), (12, 13), ], list(parallel_render.get_ranges_parts(scene)), ) def test_fixed(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.fixed = 31 scene.frame_start = 12 scene.frame_end = 134 self.assertEqual( [ (12, 43), (44, 75), (76, 107), (108, 134), ], list(parallel_render.get_ranges_fixed(scene)), ) def test_exact_fixed(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.fixed = 31 scene.frame_start = 25 scene.frame_end = 25+30 self.assertEqual( [ (25, 55), ], list(parallel_render.get_ranges_fixed(scene)), ) def test_small_fixed(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.fixed = 31 scene.frame_start = 25 scene.frame_end = 25+31 self.assertEqual( [ (25, 56), ], list(parallel_render.get_ranges_fixed(scene)), ) def test_fixed_9(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.fixed = 9 scene.frame_start = 1 scene.frame_end = 100 self.assertEqual( [ (1, 10), (11, 20), (21, 30), (31, 40), (41, 50), (51, 60), (61, 70), (71, 80), (81, 90), (91, 100), ], list(parallel_render.get_ranges_fixed(scene)), ) def test_small2_fixed(self): import parallel_render scene = mock.MagicMock() scene.parallel_render_panel.fixed = 31 scene.frame_start = 25 scene.frame_end = 25+32 self.assertEqual( [ (25, 56), (57, 57), ], list(parallel_render.get_ranges_fixed(scene)), ) class MockedDrawTest(BlenderTest): def test_parallel_render_invoke(self): import parallel_render context = mock.MagicMock() event = mock.MagicMock() operator = mock.MagicMock() self.assertEqual( parallel_render.ParallelRender.invoke(operator, context, event), context.window_manager.invoke_props_dialog() ) def test_parallel_render_modal_not_started(self): import parallel_render context = mock.MagicMock() event = mock.MagicMock() operator = mock.MagicMock() operator.summary_mutex = None self.assertEqual( {'PASS_THROUGH'}, parallel_render.ParallelRender.modal(operator, context, event) ) def test_parallel_render_modal_running(self): import parallel_render context = mock.MagicMock() event = mock.MagicMock() for event_type in ('ESC', 'TIMER', 'SOMETHING_ELSE'): with self.subTest(event_type=event_type): event.type = event_type operator = mock.MagicMock() self.assertEqual( {'PASS_THROUGH'}, parallel_render.ParallelRender.modal(operator, context, event) ) def test_parallel_render_modal_finished(self): import parallel_render context = mock.MagicMock() event = mock.MagicMock() event.type = 'TIMER' operator = mock.MagicMock() operator.thread.is_alive.side_effect = [False] self.assertEqual( {'FINISHED'}, parallel_render.ParallelRender.modal(operator, context, event) ) def test_parallel_render_panel_draw(self): import parallel_render panel = mock.MagicMock() context = mock.MagicMock() addon_props = context.preferences.addons['parallel_render'].preferences for is_dirty in (True, False): with mock.patch('bpy.data') as data: data.is_dirty = is_dirty context.scene.render.is_movie_format = False panel.ffmpeg_valid = True addon_props.ffmpeg_valid = True parallel_render.ParallelRenderPanel.draw(panel, context) parallel_render.ParallelRender.check(panel, context) parallel_render.ParallelRender.draw(panel, context) parallel_render.ParallelRenderPreferences.draw(panel, context) parallel_render.parallel_render_menu_draw(panel, context) context.scene.render.is_movie_format = True panel.ffmpeg_valid = True addon_props.ffmpeg_valid = True parallel_render.ParallelRenderPanel.draw(panel, context) parallel_render.ParallelRender.draw(panel, context) parallel_render.ParallelRender.check(panel, context) parallel_render.ParallelRenderPreferences.draw(panel, context) parallel_render.parallel_render_menu_draw(panel, context) context.scene.render.is_movie_format = True panel.ffmpeg_valid = False addon_props.ffmpeg_valid = False parallel_render.ParallelRenderPanel.draw(panel, context) parallel_render.ParallelRender.draw(panel, context) parallel_render.ParallelRender.check(panel, context) parallel_render.ParallelRenderPreferences.draw(panel, context) parallel_render.parallel_render_menu_draw(panel, context) context.scene.render.is_movie_format = False panel.ffmpeg_valid = False addon_props.ffmpeg_valid = False parallel_render.ParallelRenderPanel.draw(panel, context) parallel_render.ParallelRender.draw(panel, context) parallel_render.ParallelRender.check(panel, context) parallel_render.ParallelRenderPreferences.draw(panel, context) parallel_render.parallel_render_menu_draw(panel, context) class ParallelRenderTest(BlenderTest): def setUp(self): super(ParallelRenderTest, self).setUp() self.bpy.ops.scene.new(type='NEW') self.scn = self.bpy.context.scene self.scn.sequence_editor_create() self.scn.name = "TEST_SCENE" # startup_blend = os.path.join( # self.bpy.utils.resource_path('LOCAL'), # 'scripts', # 'startup', # 'bl_app_templates_system', # 'Video_Editing', # 'startup.blend', # ) # self.bpy.ops.workspace.append_activate(idname="Video Editing", filepath=startup_blend) # workspace = self.bpy.data.workspaces["Video Editing"] # editing_screen = workspace # editing_screen.scene = self.scn try: shutil.rmtree('output') except OSError: pass os.makedirs('output') # def tearDown(self): # self.bpy.context.screen.scene = self.scn def _setup_video(self, project_prefs, user_prefs): render = self.scn.render render.resolution_x = 90 render.resolution_y = 120 render.resolution_percentage = 25 render.pixel_aspect_x = 1 render.pixel_aspect_y = 1 render.fps = 24 render.fps_base = 1 render.image_settings.file_format = 'AVI_RAW' # Let us iterate over all properties and set them # Those are per user (visible under addon properties) pg = self.bpy.context.preferences.addons['parallel_render'].preferences for name, value in user_prefs.items(): setattr(pg, name, value) # Once we've set up everything let's recalculate # things that are not directly set by user. pg.update(self.bpy.context) # Those are per project (visible under properties tab widget) panel = self.scn.parallel_render_panel for name, value in project_prefs.items(): setattr(panel, name, value) # Recalculate derived properties (ones not directly set # by user) panel.update(self.bpy.context) def _create_red_blue_green_sequence(self): color_strips = ( ('red', (1, 0, 0)), ('green', (0, 1, 0)), ('blue', (0, 0, 1)), ) for pos, (name, color) in enumerate(color_strips): end_pos = (pos + 1) * 10 effect = self.scn.sequence_editor.sequences.new_effect( type='COLOR', channel=pos + 1, frame_start=1 + pos * 10, frame_end=end_pos, name='{} strip color'.format(name), ) effect.color = color self.scn.frame_end = end_pos def _trigger_render(self): self.bpy.ops.render.parallel_render() while self.scn.parallel_render_panel.last_run_result == 'pending': LOGGER.info('waiting for output [state %s]', self.scn.parallel_render_panel.last_run_result) sleep(0.3) def _render_video(self, expected_state='done'): self.scn.render.filepath = '//output/test' self._trigger_render() self.assertEqual(self.scn.parallel_render_panel.last_run_result, expected_state) # Actual tests def test_parallel_render_panel(self): def reload_properties_panel(): self.bpy.context.window.screen.areas[0].type = 'INFO' self.bpy.context.window.screen.areas[0].type = 'PROPERTIES' reload_properties_panel() def test_no_ffmpeg_fixed(self): for valid in (True, False): with self.subTest(ffmpeg_valid=valid): self._setup_video( user_prefs={ 'ffmpeg_executable': '', # calculated, so shouldn' matter 'ffmpeg_status': '', 'ffmpeg_valid': False, }, project_prefs={ 'max_parallel': 8, 'overwrite': False, 'mixdown': True, 'concatenate': True, 'clean_up_parts': False, 'batch_type': 'fixed', 'fixed': 10, # unused here 'parts': 3, 'last_run_result': 'done', }, ) self._create_red_blue_green_sequence() self._render_video() # Expect just the final render self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), ['test0001-0011.avi', 'test0001-0030.mp3', 'test0012-0022.avi', 'test0023-0030.avi'] ) def test_no_ffmpeg_parts(self): for valid in (True, False): with self.subTest(ffmpeg_valid=valid): self._setup_video( user_prefs={ 'ffmpeg_executable': '', # calculated, so shouldn' matter 'ffmpeg_status': '', 'ffmpeg_valid': False, }, project_prefs={ 'max_parallel': 8, 'overwrite': False, 'mixdown': True, 'concatenate': True, 'clean_up_parts': False, 'batch_type': 'parts', 'parts': 4, # unused here 'fixed': 10, 'last_run_result': 'done', }, ) self._create_red_blue_green_sequence() self._render_video() # Expect just the final render self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), ['test0001-0007.avi', 'test0001-0030.mp3', 'test0008-0015.avi', 'test0016-0022.avi','test0023-0030.avi'] ) def test_no_ffmpeg_no_mixdown(self): self._setup_video( user_prefs={ 'ffmpeg_executable': '', # calculated, so shouldn' matter 'ffmpeg_status': '', 'ffmpeg_valid': False, }, project_prefs={ 'max_parallel': 8, 'overwrite': False, 'mixdown': False, 'concatenate': True, 'clean_up_parts': False, 'batch_type': 'parts', 'parts': 4, # unused here 'fixed': 10, 'last_run_result': 'done', }, ) self._create_red_blue_green_sequence() self._render_video() # Expect just the final render self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), ['test0001-0007.avi', 'test0008-0015.avi', 'test0016-0022.avi','test0023-0030.avi'] ) def test_with_broken_ffmpeg(self): for ffmpeg_executable in ( '/some/path/that/is/really/unlikely/to/exist', os.path.dirname(__file__), # pointing to directory is not valid __file__, # this is on assumption current file is not executable ): with self.subTest(ffmpeg_executable=ffmpeg_executable): self._setup_video( user_prefs={ 'ffmpeg_executable': ffmpeg_executable, # calculated, so shouldn' matter 'ffmpeg_status': '', 'ffmpeg_valid': False, }, project_prefs={ 'max_parallel': 8, 'overwrite': False, 'mixdown': True, 'concatenate': True, 'clean_up_parts': False, 'batch_type': 'fixed', 'fixed': 10, # unused here 'parts': 3, 'last_run_result': 'done', }, ) self._create_red_blue_green_sequence() self._render_video() # Expect just the final render self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), ['test0001-0011.avi', 'test0001-0030.mp3', 'test0012-0022.avi', 'test0023-0030.avi'] ) def test_with_ffmpeg_no_cleanup(self): self._setup_video( user_prefs={ 'ffmpeg_executable': self.FFMPEG_EXECUTABLE, # calculated, so shouldn' matter 'ffmpeg_status': '', 'ffmpeg_valid': False, }, project_prefs={ 'max_parallel': 8, 'overwrite': False, 'mixdown': True, 'concatenate': True, 'clean_up_parts': False, 'batch_type': 'fixed', 'fixed': 10, # unused here 'parts': 3, 'last_run_result': 'done', }, ) self._create_red_blue_green_sequence() self._render_video() self.assertTrue(self.bpy.context.preferences.addons['parallel_render'].preferences.ffmpeg_valid) # Expect just the final render and all parts self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), ['test0001-0011.avi', 'test0001-0030.avi', 'test0001-0030.mp3', 'test0012-0022.avi', 'test0023-0030.avi'] ) def test_with_ffmpeg_with_cleanup_save(self): self._setup_video( user_prefs={ 'ffmpeg_executable': self.FFMPEG_EXECUTABLE, # calculated, so shouldn' matter 'ffmpeg_status': '', 'ffmpeg_valid': False, }, project_prefs={ 'max_parallel': 8, 'overwrite': False, 'mixdown': True, 'concatenate': True, 'clean_up_parts': True, 'batch_type': 'fixed', 'fixed': 10, # unused here 'parts': 3, 'last_run_result': 'done', }, ) self._create_red_blue_green_sequence() self.scn.render.filepath = '//output/test' filepath = os.path.join(os.path.abspath('.'), 'test.blend') self.assertEqual( self.bpy.ops.wm.save_mainfile(filepath=filepath), {'FINISHED'}, ) self.assertTrue(self.bpy.data.is_saved) self.assertTrue(os.path.exists(self.bpy.data.filepath)) # FIXME: sadly even though I just called `save_mainfile` we # still get back "is_dirty" # I shouldn't need to mock it, it should just work and this: # self.assertFalse(self.bpy.data.is_dirty) # should be True with mock.patch('parallel_render._need_temporary_file') as needs_temporary: needs_temporary.return_value = False self._trigger_render() self.assertTrue(self.bpy.data.is_saved) # self.assertFalse(self.bpy.data.is_dirty) # Expect just the final render self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), ['test0001-0030.avi'] ) def _setup_common_video(self): self._setup_video( user_prefs={ 'ffmpeg_executable': self.FFMPEG_EXECUTABLE, # calculated, so shouldn' matter 'ffmpeg_status': '', 'ffmpeg_valid': False, }, project_prefs={ 'max_parallel': 8, 'overwrite': False, 'mixdown': True, 'concatenate': True, 'clean_up_parts': True, 'batch_type': 'fixed', 'fixed': 8, # unused here 'parts': 4, 'last_run_result': 'done', }, ) self._create_red_blue_green_sequence() def test_with_popen_failure(self): self._setup_common_video() with mock.patch('subprocess.Popen') as Popen: Popen.side_effect = Exception('TEST') self._render_video(expected_state='failed') # Expect nothing, as we can't Popen self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), [] ) def test_with_ffmpeg_concat_failure(self): self._setup_common_video() with mock.patch('subprocess.call') as call: call.side_effect = [-1] self._render_video(expected_state='failed') self.assertEqual(call.call_args[0][0][0], self.FFMPEG_EXECUTABLE) self.assertEqual(call.call_count, 1) # should only be called for running ffmpeg # Expect all prats, but not concatenated bit self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), [ 'test0001-0009.avi', 'test0001-0030.mp3', 'test0010-0018.avi', 'test0019-0027.avi', 'test0028-0030.avi', ] ) def test_with_custom_failures(self): self._setup_common_video() base_dir = os.path.realpath('output') def create_output(filepath, rc): process_mock = mock.MagicMock() if filepath is not None: filepath = os.path.join(base_dir, filepath) LOGGER.info('Creating dummy file %s', filepath) with open(filepath, 'w'): pass process_mock.returncode = int(rc) process_mock.wait.return_value = int(rc) return process_mock processes = iter(( create_output(filepath='test0001-0009.avi', rc=0), create_output(filepath=None, rc=-11), create_output(filepath='test0019-0027.avi', rc=1), create_output(filepath=None, rc=-12), )) with mock.patch('parallel_render.Pool') as Pool: Pool().__enter__().imap_unordered = map Pool().__enter__().map = map with mock.patch('subprocess.Popen') as Popen: Popen.side_effect = lambda *_, **_kw: next(processes) with mock.patch('parallel_render.MessageChannel') as MessageChannel: MessageChannel().recv.side_effect = [ {'output_file': os.path.join(base_dir, 'test0001-0009.avi'), 'current_frame': 11}, None, {'output_file': os.path.join(base_dir, 'test0010-0018.avi'), 'current_frame': 12}, None, {'output_file': os.path.join(base_dir, 'test0019-0027.avi'), 'current_frame': 25}, None, None, ] with mock.patch('socket.socket') as socket: socket().getsockname.return_value = 'does not matter' socket().accept.return_value = (mock.MagicMock(), mock.MagicMock()) self._render_video(expected_state='failed') # Expect nothing, as we can't Popen self.assertEqual( sorted(fname for fname in os.listdir('output/') if fname[0] != '.'), ['test0001-0009.avi'] ) def _run_tests(args): BlenderTest.FFMPEG_EXECUTABLE = args[2] unittest.main( argv=['<blender executable>'] + args[3:], ) def run_tests(args): extra_pythonpath = args[1] sys.path.append(extra_pythonpath) LOGGER.info("Appending extra PYTHONPATH %s", extra_pythonpath) import coverage coverage.process_startup() # I split this into separate function to increase coverage # ever so slightly. # I am not clear why, but it seems that coverage misses out on lines # within the same function as coverage.process_startup() got called. # Caling into another function seems to help it. _run_tests(args) def launch_tests_under_blender(args): import coverage blender_executable = args.pop(1) ffmpeg_executable = args.pop(1) coverage_module_path = os.path.realpath(os.path.dirname(os.path.dirname(coverage.__file__))) cmd = ( blender_executable, '--background', '-noaudio', '--factory-startup', '--python', os.path.abspath(__file__), '--', 'run', coverage_module_path, os.path.realpath(ffmpeg_executable) ) + tuple(args[1:]) LOGGER.info('Running: %s', cmd) env = dict(os.environ) env['BLENDER_USER_SCRIPTS'] = os.path.realpath('scripts') env['PYTHONPATH'] = coverage_module_path outdir = os.path.realpath('tests_output') subprocess.check_call(cmd, cwd=outdir, env=env) MAIN_ACTIONS = { 'test': launch_tests_under_blender, 'run': run_tests, } def main(): logging.basicConfig(level=logging.INFO) try: args = sys.argv[sys.argv.index('--') + 1:] except ValueError: args = sys.argv[1:] MAIN_ACTIONS[args[0]](args) if __name__ == "__main__": main()