class TestRunnableController(unittest.TestCase):
    def checkState(self, state, child=True, parent=True):
        if child:
            self.assertEqual(self.b_child.state, state)
        if parent:
            self.assertEqual(self.b.state, state)

    def checkSteps(self, configured, completed, total):
        self.assertEqual(self.b.configuredSteps, configured)
        self.assertEqual(self.b.completedSteps, completed)
        self.assertEqual(self.b.totalSteps, total)
        self.assertEqual(self.b_child.configuredSteps, configured)
        self.assertEqual(self.b_child.completedSteps, completed)
        self.assertEqual(self.b_child.totalSteps, total)

    def setUp(self):
        self.maxDiff = 5000

        self.p = Process('process1', SyncFactory('threading'))

        # Make a ticker block to act as our child
        params = Ticker.MethodMeta.prepare_input_map(mri="childBlock",
                                                     configDir="/tmp")
        self.b_child = Ticker(self.p, params)[-1]

        # Make an empty part for our parent
        params = Part.MethodMeta.prepare_input_map(name='part1')
        part1 = Part(self.p, params)

        # Make a RunnableChildPart to control the ticker
        params = RunnableChildPart.MethodMeta.prepare_input_map(
            mri='childBlock', name='part2')
        part2 = RunnableChildPart(self.p, params)

        # create a root block for the RunnableController block to reside in
        params = RunnableController.MethodMeta.prepare_input_map(
            mri='mainBlock', configDir="/tmp")
        self.c = RunnableController(self.p, [part1, part2], params)
        self.b = self.c.block
        self.sm = self.c.stateMachine

        # start the process off
        self.p.start()

        # wait until block is Ready
        task = Task("block_ready_task", self.p)
        task.when_matches(self.b["state"], self.sm.IDLE, timeout=1)

        self.checkState(self.sm.IDLE)

    def tearDown(self):
        self.p.stop()

    def test_init(self):
        # the following block attributes should be created by a call to
        # set_attributes via _set_block_children in __init__
        self.assertEqual(self.b['totalSteps'].meta.typeid,
                         'malcolm:core/NumberMeta:1.0')
        self.assertEqual(self.b['completedSteps'].meta.typeid,
                         'malcolm:core/NumberMeta:1.0')
        self.assertEqual(self.b['configuredSteps'].meta.typeid,
                         'malcolm:core/NumberMeta:1.0')
        self.assertEqual(self.b['axesToMove'].meta.typeid,
                         'malcolm:core/StringArrayMeta:1.0')

        # the following hooks should be created via _find_hooks in __init__
        self.assertEqual(
            self.c.hook_names, {
                self.c.Reset: "Reset",
                self.c.Disable: "Disable",
                self.c.ReportOutports: "ReportOutports",
                self.c.Layout: "Layout",
                self.c.Load: "Load",
                self.c.Save: "Save",
                self.c.Validate: "Validate",
                self.c.ReportStatus: "ReportStatus",
                self.c.Configure: "Configure",
                self.c.PostConfigure: "PostConfigure",
                self.c.Run: "Run",
                self.c.PostRunReady: "PostRunReady",
                self.c.PostRunIdle: "PostRunIdle",
                self.c.Seek: "Seek",
                self.c.Pause: "Pause",
                self.c.Resume: "Resume",
                self.c.Abort: "Abort",
            })

        # check instantiation of object tree via logger names
        self.assertEqual(self.c._logger.name, 'RunnableController(mainBlock)')
        self.assertEqual(self.c.parts['part1']._logger.name,
                         'RunnableController(mainBlock).part1')
        self.assertEqual(self.c.parts['part2']._logger.name,
                         'RunnableController(mainBlock).part2')

    def test_edit(self):
        self.b.edit()
        self.checkState(self.sm.EDITABLE, child=False)

    def test_reset(self):
        self.b.disable()
        self.checkState(self.sm.DISABLED)
        self.b.reset()
        self.checkState(self.sm.IDLE)

    def test_set_axes_to_move(self):
        self.c.set_axes_to_move(['y'])
        self.assertEqual(self.c.axes_to_move.value, ('y', ))

    def test_validate(self):
        line1 = LineGenerator('y', 'mm', 0, 2, 3)
        line2 = LineGenerator('x', 'mm', 0, 2, 2)
        compound = CompoundGenerator([line1, line2], [], [])
        actual = self.b.validate(generator=compound, axesToMove=['x'])
        self.assertEqual(actual["generator"], compound)
        self.assertEqual(actual["axesToMove"], ('x', ))

    def prepare_half_run(self, duration=0.01, exception=0):
        line1 = LineGenerator('y', 'mm', 0, 2, 3)
        line2 = LineGenerator('x', 'mm', 0, 2, 2)
        compound = CompoundGenerator([line1, line2], [], [], duration)
        self.b.configure(generator=compound,
                         axesToMove=['x'],
                         exceptionStep=exception)

    def test_configure_run(self):
        self.prepare_half_run()
        self.checkSteps(2, 0, 6)
        self.checkState(self.sm.READY)

        self.b.run()
        self.checkState(self.sm.READY)
        self.checkSteps(4, 2, 6)

        self.b.run()
        self.checkState(self.sm.READY)
        self.checkSteps(6, 4, 6)

        self.b.run()
        self.checkState(self.sm.IDLE)

    def test_abort(self):
        self.prepare_half_run()
        self.b.run()
        self.b.abort()
        self.checkState(self.sm.ABORTED)

    def test_pause_seek_resume(self):
        self.prepare_half_run()
        self.checkSteps(configured=2, completed=0, total=6)
        self.b.run()
        self.checkState(self.sm.READY)
        self.checkSteps(4, 2, 6)
        self.b.pause(completedSteps=1)
        self.checkState(self.sm.READY)
        self.checkSteps(2, 1, 6)
        self.b.run()
        self.checkSteps(4, 2, 6)
        self.b.completedSteps = 5
        self.checkSteps(6, 5, 6)
        self.b.run()
        self.checkState(self.sm.IDLE)

    def test_resume_in_run(self):
        self.prepare_half_run(duration=0.5)
        w = self.p.spawn(self.b.run)
        time.sleep(0.85)
        self.b.pause()
        self.checkState(self.sm.PAUSED)
        self.checkSteps(2, 1, 6)
        self.b.resume()
        # return to PRERUN should continue original run to completion and
        # READY state
        then = time.time()
        w.wait()
        self.assertAlmostEqual(time.time() - then, 0.5, delta=0.4)
        self.checkState(self.sm.READY)

    def test_run_exception(self):
        self.prepare_half_run(exception=1)
        with self.assertRaises(ResponseError):
            self.b.run()
        self.checkState(self.sm.FAULT)

    def test_run_stop(self):
        self.prepare_half_run(duration=0.5)
        w = self.p.spawn(self.b.run)
        time.sleep(0.5)
        self.b.abort()
        with self.assertRaises(AbortedError):
            w.get()
        self.checkState(self.sm.ABORTED)
class TestHDFWriterPart(ChildTestCase):
    maxDiff = None

    def setUp(self):
        self.process = Process("Process")
        self.context = Context(self.process)
        self.child = self.create_child_block(hdf_writer_block,
                                             self.process,
                                             mri="BLOCK:HDF5",
                                             prefix="prefix")
        self.config_dir = tmp_dir("config_dir")
        self.process.start()

    def tearDown(self):
        self.process.stop(2)
        shutil.rmtree(self.config_dir.value)

    def test_init(self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        c = RunnableController("mri", self.config_dir.value)
        c.add_part(self.o)
        self.process.add_controller(c)
        b = c.block_view()
        assert list(b.configure.meta.takes.elements) == [
            "generator",
            "fileDir",
            "axesToMove",
            "breakpoints",
            "formatName",
            "fileTemplate",
        ]

    @patch("malcolm.modules.ADCore.parts.hdfwriterpart.check_driver_version")
    def test_validate(self, check_mock):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")

        # Version check should not be called with require_version None
        self.o.require_version = None
        self.set_attributes(self.child, driverVersion="1.1")

        self.o.on_validate(self.context)

        check_mock.assert_not_called()

        # Test version check called if required_version not None
        self.o.required_version = "1.0"
        self.o.on_validate(self.context)

        check_mock.assert_called_once_with("1.1", "1.0")

    def configure_and_check_output(self, on_windows=False):
        energy = LineGenerator("energy", "kEv", 13.0, 15.2, 2)
        spiral = SpiralGenerator(["x", "y"], ["mm", "mm"], [0.0, 0.0],
                                 5.0,
                                 scale=2.0)
        generator = CompoundGenerator([energy, spiral], [], [], 0.1)
        generator.prepare()
        fileDir = "/tmp"
        formatName = "xspress3"
        fileTemplate = "thing-%s.h5"
        completed_steps = 0
        steps_to_do = 38
        part_info = {
            "DET": [NDArrayDatasetInfo(2)],
            "PANDA": [
                NDAttributeDatasetInfo.from_attribute_type(
                    "I0", AttributeDatasetType.DETECTOR, "COUNTER1.COUNTER"),
                NDAttributeDatasetInfo.from_attribute_type(
                    "It", AttributeDatasetType.MONITOR, "COUNTER2.COUNTER"),
                NDAttributeDatasetInfo.from_attribute_type(
                    "t1x", AttributeDatasetType.POSITION, "INENC1.VAL"),
            ],
            "STAT": [CalculatedNDAttributeDatasetInfo("sum", "StatsTotal")],
        }
        if on_windows:
            part_info["WINPATH"] = [FilePathTranslatorInfo("Y", "/tmp", "")]
        infos = self.o.on_configure(
            self.context,
            completed_steps,
            steps_to_do,
            part_info,
            generator,
            fileDir,
            formatName,
            fileTemplate,
        )
        assert len(infos) == 8
        assert infos[0].name == "xspress3.data"
        assert infos[0].filename == "thing-xspress3.h5"
        assert infos[0].type == DatasetType.PRIMARY
        assert infos[0].rank == 4
        assert infos[0].path == "/entry/detector/detector"
        assert infos[0].uniqueid == "/entry/NDAttributes/NDArrayUniqueId"

        assert infos[1].name == "xspress3.sum"
        assert infos[1].filename == "thing-xspress3.h5"
        assert infos[1].type == DatasetType.SECONDARY
        assert infos[1].rank == 2
        assert infos[1].path == "/entry/sum/sum"
        assert infos[1].uniqueid == "/entry/NDAttributes/NDArrayUniqueId"

        assert infos[2].name == "I0.data"
        assert infos[2].filename == "thing-xspress3.h5"
        assert infos[2].type == DatasetType.PRIMARY
        assert infos[2].rank == 2
        assert infos[2].path == "/entry/I0.data/I0.data"
        assert infos[2].uniqueid == "/entry/NDAttributes/NDArrayUniqueId"

        assert infos[3].name == "It.data"
        assert infos[3].filename == "thing-xspress3.h5"
        assert infos[3].type == DatasetType.MONITOR
        assert infos[3].rank == 2
        assert infos[3].path == "/entry/It.data/It.data"
        assert infos[3].uniqueid == "/entry/NDAttributes/NDArrayUniqueId"

        assert infos[4].name == "t1x.value"
        assert infos[4].filename == "thing-xspress3.h5"
        assert infos[4].type == DatasetType.POSITION_VALUE
        assert infos[4].rank == 2
        assert infos[4].path == "/entry/t1x.value/t1x.value"
        assert infos[4].uniqueid == "/entry/NDAttributes/NDArrayUniqueId"

        assert infos[5].name == "energy.value_set"
        assert infos[5].filename == "thing-xspress3.h5"
        assert infos[5].type == DatasetType.POSITION_SET
        assert infos[5].rank == 1
        assert infos[5].path == "/entry/detector/energy_set"
        assert infos[5].uniqueid == ""

        assert infos[6].name == "x.value_set"
        assert infos[6].filename == "thing-xspress3.h5"
        assert infos[6].type == DatasetType.POSITION_SET
        assert infos[6].rank == 1
        assert infos[6].path == "/entry/detector/x_set"
        assert infos[6].uniqueid == ""

        assert infos[7].name == "y.value_set"
        assert infos[7].filename == "thing-xspress3.h5"
        assert infos[7].type == DatasetType.POSITION_SET
        assert infos[7].rank == 1
        assert infos[7].path == "/entry/detector/y_set"
        assert infos[7].uniqueid == ""

        expected_xml_filename_local = "/tmp/BLOCK_HDF5-layout.xml"
        if on_windows:
            expected_xml_filename_remote = "Y:\\BLOCK_HDF5-layout.xml"
            expected_filepath = "Y:" + os.sep
        else:
            expected_xml_filename_remote = expected_xml_filename_local
            expected_filepath = "/tmp" + os.sep
        # Wait for the start_future so the post gets through to our child
        # even on non-cothread systems
        self.o.start_future.result(timeout=1)
        assert self.child.handled_requests.mock_calls == [
            call.put("positionMode", True),
            call.put("arrayCounter", 0),
            call.put("dimAttDatasets", True),
            call.put("enableCallbacks", True),
            call.put("fileName", "xspress3"),
            call.put("filePath", expected_filepath),
            call.put("fileTemplate", "%sthing-%s.h5"),
            call.put("fileWriteMode", "Stream"),
            call.put("lazyOpen", True),
            call.put("storeAttr", True),
            call.put("swmrMode", True),
            call.put("extraDimSize3", 1),
            call.put("extraDimSize4", 1),
            call.put("extraDimSize5", 1),
            call.put("extraDimSize6", 1),
            call.put("extraDimSize7", 1),
            call.put("extraDimSize8", 1),
            call.put("extraDimSize9", 1),
            call.put("extraDimSizeN", 20),
            call.put("extraDimSizeX", 2),
            call.put("extraDimSizeY", 1),
            call.put("numExtraDims", 1),
            call.put("posNameDim3", ""),
            call.put("posNameDim4", ""),
            call.put("posNameDim5", ""),
            call.put("posNameDim6", ""),
            call.put("posNameDim7", ""),
            call.put("posNameDim8", ""),
            call.put("posNameDim9", ""),
            call.put("posNameDimN", "d1"),
            call.put("posNameDimX", "d0"),
            call.put("posNameDimY", ""),
            call.put("flushAttrPerNFrames", 0),
            call.put("flushDataPerNFrames", 38),
            call.put("xmlLayout", expected_xml_filename_remote),
            call.put("numCapture", 0),
            call.post("start"),
            call.when_value_matches("arrayCounterReadback", greater_than_zero,
                                    None),
        ]
        with open(expected_xml_filename_local) as f:
            actual_xml = f.read().replace(">", ">\n")
        # Check the layout filename Malcolm uses for file creation
        assert self.o.layout_filename == expected_xml_filename_local
        return actual_xml

    @staticmethod
    def mock_xml_is_valid_check(part):
        mock_xml_layout_value = MagicMock(name="mock_xml_layout_value")
        mock_xml_layout_value.return_value = True
        part._check_xml_is_valid = mock_xml_layout_value

    def test_configure(self):
        self.mock_when_value_matches(self.child)
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        self.mock_xml_is_valid_check(self.o)
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        actual_xml = self.configure_and_check_output()
        assert actual_xml == expected_xml

        actual_tree = ElementTree.XML(actual_xml)
        expected_tree = ElementTree.XML(expected_xml)
        assert ElementTree.dump(actual_tree) == ElementTree.dump(expected_tree)

    def test_honours_write_all_attributes_flag(self):
        self.mock_when_value_matches(self.child)
        self.o = HDFWriterPart(name="m",
                               mri="BLOCK:HDF5",
                               write_all_nd_attributes=False)
        self.mock_xml_is_valid_check(self.o)
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        actual_xml = self.configure_and_check_output()

        actual_tree = ElementTree.XML(actual_xml)
        expected_tree = ElementTree.XML(expected_xml)
        assert ElementTree.dump(actual_tree) == ElementTree.dump(expected_tree)

    def test_configure_windows(self):
        self.mock_when_value_matches(self.child)
        self.o = HDFWriterPart(name="m",
                               mri="BLOCK:HDF5",
                               runs_on_windows=True)
        self.mock_xml_is_valid_check(self.o)
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        actual_xml = self.configure_and_check_output(on_windows=True)
        assert actual_xml == expected_xml

        actual_tree = ElementTree.XML(actual_xml)
        expected_tree = ElementTree.XML(expected_xml)
        assert ElementTree.dump(actual_tree) == ElementTree.dump(expected_tree)

    def test_run(self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        self.o.done_when_captured = 38
        # Need a registrar object or we get AssertionError
        self.o.registrar = MagicMock()
        # Run waits for this value, so say we have finished immediately
        self.set_attributes(self.child,
                            numCapturedReadback=self.o.done_when_captured)
        self.mock_when_value_matches(self.child)

        # Run
        self.o.on_run(self.context)

        # Check calls
        assert self.child.handled_requests.mock_calls == [
            call.when_value_matches("numCapturedReadback", 38, None)
        ]
        assert self.o.registrar.report.called_once
        assert self.o.registrar.report.call_args_list[0][0][0].steps == 38

    def test_run_and_flush(self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")

        def set_num_captured():
            # Sleep for 2.5 seconds to ensure 2 flushes, and then set value to finish
            cothread.Sleep(2.5)
            self.set_attributes(self.child,
                                numCapturedReadback=self.o.done_when_captured)

        self.o.done_when_captured = 5
        # Say that we're getting the first frame
        self.o.first_array_future = Future(None)
        self.o.first_array_future.set_result(None)
        self.o.start_future = Future(None)
        # Need a registrar object or we get AssertionError
        self.o.registrar = MagicMock()
        # Reduce frame timeout so we don't hang on this test for too long
        self.o.frame_timeout = 5
        # Spawn process to finish it after a few seconds
        self.process.spawn(set_num_captured)

        # Run
        self.o.on_run(self.context)

        # Check calls
        assert self.child.handled_requests.mock_calls == [
            call.post("flushNow"),
            call.post("flushNow"),
        ]
        assert self.o.registrar.report.called_once
        assert self.o.registrar.report.call_args_list[0][0][0].steps == 0
        assert self.o.registrar.report.call_args_list[1][0][0].steps == 5

    def test_run_raises_TimeoutError_for_stalled_writer(self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        self.o.done_when_captured = 10
        # Need a registrar object or we get AssertionError
        self.o.registrar = MagicMock()
        self.o.start_future = MagicMock()
        # Mock the last update
        self.o.last_capture_update = MagicMock()
        self.o.last_capture_update.return_value = 0.0
        # Set a short timeout for testing
        self.o.frame_timeout = 0.1

        # Now check the error is raised
        self.assertRaises(TimeoutError, self.o.on_run, self.context)

    def test_seek(self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        # Num captured readback usually reads a bit higher than the completed steps
        # after a pause is requested
        completed_steps = 4
        self.set_attributes(self.child, numCapturedReadback=6)
        # Call the seek
        steps_to_do = 3
        self.o.on_seek(self.context, completed_steps, steps_to_do)
        # We expect done when captured to be the current captured readback + steps to do
        assert self.o.done_when_captured == 9

    def test_post_run_ready(self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        self.context.set_notify_dispatch_request(
            self.o.notify_dispatch_request)
        # Say that we've returned from start
        self.o.start_future = Future(None)
        self.o.start_future.set_result(None)
        fname = "/tmp/test_filename"
        with open(fname, "w") as f:
            f.write("thing")
        assert os.path.isfile(fname)
        self.o.layout_filename = fname
        self.o.on_post_run_ready(self.context)
        assert self.child.handled_requests.mock_calls == []
        assert os.path.isfile(fname)
        self.o.on_reset(self.context)
        assert not os.path.isfile(fname)

    def test_post_run_ready_not_done_flush(self):
        # Say that we've returned from start
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        self.o.start_future = Future(None)
        fname = "/tmp/test_filename"
        with open(fname, "w") as f:
            f.write("thing")
        assert os.path.isfile(fname)
        self.o.layout_filename = fname
        self.o.on_post_run_ready(self.context)
        assert self.child.handled_requests.mock_calls == [
            call.post("flushNow")
        ]
        assert os.path.isfile(fname)
        self.o.on_reset(self.context)
        assert not os.path.isfile(fname)

    def test_check_xml_is_valid_method_succeeds_for_valid_value(self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        child = MagicMock(name="child_mock")
        child.xmlLayoutValid.value = True

        try:
            self.o._check_xml_is_valid(child)
        except AssertionError:
            self.fail("_check_xml_is_valid() threw unexpected AssertionError")

    def test_check_xml_is_valid_method_throws_AssertionError_for_bad_value(
            self):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")
        child = MagicMock(name="child_mock")
        child.xmlLayoutValid.value = False
        child.xmlErrorMsg.value = "XML description file cannot be opened"

        self.assertRaises(AssertionError, self.o._check_xml_is_valid, child)

    @patch("malcolm.modules.ADCore.parts.hdfwriterpart.time.time")
    def test_has_file_writing_stalled(self, time_mock):
        self.o = HDFWriterPart(name="m", mri="BLOCK:HDF5")

        # First case - no last capture update so return False
        assert self.o._has_file_writing_stalled() is False

        # Set up the attributes and mock for the last two cases
        self.o.last_capture_update = 10.0
        self.o.frame_timeout = 60.0
        time_mock.side_effect = [30.0, 71.0]

        # Second case - last capture update is within frame timeout
        assert self.o._has_file_writing_stalled() is False

        # Final case - last capture update is outside frame timeout
        assert self.o._has_file_writing_stalled() is True
class TestRunnableController(unittest.TestCase):

    def checkState(self, state, child=True, parent=True):
        if child:
            self.assertEqual(self.b_child.state, state)
        if parent:
            self.assertEqual(self.b.state, state)

    def checkSteps(self, configured, completed, total):
        self.assertEqual(self.b.configuredSteps, configured)
        self.assertEqual(self.b.completedSteps, completed)
        self.assertEqual(self.b.totalSteps, total)
        self.assertEqual(self.b_child.configuredSteps, configured)
        self.assertEqual(self.b_child.completedSteps, completed)
        self.assertEqual(self.b_child.totalSteps, total)

    def setUp(self):
        self.maxDiff = 5000

        self.p = Process('process1', SyncFactory('threading'))

        # Make a ticker block to act as our child
        params = Ticker.MethodMeta.prepare_input_map(mri="childBlock")
        self.b_child = Ticker(self.p, params)[-1]

        # Make an empty part for our parent
        params = Part.MethodMeta.prepare_input_map(name='part1')
        part1 = Part(self.p, params)

        # Make a RunnableChildPart to control the ticker
        params = RunnableChildPart.MethodMeta.prepare_input_map(
            mri='childBlock', name='part2')
        part2 = RunnableChildPart(self.p, params)

        # create a root block for the RunnableController block to reside in
        params = RunnableController.MethodMeta.prepare_input_map(
            mri='mainBlock')
        self.c = RunnableController(self.p, [part1, part2], params)
        self.b = self.c.block
        self.sm = self.c.stateMachine

        # start the process off
        self.p.start()

        # wait until block is Ready
        task = Task("block_ready_task", self.p)
        task.when_matches(self.b["state"], self.sm.IDLE, timeout=1)

        self.checkState(self.sm.IDLE)

    def tearDown(self):
        self.p.stop()

    def test_init(self):
        # the following block attributes should be created by a call to
        # set_attributes via _set_block_children in __init__
        self.assertEqual(self.b['totalSteps'].meta.typeid,
                         'malcolm:core/NumberMeta:1.0')
        self.assertEqual(self.b['layout'].meta.typeid,
                         'malcolm:core/TableMeta:1.0')
        self.assertEqual(self.b['completedSteps'].meta.typeid,
                         'malcolm:core/NumberMeta:1.0')
        self.assertEqual(self.b['configuredSteps'].meta.typeid,
                         'malcolm:core/NumberMeta:1.0')
        self.assertEqual(self.b['axesToMove'].meta.typeid,
                         'malcolm:core/StringArrayMeta:1.0')
        self.assertEqual(self.b['layoutName'].meta.typeid,
                         'malcolm:core/StringMeta:1.0')

        # the following hooks should be created via _find_hooks in __init__
        self.assertEqual(self.c.hook_names, {
            self.c.Reset: "Reset",
            self.c.Disable: "Disable",
            self.c.ReportOutports: "ReportOutports",
            self.c.Layout: "Layout",
            self.c.Load: "Load",
            self.c.Save: "Save",
            self.c.Validate: "Validate",
            self.c.ReportStatus: "ReportStatus",
            self.c.Configure: "Configure",
            self.c.PostConfigure: "PostConfigure",
            self.c.Run: "Run",
            self.c.PostRunReady: "PostRunReady",
            self.c.PostRunIdle: "PostRunIdle",
            self.c.Seek: "Seek",
            self.c.Pause: "Pause",
            self.c.Resume: "Resume",
            self.c.Abort: "Abort",
        })

        # check instantiation of object tree via logger names
        self.assertEqual(self.c._logger.name,
                         'RunnableController(mainBlock)')
        self.assertEqual(self.c.parts['part1']._logger.name,
                         'RunnableController(mainBlock).part1')
        self.assertEqual(self.c.parts['part2']._logger.name,
                         'RunnableController(mainBlock).part2')

    def test_edit(self):
        self.b.edit()
        self.checkState(self.sm.EDITABLE, child=False)

    def test_reset(self):
        self.b.disable()
        self.checkState(self.sm.DISABLED)
        self.b.reset()
        self.checkState(self.sm.IDLE)

    def test_set_axes_to_move(self):
        self.c.set_axes_to_move(['y'])
        self.assertEqual(self.c.axes_to_move.value, ['y'])

    def test_validate(self):
        line1 = LineGenerator('y', 'mm', 0, 2, 3)
        line2 = LineGenerator('x', 'mm', 0, 2, 2)
        compound = CompoundGenerator([line1, line2], [], [])
        actual = self.b.validate(generator=compound, axesToMove=['x'])
        self.assertEqual(actual["generator"], compound)
        self.assertEqual(actual["axesToMove"], ['x'])

    def prepare_half_run(self, duration=0.01, exception=0):
        line1 = LineGenerator('y', 'mm', 0, 2, 3)
        line2 = LineGenerator('x', 'mm', 0, 2, 2)
        duration = FixedDurationMutator(duration)
        compound = CompoundGenerator([line1, line2], [], [duration])
        self.b.configure(
            generator=compound, axesToMove=['x'], exceptionStep=exception)

    def test_configure_run(self):
        self.prepare_half_run()
        self.checkSteps(2, 0, 6)
        self.checkState(self.sm.READY)

        self.b.run()
        self.checkState(self.sm.READY)
        self.checkSteps(4, 2, 6)

        self.b.run()
        self.checkState(self.sm.READY)
        self.checkSteps(6, 4, 6)

        self.b.run()
        self.checkState(self.sm.IDLE)

    def test_abort(self):
        self.prepare_half_run()
        self.b.run()
        self.b.abort()
        self.checkState(self.sm.ABORTED)

    def test_pause_seek_resume(self):
        self.prepare_half_run()
        self.checkSteps(configured=2, completed=0, total=6)
        self.b.run()
        self.checkState(self.sm.READY)
        self.checkSteps(4, 2, 6)
        self.b.pause(completedSteps=1)
        self.checkState(self.sm.READY)
        self.checkSteps(2, 1, 6)
        self.b.run()
        self.checkSteps(4, 2, 6)
        self.b.completedSteps = 5
        self.checkSteps(6, 5, 6)
        self.b.run()
        self.checkState(self.sm.IDLE)

    def test_resume_in_run(self):
        self.prepare_half_run(duration=0.5)
        w = self.p.spawn(self.b.run)
        time.sleep(0.85)
        self.b.pause()
        self.checkState(self.sm.PAUSED)
        self.checkSteps(2, 1, 6)
        self.b.resume()
        # return to PRERUN should continue original run to completion and
        # READY state
        then = time.time()
        w.wait()
        self.assertAlmostEqual(time.time() - then, 0.5, delta=0.4)
        self.checkState(self.sm.READY)

    def test_run_exception(self):
        self.prepare_half_run(exception=1)
        with self.assertRaises(ResponseError):
            self.b.run()
        self.checkState(self.sm.FAULT)

    def test_run_stop(self):
        self.prepare_half_run(duration=0.5)
        w = self.p.spawn(self.b.run)
        time.sleep(0.5)
        self.b.abort()
        with self.assertRaises(AbortedError):
            w.get()
        self.checkState(self.sm.ABORTED)