diff --git a/.release b/.release index 02f219ab46a84e7ed5f50ed35b5e6536a431fc21..51faac07be14ca7c1987d83d647ec9ae96fec0fe 100644 --- a/.release +++ b/.release @@ -1,2 +1,2 @@ -release=0.6.3 -tag=lmcbaseclasses-0.6.3 +release=0.6.4 +tag=lmcbaseclasses-0.6.4 diff --git a/README.md b/README.md index 55520e446b963fa62a4fc956ae363662ac7cfb0b..a416cd460894b21d9d1ffa861b858ad64a7916cb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ The lmc-base-classe repository contains set of eight classes as mentioned in SKA ## Version History +#### 0.6.4 +- Refactor state machine to use pytransitions library. +- Minor behavioural change: Off() command is accepted in every obsState, rather +than only EMPTY obsState. +- support `straight_to_state` shortcuts to simplify test setups +- Refactor of state machine testing to make it more portable + #### 0.6.3 - Fix omission of fatal_error transition from base device state machine. diff --git a/docs/source/SKABaseDevice.rst b/docs/source/SKABaseDevice.rst index 4e6bcb44ad1ec4d5feb0cb2a8ff2353eb78d963b..9d41032725d5449356f3100f4035213ed5a81c07 100644 --- a/docs/source/SKABaseDevice.rst +++ b/docs/source/SKABaseDevice.rst @@ -6,14 +6,6 @@ SKA BaseDevice ============================================ -The SKABaseDevice implements the basic device state machine, as illustrated -below, but without, at present, a Standby state. - -.. image:: images/device_state_diagram.png - :width: 400 - :alt: Diagram of the device state machine showing states and transitions - - .. toctree:: :maxdepth: 2 diff --git a/docs/source/SKASubarray.rst b/docs/source/SKASubarray.rst index 93f320456ed91dd4d416971c7368675030a78213..4db79893338aa3fbdf320ff54c852e1896d79b03 100644 --- a/docs/source/SKASubarray.rst +++ b/docs/source/SKASubarray.rst @@ -6,13 +6,6 @@ SKA Subarray ============================================ -The SKA Subarray device implements the observation state machine as defined -in ADR-8: - -.. image:: images/ADR-8.png - :width: 400 - :alt: Diagram of the observation state machine showing states and transitions - .. toctree:: :maxdepth: 2 diff --git a/docs/source/State_Machine.rst b/docs/source/State_Machine.rst new file mode 100644 index 0000000000000000000000000000000000000000..ff7d1687b8cdd628b351bff00989fe4544c8bdc7 --- /dev/null +++ b/docs/source/State_Machine.rst @@ -0,0 +1,69 @@ + +State Machine +============= + +The state machine modules implements SKA's two fundamental state machines: the +base device state machine, and the observation state machine. + +Base device state machine +------------------------- +The base device state machine provides basic state needed for all devices, +covering initialisation, off and on states, and a fault state. This state +machine is implemented by all SKA Tango devices that inherit from these LMC +base classes, though some devices with standby power modes may need to +implement further states. + + +.. figure:: images/device_state_diagram.png + :width: 80% + :alt: Diagram of the device state machine, taken from SKA design + documentation, showing the state machine as designed + + Diagram of the device state machine, taken from SKA design + documentation, showing the state machine as designed + + +.. figure:: images/BaseDeviceStateMachine.png + :width: 80% + :alt: Diagram of the device state machine, automatically generated + from the state machine as specified in code. + + Diagram of the device state machine, automatically generated from the + state machine as specified in code. The equivalence of this diagram to + the diagram previous demonstrates that the machine has been + implemented as designed. + + +Observation state machine +------------------------- +The observation state machine is implemented by devices that manage +observations (currently only subarray devices). + +.. figure:: images/ADR-8.png + :width: 80% + :alt: Diagram of the observation state machine, as decided and published in ADR-8. + + Diagram of the observation state machine, as decided and published in ADR-8. + + +.. figure:: images/ObservationStateMachine.png + :width: 80% + :alt: Diagram of the observation state machine, automatically generated from + the state machine as specified in code. + + Diagram of the observation state machine, automatically generated from + the state machine as specified in code. The equivalance of this + diagram to the diagram previous demonstrates that the machine has been + implemented in conformance with ADR-8. + +API +--- + +.. toctree:: + :maxdepth: 2 + + +.. automodule:: ska.base.state_machine + :members: + :undoc-members: + diff --git a/docs/source/images/BaseDeviceStateMachine.png b/docs/source/images/BaseDeviceStateMachine.png new file mode 100644 index 0000000000000000000000000000000000000000..ca141bc982bdeededdcb9953e14c20c5b582292b Binary files /dev/null and b/docs/source/images/BaseDeviceStateMachine.png differ diff --git a/docs/source/images/ObservationStateMachine.png b/docs/source/images/ObservationStateMachine.png new file mode 100644 index 0000000000000000000000000000000000000000..112c60101272b7c5e33f7f2071af25340fa6d576 Binary files /dev/null and b/docs/source/images/ObservationStateMachine.png differ diff --git a/docs/source/index.rst b/docs/source/index.rst index adec635929c105702b9a76a53ba22effc83ddd0f..9f38ed650a4c8e0c948d6d80cd0a7513f4dcc14c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -22,6 +22,7 @@ Welcome to LMC Base Classes documentation! SKA Control Model<Control_Model> SKA Commands<Commands> + SKA State Machine<State_Machine> Indices and tables ================== @@ -29,4 +30,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/setup.py b/setup.py index 6d6fd488c012119aed6f461836304cf05b34efee..d3ca8bf0e8a33d55f0f1f81deb3dd16f8e6fe5d5 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ setuptools.setup( ], platforms=["OS Independent"], setup_requires=[] + pytest_runner, - install_requires=["future", "ska_logging >= 0.3.0"], + install_requires=["future", "transitions", "ska_logging >= 0.3.0"], tests_require=["pytest", "coverage", "pytest-json-report", "pytest-forked"], entry_points={ "console_scripts": [ diff --git a/src/ska/base/__init__.py b/src/ska/base/__init__.py index cebd343850fee474796a5d3e1514374c0fa1dc40..049a6be104bcccb29eab16282adf8888bfa8f600 100644 --- a/src/ska/base/__init__.py +++ b/src/ska/base/__init__.py @@ -1,12 +1,13 @@ __all__ = ( "commands", "control_model", + "state_machine", "SKAAlarmHandler", "SKABaseDevice", "SKABaseDeviceStateModel", "SKACapability", "SKALogger", "SKAMaster", - "SKAObsDevice", "SKAObsDeviceStateModel", + "SKAObsDevice", "SKASubarray", "SKASubarrayStateModel", "SKASubarrayResourceManager", "SKATelState", ) @@ -21,7 +22,7 @@ from .master_device import SKAMaster from .tel_state_device import SKATelState # SKAObsDevice, and then classes that inherit from it -from .obs_device import SKAObsDevice, SKAObsDeviceStateModel +from .obs_device import SKAObsDevice from .capability_device import SKACapability from .subarray_device import ( SKASubarray, SKASubarrayStateModel, SKASubarrayResourceManager diff --git a/src/ska/base/base_device.py b/src/ska/base/base_device.py index 30852c8c0c30dff6bf93101c09c0eb58d1dd70a7..656922d2a75f48200be9f8b34e9a9b10f5af1881 100644 --- a/src/ska/base/base_device.py +++ b/src/ska/base/base_device.py @@ -19,7 +19,7 @@ import socket import sys import threading import warnings - +from transitions import MachineError from urllib.parse import urlparse from urllib.request import url2pathname @@ -35,10 +35,12 @@ from ska.base.commands import ( ) from ska.base.control_model import ( AdminMode, ControlMode, SimulationMode, TestMode, HealthState, - LoggingLevel, DeviceStateModel + LoggingLevel ) +from ska.base.faults import StateModelError +from ska.base.state_machine import BaseDeviceStateMachine -from ska.base.utils import get_groups_from_json +from ska.base.utils import get_groups_from_json, for_testing_only from ska.base.faults import (GroupDefinitionsError, LoggingTargetError, LoggingLevelError) @@ -307,211 +309,40 @@ class LoggingUtils: # PROTECTED REGION END # // SKABaseDevice.additionnal_import -__all__ = ["SKABaseDevice", "SKABaseDeviceStateModel", "main"] +__all__ = ["SKABaseDevice", "main"] -class SKABaseDeviceStateModel(DeviceStateModel): +class SKABaseDeviceStateModel: """ Implements the state model for the SKABaseDevice """ - __transitions = { - ('UNINITIALISED', 'init_started'): ( - "INIT (ENABLED)", - lambda self: ( - self._set_admin_mode(AdminMode.MAINTENANCE), - self._set_dev_state(DevState.INIT), - ) - ), - ('INIT (ENABLED)', 'init_succeeded'): ( - 'OFF', - lambda self: self._set_dev_state(DevState.OFF) - ), - ('INIT (ENABLED)', 'init_failed'): ( - 'FAULT (ENABLED)', - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('INIT (ENABLED)', 'fatal_error'): ( - "FAULT (ENABLED)", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('INIT (ENABLED)', 'to_notfitted'): ( - "INIT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.NOT_FITTED) - ), - ('INIT (ENABLED)', 'to_offline'): ( - "INIT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.OFFLINE) - ), - ('INIT (ENABLED)', 'to_maintenance'): ( - "INIT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.MAINTENANCE) - ), - ('INIT (ENABLED)', 'to_online'): ( - "INIT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.ONLINE) - ), - ('INIT (DISABLED)', 'init_succeeded'): ( - 'DISABLED', - lambda self: self._set_dev_state(DevState.DISABLE) - ), - ('INIT (DISABLED)', 'init_failed'): ( - 'FAULT (DISABLED)', - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('INIT (DISABLED)', 'fatal_error'): ( - "FAULT (DISABLED)", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('INIT (DISABLED)', 'to_notfitted'): ( - "INIT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.NOT_FITTED) - ), - ('INIT (DISABLED)', 'to_offline'): ( - "INIT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.OFFLINE) - ), - ('INIT (DISABLED)', 'to_maintenance'): ( - "INIT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.MAINTENANCE) - ), - ('INIT (DISABLED)', 'to_online'): ( - "INIT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.ONLINE) - ), - ('FAULT (DISABLED)', 'reset_succeeded'): ( - "DISABLED", - lambda self: self._set_dev_state(DevState.DISABLE) - ), - ('FAULT (DISABLED)', 'reset_failed'): ("FAULT (DISABLED)", None), - ('FAULT (DISABLED)', 'fatal_error'): ("FAULT (DISABLED)", None), - ('FAULT (DISABLED)', 'to_notfitted'): ( - "FAULT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.NOT_FITTED) - ), - ('FAULT (DISABLED)', 'to_offline'): ( - "FAULT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.OFFLINE) - ), - ('FAULT (DISABLED)', 'to_maintenance'): ( - "FAULT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.MAINTENANCE) - ), - ('FAULT (DISABLED)', 'to_online'): ( - "FAULT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.ONLINE) - ), - ('FAULT (ENABLED)', 'reset_succeeded'): ( - "OFF", - lambda self: self._set_dev_state(DevState.OFF) - ), - ('FAULT (ENABLED)', 'reset_failed'): ("FAULT (ENABLED)", None), - ('FAULT (ENABLED)', 'fatal_error'): ("FAULT (ENABLED)", None), - ('FAULT (ENABLED)', 'to_notfitted'): ( - "FAULT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.NOT_FITTED)), - ('FAULT (ENABLED)', 'to_offline'): ( - "FAULT (DISABLED)", - lambda self: self._set_admin_mode(AdminMode.OFFLINE)), - ('FAULT (ENABLED)', 'to_maintenance'): ( - "FAULT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.MAINTENANCE) - ), - ('FAULT (ENABLED)', 'to_online'): ( - "FAULT (ENABLED)", - lambda self: self._set_admin_mode(AdminMode.ONLINE) - ), - ('DISABLED', 'to_offline'): ( - "DISABLED", - lambda self: self._set_admin_mode(AdminMode.OFFLINE) - ), - ('DISABLED', 'to_online'): ( - "OFF", - lambda self: ( - self._set_admin_mode(AdminMode.ONLINE), - self._set_dev_state(DevState.OFF) - ) - ), - ('DISABLED', 'to_maintenance'): ( - "OFF", - lambda self: ( - self._set_admin_mode(AdminMode.MAINTENANCE), - self._set_dev_state(DevState.OFF) - ) - ), - ('DISABLED', 'to_notfitted'): ( - "DISABLED", - lambda self: self._set_admin_mode(AdminMode.NOT_FITTED) - ), - ('DISABLED', 'fatal_error'): ( - "FAULT (DISABLED)", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('OFF', 'to_notfitted'): ( - "DISABLED", - lambda self: ( - self._set_admin_mode(AdminMode.NOT_FITTED), - self._set_dev_state(DevState.DISABLE) - ) - ), - ('OFF', 'to_offline'): ( - "DISABLED", lambda self: ( - self._set_admin_mode(AdminMode.OFFLINE), - self._set_dev_state(DevState.DISABLE) - ) - ), - ('OFF', 'to_online'): ( - "OFF", - lambda self: self._set_admin_mode(AdminMode.ONLINE) - ), - ('OFF', 'to_maintenance'): ( - "OFF", - lambda self: self._set_admin_mode(AdminMode.MAINTENANCE) - ), - ('OFF', 'fatal_error'): ( - "FAULT (ENABLED)", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('OFF', 'on_succeeded'): ( - "ON", - lambda self: self._set_dev_state(DevState.ON) - ), - ('OFF', 'on_failed'): ( - "FAULT (ENABLED)", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('ON', 'off_succeeded'): ( - "OFF", - lambda self: self._set_dev_state(DevState.OFF) - ), - ('ON', 'off_failed'): ( - "FAULT (ENABLED)", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('ON', 'fatal_error'): ( - "FAULT (ENABLED)", - lambda self: self._set_dev_state(DevState.FAULT) - ), - - } - - def __init__(self, dev_state_callback=None, admin_mode_callback=None): + def __init__(self, logger, op_state_callback=None, admin_mode_callback=None): """ Initialises the state model. - :param dev_state_callback: A callback to be called when a - transition implies a change to device state - :type dev_state_callback: callable + :param logger: the logger to be used by this state model. + :type logger: a logger that implements the standard library + logger interface + :param op_state_callback: A callback to be called when a + transition implies a change to op state + :type op_state_callback: callable :param admin_mode_callback: A callback to be called when a transition causes a change to device admin_mode :type admin_mode_callback: callable """ - super().__init__(self.__transitions, "UNINITIALISED") + self.logger = logger + self._op_state = None self._admin_mode = None + + self._op_state_callback = op_state_callback self._admin_mode_callback = admin_mode_callback - self._dev_state = None - self._dev_state_callback = dev_state_callback + + self._state_machine = BaseDeviceStateMachine( + op_state_callback=self._update_op_state, + admin_mode_callback=self._update_admin_mode + ) @property def admin_mode(self): @@ -523,40 +354,124 @@ class SKABaseDeviceStateModel(DeviceStateModel): """ return self._admin_mode - def _set_admin_mode(self, admin_mode): + def _update_admin_mode(self, admin_mode): """ - Helper method: calls the admin_mode callback if one exists + Helper method that updates admin_mode, ensuring that the callback is + called if one exists. - :param admin_mode: the new admin_mode value + :param admin_mode: the new adminMode attribute value :type admin_mode: AdminMode """ if self._admin_mode != admin_mode: self._admin_mode = admin_mode if self._admin_mode_callback is not None: - self._admin_mode_callback(self._admin_mode) + self._admin_mode_callback(admin_mode) @property - def dev_state(self): + def op_state(self): """ - Returns the dev_state + Returns the op_state - :returns: dev_state of this state model + :returns: op_state of this state model :rtype: tango.DevState """ - return self._dev_state + return self._op_state + + def _update_op_state(self, op_state): + """ + Helper method that updates op_state, ensuring that the callback is + called if one exists. - def _set_dev_state(self, dev_state): + :param op_state: the new opState attribute value + :type op_state: tango.DevState """ - Helper method: sets this state models dev_state, and calls the - dev_state callback if one exists + if self._op_state != op_state: + self._op_state = op_state + if self._op_state_callback is not None: + self._op_state_callback(op_state) - :param dev_state: the new state value - :type admin_mode: DevState + def is_action_allowed(self, action): """ - if self._dev_state != dev_state: - self._dev_state = dev_state - if self._dev_state_callback is not None: - self._dev_state_callback(self._dev_state) + Whether a given action is allowed in the current state. + + :param action: an action, as given in the transitions table + :type action: ANY + """ + return action in self._state_machine.get_triggers(self._state_machine.state) + + def try_action(self, action): + """ + Checks whether a given action is allowed in the current state, + and raises a StateModelError if it is not. + + :param action: an action, as given in the transitions table + :type action: ANY + + :raises StateModelError: if the action is not allowed in the + current state + + :returns: True if the action is allowed + :rtype: boolean + """ + if not self.is_action_allowed(action): + raise StateModelError( + f"Action '{action}' not allowed in current state ({self._state_machine.state})." + ) + return True + + def perform_action(self, action): + """ + Performs an action on the state model + + :param action: an action, as given in the transitions table + :type action: ANY + :raises StateModelError: if the action is not allowed in the + current state + + """ + try: + self._state_machine.trigger(action) + except MachineError as error: + raise StateModelError(error) + + @for_testing_only + def _straight_to_state(self, state): + """ + Takes the DeviceStateModel straight to the specified state. This method + exists to simplify testing; for example, if testing that a command may + be run in a given state, one can push the state model straight to that + state, rather than having to drive it to that state through a sequence + of actions. It is not intended that this method would be called outside + of test setups. A warning will be raised if it is. + + Note that states are non-deterministic with respect to adminMode. For + example, in state "FAULT-DISABLED", the adminMode could be OFFLINE or + NOT_FITTED. When you drive the state machine through its transitions, + the adminMode will be set accordingly. When using this method, the + adminMode will simply be set to something sensible. + + :param state: the target state + :type state: string + """ + if state == "UNINITIALISED": + pass + elif "DISABLED" in state: + if self._admin_mode not in [AdminMode.OFFLINE, AdminMode.NOT_FITTED]: + self._state_machine._update_admin_mode(AdminMode.OFFLINE) + else: + if self._admin_mode not in [AdminMode.ONLINE, AdminMode.MAINTENANCE]: + self._state_machine._update_admin_mode(AdminMode.ONLINE) + + getattr(self._state_machine, f"to_{state}")() + + @property + def _state(self): + """ + Returns the state of the underlying state machine. This would normally + be a hidden implementation detail, but is exposed here for testing + purposes. + """ + return self._state_machine.state class SKABaseDevice(Device): @@ -578,8 +493,7 @@ class SKABaseDevice(Device): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKABaseDeviceStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -830,11 +744,25 @@ class SKABaseDevice(Device): self.set_status(f"The device is in {state} state.") def set_state(self, state): + """ + Helper method for setting device state, ensuring that change + events are pushed. + + :param state: the new state + :type state: tango.DevState + """ super().set_state(state) self.push_change_event('state') self.push_archive_event('state') def set_status(self, status): + """ + Helper method for setting device status, ensuring that change + events are pushed. + + :param status: the new status + :type status: str + """ super().set_status(status) self.push_change_event('status') self.push_archive_event('status') @@ -874,17 +802,43 @@ class SKABaseDevice(Device): Creates the state model for the device """ self.state_model = SKABaseDeviceStateModel( - dev_state_callback=self._update_state, + logger=self.logger, + op_state_callback=self._update_state, admin_mode_callback=self._update_admin_mode ) def register_command_object(self, command_name, command_object): + """ + Registers a command object as the object to handle invocations + of a given command + + :param command_name: name of the command for which the object is + being registered + :type command_name: str + :param command_object: the object that will handle invocations + of the given command + :type command_object: Command instance + """ self._command_objects[command_name] = command_object def get_command_object(self, command_name): + """ + Returns the command object (handler) for a given command. + + :param command_name: name of the command for which a command + object (handler) is sought + :type command_name: str + + :return: the registered command object (handler) for the command + :rtype: Command instance + """ return self._command_objects[command_name] def init_command_objects(self): + """ + Creates and registers command objects (handlers) for the + commands supported by this device. + """ device_args = (self, self.state_model, self.logger) self.register_command_object("On", self.OnCommand(*device_args)) @@ -1164,8 +1118,7 @@ class SKABaseDevice(Device): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKABaseDeviceStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -1237,8 +1190,7 @@ class SKABaseDevice(Device): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKABaseDeviceStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -1264,6 +1216,7 @@ class SKABaseDevice(Device): Check if command `On` is allowed in the current device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1301,8 +1254,7 @@ class SKABaseDevice(Device): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKABaseDeviceStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -1328,6 +1280,7 @@ class SKABaseDevice(Device): Check if command `Off` is allowed in the current device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ diff --git a/src/ska/base/commands.py b/src/ska/base/commands.py index 1d6fc20d3cc00bfb3bf5d4c85fe864708574d773..b8d70baa16f4d10d9fe57dfd7f0fdc9649efe2c7 100644 --- a/src/ska/base/commands.py +++ b/src/ska/base/commands.py @@ -153,7 +153,8 @@ class BaseCommand: return self.state_model.try_action(action) except StateModelError as exc: raise CommandError( - f"Error executing command {self.name}") from exc + f"Error executing command {self.name}" + ) from exc def _perform_action(self, action): """ @@ -167,6 +168,12 @@ class BaseCommand: class ResponseCommand(BaseCommand): + """ + Abstract base class for a tango command handler, for commands that + execute a procedure/operation and return a (ResultCode, message) + tuple. + """ + def __call__(self, argin=None): """ What to do when the command is called. This base class simply diff --git a/src/ska/base/control_model.py b/src/ska/base/control_model.py index 6f500d55fb9301572a51b026327033b94162c7db..cfc5075708d6d1f7c54c9a1239a57994ecd19905 100644 --- a/src/ska/base/control_model.py +++ b/src/ska/base/control_model.py @@ -14,13 +14,11 @@ other useful enumerations. """ import enum -from ska.base.faults import StateModelError + # --------------------------------- # Core SKA Control Model attributes # --------------------------------- - - class HealthState(enum.IntEnum): """Python enumerated type for ``healthState`` attribute.""" @@ -328,94 +326,3 @@ class LoggingLevel(enum.IntEnum): WARNING = 3 INFO = 4 DEBUG = 5 - - -class DeviceStateModel: - """ - Base class for the state model used by SKA devices. - """ - - def __init__(self, transitions, initial_state): - """ - Create a new device state model. - - :param transitions: a dictionary for which each key is a (state, - event) tuple, and each value is a (state, side-effect) - tuple. When the device is in state `IN-STATE`, and action - `ACTION` is attempted, the transitions table will be checked - for an entry under key `(IN-STATE, EVENT)`. If no such key - exists, the action will be denied and a model will raise a - `StateModelError`. If the key does exist, then its value - `(OUT-STATE, SIDE-EFFECT)` will result in the model - transitioning to state `OUT-STATE`, and executing - `SIDE-EFFECT`, which must be a function or lambda that - takes a single parameter - a model instance. - :type transitions: dict - :param initial_state: the starting state of the model - :type initial_state: a state with an entry in the transitions - table - """ - # instances may update transitions dict, so need a copy - self._transitions = transitions.copy() - self._state = initial_state - - @property - def state(self): - """Return current state as a string.""" - return self._state - - def update_transitions(self, transitions): - """ - Update the transitions table with new transitions. - - :param transitions: new transitions to be included in the - transitions table. Transitions with pre-existing keys will - replace the transitions for that key. Transitions with novel - keys will be added. There is no provision for removing - transitions - :type transitions: dict - """ - self._transitions.update(transitions) - - def is_action_allowed(self, action): - """ - Whether a given action is allowed in the current state. - - :param action: an action, as given in the transitions table - :type action: ANY - """ - return (self._state, action) in self._transitions - - def try_action(self, action): - """ - Checks whether a given action is allowed in the current state, - and raises a StateModelError if it is not. - - :param action: an action, as given in the transitions table - :type action: ANY - :raises StateModelError: if the action is not allowed in the - current state - :returns: True if the action is allowed - :rtype: boolean - """ - if not self.is_action_allowed(action): - raise StateModelError( - f"Action '{action}' not allowed in current state ({self._state})." - ) - return True - - def perform_action(self, action): - """ - Performs an action on the state model - - :param action: an action, as given in the transitions table - :type action: ANY - :raises StateModelError: if the action is not allowed in the - current state - - """ - self.try_action(action) - - (self._state, side_effect) = self._transitions[(self._state, action)] - if side_effect is not None: - side_effect(self) diff --git a/src/ska/base/obs_device.py b/src/ska/base/obs_device.py index 48245f348788cfcc809d6c15a93dd2de1dcd5feb..9119cc0aeb2ebc65546f8aa74325809a31db3795 100644 --- a/src/ska/base/obs_device.py +++ b/src/ska/base/obs_device.py @@ -14,78 +14,15 @@ instead of just SKABaseDevice. # Additional import # PROTECTED REGION ID(SKAObsDevice.additionnal_import) ENABLED START # # Tango imports -from tango import DevState from tango.server import run, attribute # SKA specific imports -from ska.base import SKABaseDevice, SKABaseDeviceStateModel +from ska.base import SKABaseDevice from ska.base.commands import ResultCode -from ska.base.control_model import AdminMode, ObsMode, ObsState +from ska.base.control_model import ObsMode, ObsState # PROTECTED REGION END # // SKAObsDevice.additionnal_imports -__all__ = ["SKAObsDevice", "SKAObsDeviceStateModel", "main"] - - -class SKAObsDeviceStateModel(SKABaseDeviceStateModel): - """ - Implements the state model for the SKABaseDevice - """ - def __init__( - self, - dev_state_callback=None, - admin_mode_callback=None, - obs_state_callback=None - ): - """ - Initialises the model. Note that this does not imply moving to - INIT state. The INIT state is managed by the model itself. - - :param dev_state_callback: A callback to be called when a - transition implies a change to device state - :type dev_state_callback: callable - :param admin_mode_callback: A callback to be called when a - transition causes a change to device admin_mode - :type admin_mode_callback: callable - :param obs_state_callback: A callback to be called when a - transition causes a change to device obs_state - :type obs_state_callback: callable - """ - super().__init__( - dev_state_callback=dev_state_callback, - admin_mode_callback=admin_mode_callback - ) - self._obs_state_callback = obs_state_callback - - self.update_transitions( - { - ('UNINITIALISED', 'init_started'): ( - "INIT (ENABLED)", - lambda self: ( - self._set_admin_mode(AdminMode.MAINTENANCE), - self._set_dev_state(DevState.INIT), - self._set_obs_state(ObsState.EMPTY) - ) - ) - } - ) - self._obs_state = None - - def _set_obs_state(self, obs_state): - """ - Helper method: set the value of obs_state value, and calls the - obs_state_callback if one exists. - - :param obs_state: the new obs_state value - :type obs_state: ObsState - """ - if self._obs_state != obs_state: - self._obs_state = obs_state - if self._obs_state_callback is not None: - self._obs_state_callback(self._obs_state) - - @property - def obs_state(self): - return self._obs_state +__all__ = ["SKAObsDevice", "main"] class SKAObsDevice(SKABaseDevice): @@ -111,6 +48,7 @@ class SKAObsDevice(SKABaseDevice): device.set_change_event("obsState", True, True) device.set_archive_event("obsState", True, True) + device._obs_state = ObsState.EMPTY device._obs_mode = ObsMode.IDLE device._config_progress = 0 device._config_delay_expected = 0 @@ -166,26 +104,27 @@ class SKAObsDevice(SKABaseDevice): :param obs_state: the new obs_state value :type obs_state: ObsState """ + self._obs_state = obs_state self.push_change_event("obsState", obs_state) self.push_archive_event("obsState", obs_state) - def _init_state_model(self): - """ - Sets up the state model for the device - """ - self.state_model = SKAObsDeviceStateModel( - dev_state_callback=self._update_state, - admin_mode_callback=self._update_admin_mode, - obs_state_callback=self._update_obs_state - ) - def always_executed_hook(self): # PROTECTED REGION ID(SKAObsDevice.always_executed_hook) ENABLED START # + """ + Method that is always executed before any device command gets executed. + + :return: None + """ pass # PROTECTED REGION END # // SKAObsDevice.always_executed_hook def delete_device(self): # PROTECTED REGION ID(SKAObsDevice.delete_device) ENABLED START # + """ + Method to cleanup when device is stopped. + + :return: None + """ pass # PROTECTED REGION END # // SKAObsDevice.delete_device @@ -196,7 +135,7 @@ class SKAObsDevice(SKABaseDevice): def read_obsState(self): # PROTECTED REGION ID(SKAObsDevice.obsState_read) ENABLED START # """Reads Observation State of the device""" - return self.state_model.obs_state + return self._obs_state # PROTECTED REGION END # // SKAObsDevice.obsState_read def read_obsMode(self): diff --git a/src/ska/base/release.py b/src/ska/base/release.py index 1afbbc52106869070e5e007bea9f57606139e4eb..22e3240fb99e973a8ee33e49ae2fa2b3df5249cb 100644 --- a/src/ska/base/release.py +++ b/src/ska/base/release.py @@ -7,7 +7,7 @@ """Release information for lmc-base-classes Python Package""" name = """lmcbaseclasses""" -version = "0.6.3" +version = "0.6.4" version_info = version.split(".") description = """A set of generic base devices for SKA Telescope.""" author = "SKA India and SARAO and CSIRO" diff --git a/src/ska/base/state_machine.py b/src/ska/base/state_machine.py new file mode 100644 index 0000000000000000000000000000000000000000..044a97df199f88fd176b370b1e31192801d847d7 --- /dev/null +++ b/src/ska/base/state_machine.py @@ -0,0 +1,469 @@ +""" +This module contains specifications of SKA state machines. +""" +from transitions import Machine, State +from tango import DevState + +from ska.base.control_model import AdminMode, ObsState + + +class BaseDeviceStateMachine(Machine): + """ + State machine for an SKA base device. Supports ON and OFF states, + states, plus initialisation and fault states, and + also the basic admin modes. + """ + + def __init__(self, op_state_callback=None, admin_mode_callback=None): + """ + Initialises the state model. + + :param op_state_callback: A callback to be called when a + transition implies a change to op state + :type op_state_callback: callable + :param admin_mode_callback: A callback to be called when a + transition causes a change to device admin_mode + :type admin_mode_callback: callable + """ + self._admin_mode = None + self._admin_mode_callback = admin_mode_callback + self._op_state = None + self._op_state_callback = op_state_callback + + states = [ + State("UNINITIALISED"), + State("INIT_ENABLED", on_enter="_init_entered"), + State("INIT_DISABLED", on_enter="_init_entered"), + State("FAULT_ENABLED", on_enter="_fault_entered"), + State("FAULT_DISABLED", on_enter="_fault_entered"), + State("DISABLED", on_enter="_disabled_entered"), + State("OFF", on_enter="_off_entered"), + State("ON", on_enter="_on_entered"), + ] + + transitions = [ + { + "source": "UNINITIALISED", + "trigger": "init_started", + "dest": "INIT_ENABLED", + "after": self._maintenance_callback, + }, + { + "source": ["INIT_ENABLED", "OFF", "FAULT_ENABLED", "ON"], + "trigger": "fatal_error", + "dest": "FAULT_ENABLED", + }, + { + "source": ["INIT_DISABLED", "DISABLED", "FAULT_DISABLED"], + "trigger": "fatal_error", + "dest": "FAULT_DISABLED", + }, + { + "source": "INIT_ENABLED", + "trigger": "init_succeeded", + "dest": "OFF", + }, + { + "source": "INIT_DISABLED", + "trigger": "init_succeeded", + "dest": "DISABLED", + }, + { + "source": "INIT_ENABLED", + "trigger": "init_failed", + "dest": "FAULT_ENABLED", + }, + { + "source": "INIT_DISABLED", + "trigger": "init_failed", + "dest": "FAULT_DISABLED", + }, + { + "source": ["INIT_ENABLED", "INIT_DISABLED"], + "trigger": "to_notfitted", + "dest": "INIT_DISABLED", + "after": self._not_fitted_callback + }, + { + "source": ["INIT_ENABLED", "INIT_DISABLED"], + "trigger": "to_offline", + "dest": "INIT_DISABLED", + "after": self._offline_callback + }, + { + "source": ["INIT_ENABLED", "INIT_DISABLED"], + "trigger": "to_maintenance", + "dest": "INIT_ENABLED", + "after": self._maintenance_callback + }, + { + "source": ["INIT_ENABLED", "INIT_DISABLED"], + "trigger": "to_online", + "dest": "INIT_ENABLED", + "after": self._online_callback + }, + { + "source": "FAULT_DISABLED", + "trigger": "reset_succeeded", + "dest": "DISABLED", + }, + { + "source": "FAULT_ENABLED", + "trigger": "reset_succeeded", + "dest": "OFF", + }, + { + "source": ["FAULT_DISABLED", "FAULT_ENABLED"], + "trigger": "reset_failed", + "dest": None, + }, + { + "source": ["FAULT_DISABLED", "FAULT_ENABLED"], + "trigger": "to_notfitted", + "dest": "FAULT_DISABLED", + "after": self._not_fitted_callback + }, + { + "source": ["FAULT_DISABLED", "FAULT_ENABLED"], + "trigger": "to_offline", + "dest": "FAULT_DISABLED", + "after": self._offline_callback + }, + { + "source": ["FAULT_DISABLED", "FAULT_ENABLED"], + "trigger": "to_maintenance", + "dest": "FAULT_ENABLED", + "after": self._maintenance_callback + }, + { + "source": ["FAULT_DISABLED", "FAULT_ENABLED"], + "trigger": "to_online", + "dest": "FAULT_ENABLED", + "after": self._online_callback + }, + { + "source": "DISABLED", + "trigger": "to_notfitted", + "dest": "DISABLED", + "after": self._not_fitted_callback + }, + { + "source": "DISABLED", + "trigger": "to_offline", + "dest": "DISABLED", + "after": self._offline_callback + }, + { + "source": "DISABLED", + "trigger": "to_maintenance", + "dest": "OFF", + "after": self._maintenance_callback + }, + { + "source": "DISABLED", + "trigger": "to_online", + "dest": "OFF", + "after": self._online_callback + }, + { + "source": "OFF", + "trigger": "to_notfitted", + "dest": "DISABLED", + "after": self._not_fitted_callback + }, + { + "source": "OFF", + "trigger": "to_offline", + "dest": "DISABLED", + "after": self._offline_callback + }, + { + "source": "OFF", + "trigger": "to_maintenance", + "dest": "OFF", + "after": self._maintenance_callback + }, + { + "source": "OFF", + "trigger": "to_online", + "dest": "OFF", + "after": self._online_callback + }, + { + "source": "OFF", + "trigger": "on_succeeded", + "dest": "ON", + }, + { + "source": "OFF", + "trigger": "on_failed", + "dest": "FAULT_ENABLED", + }, + { + "source": "ON", + "trigger": "off_succeeded", + "dest": "OFF", + }, + { + "source": "ON", + "trigger": "off_failed", + "dest": "FAULT_ENABLED", + }, + ] + + super().__init__( + states=states, + initial="UNINITIALISED", + transitions=transitions, + ) + + def _init_entered(self): + """ + called when the state machine enters the "" state. + """ + self._update_op_state(DevState.INIT) + + def _fault_entered(self): + """ + called when the state machine enters the "" state. + """ + self._update_op_state(DevState.FAULT) + + def _disabled_entered(self): + """ + called when the state machine enters the "" state. + """ + self._update_op_state(DevState.DISABLE) + + def _off_entered(self): + """ + called when the state machine enters the "" state. + """ + self._update_op_state(DevState.OFF) + + def _on_entered(self): + """ + called when the state machine enters the "" state. + """ + self._update_op_state(DevState.ON) + + def _not_fitted_callback(self): + """ + callback called when the state machine is set to admin mode + NOT FITTED + """ + self._update_admin_mode(AdminMode.NOT_FITTED) + + def _offline_callback(self): + """ + callback called when the state machine is set to admin mode + OFFLINE + """ + self._update_admin_mode(AdminMode.OFFLINE) + + def _maintenance_callback(self): + """ + callback called when the state machine is set to admin mode + MAINTENANCE + """ + self._update_admin_mode(AdminMode.MAINTENANCE) + + def _online_callback(self): + """ + callback called when the state machine is set to admin mode + online + """ + self._update_admin_mode(AdminMode.ONLINE) + + def _update_admin_mode(self, admin_mode): + """ + Helper method: calls the admin_mode callback if one exists + + :param admin_mode: the new admin_mode value + :type admin_mode: AdminMode + """ + if self._admin_mode != admin_mode: + self._admin_mode = admin_mode + if self._admin_mode_callback is not None: + self._admin_mode_callback(self._admin_mode) + + def _update_op_state(self, op_state): + """ + Helper method: sets this state models op_state, and calls the + op_state callback if one exists + + :param op_state: the new op state value + :type op_state: DevState + """ + if self._op_state != op_state: + self._op_state = op_state + if self._op_state_callback is not None: + self._op_state_callback(self._op_state) + + +class ObservationStateMachine(Machine): + """ + The observation state machine used by an observing subarray, per + ADR-8. + """ + + def __init__(self, obs_state_callback=None): + """ + Initialises the model. + + :param obs_state_callback: A callback to be called when a + transition causes a change to device obs_state + :type obs_state_callback: callable + """ + self._obs_state = ObsState.EMPTY + self._obs_state_callback = obs_state_callback + + states = [obs_state.name for obs_state in ObsState] + transitions = [ + { + "source": "*", + "trigger": "fatal_error", + "dest": ObsState.FAULT.name, + }, + { + "source": [ObsState.EMPTY.name, ObsState.IDLE.name], + "trigger": "assign_started", + "dest": ObsState.RESOURCING.name, + }, + { + "source": ObsState.IDLE.name, + "trigger": "release_started", + "dest": ObsState.RESOURCING.name, + }, + { + "source": ObsState.RESOURCING.name, + "trigger": "resourcing_succeeded_some_resources", + "dest": ObsState.IDLE.name, + }, + { + "source": ObsState.RESOURCING.name, + "trigger": "resourcing_succeeded_no_resources", + "dest": ObsState.EMPTY.name, + }, + { + "source": ObsState.RESOURCING.name, + "trigger": "resourcing_failed", + "dest": ObsState.FAULT.name, + }, + { + "source": [ObsState.IDLE.name, ObsState.READY.name], + "trigger": "configure_started", + "dest": ObsState.CONFIGURING.name, + }, + { + "source": ObsState.CONFIGURING.name, + "trigger": "configure_succeeded", + "dest": ObsState.READY.name, + }, + { + "source": ObsState.CONFIGURING.name, + "trigger": "configure_failed", + "dest": ObsState.FAULT.name, + }, + { + "source": ObsState.READY.name, + "trigger": "end_succeeded", + "dest": ObsState.IDLE.name, + }, + { + "source": ObsState.READY.name, + "trigger": "end_failed", + "dest": ObsState.FAULT.name, + }, + { + "source": ObsState.READY.name, + "trigger": "scan_started", + "dest": ObsState.SCANNING.name, + }, + { + "source": ObsState.SCANNING.name, + "trigger": "scan_succeeded", + "dest": ObsState.READY.name, + }, + { + "source": ObsState.SCANNING.name, + "trigger": "scan_failed", + "dest": ObsState.FAULT.name, + }, + { + "source": ObsState.SCANNING.name, + "trigger": "end_scan_succeeded", + "dest": ObsState.READY.name, + }, + { + "source": ObsState.SCANNING.name, + "trigger": "end_scan_failed", + "dest": ObsState.FAULT.name, + }, + { + "source": [ + ObsState.IDLE.name, ObsState.CONFIGURING.name, + ObsState.READY.name, ObsState.SCANNING.name, + ], + "trigger": "abort_started", + "dest": ObsState.ABORTING.name, + }, + { + "source": ObsState.ABORTING.name, + "trigger": "abort_succeeded", + "dest": ObsState.ABORTED.name, + }, + { + "source": ObsState.ABORTING.name, + "trigger": "abort_failed", + "dest": ObsState.FAULT.name, + }, + { + "source": [ObsState.ABORTED.name, ObsState.FAULT.name], + "trigger": "obs_reset_started", + "dest": ObsState.RESETTING.name, + }, + { + "source": ObsState.RESETTING.name, + "trigger": "obs_reset_succeeded", + "dest": ObsState.IDLE.name, + }, + { + "source": ObsState.RESETTING.name, + "trigger": "obs_reset_failed", + "dest": ObsState.FAULT.name, + }, + { + "source": [ObsState.ABORTED.name, ObsState.FAULT.name], + "trigger": "restart_started", + "dest": ObsState.RESTARTING.name, + }, + { + "source": ObsState.RESTARTING.name, + "trigger": "restart_succeeded", + "dest": ObsState.EMPTY.name, + }, + { + "source": ObsState.RESTARTING.name, + "trigger": "restart_failed", + "dest": ObsState.FAULT.name, + }, + ] + + super().__init__( + states=states, + initial=ObsState.EMPTY.name, + transitions=transitions, + after_state_change=self._obs_state_changed + ) + + def _obs_state_changed(self): + """ + State machine callback that is called every time the obs_state + changes. Responsible for ensuring that callbacks are called. + """ + obs_state = ObsState[self.state] + if self._obs_state != obs_state: + self._obs_state = obs_state + if self._obs_state_callback is not None: + self._obs_state_callback(self._obs_state) diff --git a/src/ska/base/subarray_device.py b/src/ska/base/subarray_device.py index 54407ad74c6059d12a6ae22f4f377d519becbb9f..9a0fe49319e1973b121e67b3f3517b9bd8a557d1 100644 --- a/src/ska/base/subarray_device.py +++ b/src/ska/base/subarray_device.py @@ -12,216 +12,34 @@ information like assigned resources, configured capabilities, etc. """ # PROTECTED REGION ID(SKASubarray.additionnal_import) ENABLED START # import json +import warnings from tango import DebugIt -from tango import DevState from tango.server import run, attribute, command from tango.server import device_property # SKA specific imports -from ska.base import SKAObsDevice, SKAObsDeviceStateModel +from ska.base import SKAObsDevice, SKABaseDeviceStateModel from ska.base.commands import ActionCommand, ResultCode -from ska.base.control_model import ObsState -from ska.base.faults import CapabilityValidationError +from ska.base.control_model import AdminMode, ObsState +from ska.base.faults import CapabilityValidationError, StateModelError +from ska.base.state_machine import ObservationStateMachine +from ska.base.utils import for_testing_only + # PROTECTED REGION END # // SKASubarray.additionnal_imports -__all__ = ["SKASubarray", "SKASubarrayResourceManager", "SKASubarrayStateModel", "main"] +__all__ = ["SKASubarray", "main"] -class SKASubarrayStateModel(SKAObsDeviceStateModel): +class SKASubarrayStateModel(SKABaseDeviceStateModel): """ Implements the state model for the SKASubarray """ - __transitions = { - ('OFF', 'on_succeeded'): ( - "EMPTY", - lambda self: self._set_dev_state(DevState.ON) - ), - ('OFF', 'on_failed'): ( - "FAULT", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('EMPTY', 'off_succeeded'): ( - "OFF", - lambda self: self._set_dev_state(DevState.OFF) - ), - ('EMPTY', 'off_failed'): ( - "FAULT", - lambda self: self._set_dev_state(DevState.FAULT) - ), - ('EMPTY', 'assign_started'): ( - "RESOURCING", - lambda self: self._set_obs_state(ObsState.RESOURCING) - ), - ('EMPTY', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('RESOURCING', 'resourcing_succeeded_some_resources'): ( - "IDLE", - lambda self: self._set_obs_state(ObsState.IDLE) - ), - ('RESOURCING', 'resourcing_succeeded_no_resources'): ( - "EMPTY", - lambda self: self._set_obs_state(ObsState.EMPTY) - ), - ('RESOURCING', 'resourcing_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('RESOURCING', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('IDLE', 'assign_started'): ( - "RESOURCING", - lambda self: self._set_obs_state(ObsState.RESOURCING) - ), - ('IDLE', 'release_started'): ( - "RESOURCING", - lambda self: self._set_obs_state(ObsState.RESOURCING) - ), - ('IDLE', 'configure_started'): ( - "CONFIGURING", - lambda self: self._set_obs_state(ObsState.CONFIGURING) - ), - ('IDLE', 'abort_started'): ( - "ABORTING", - lambda self: self._set_obs_state(ObsState.ABORTING) - ), - ('IDLE', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('CONFIGURING', 'configure_succeeded'): ( - "READY", - lambda self: self._set_obs_state(ObsState.READY) - ), - ('CONFIGURING', 'configure_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('CONFIGURING', 'abort_started'): ( - "ABORTING", - lambda self: self._set_obs_state(ObsState.ABORTING) - ), - ('CONFIGURING', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('READY', 'end_succeeded'): ( - "IDLE", - lambda self: self._set_obs_state(ObsState.IDLE) - ), - ('READY', 'end_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('READY', 'configure_started'): ( - "CONFIGURING", - lambda self: self._set_obs_state(ObsState.CONFIGURING) - ), - ('READY', 'abort_started'): ( - "ABORTING", - lambda self: self._set_obs_state(ObsState.ABORTING) - ), - ('READY', 'scan_started'): ( - "SCANNING", - lambda self: self._set_obs_state(ObsState.SCANNING) - ), - ('READY', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('SCANNING', 'scan_succeeded'): ( - "READY", - lambda self: self._set_obs_state(ObsState.READY) - ), - ('SCANNING', 'scan_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('SCANNING', 'end_scan_succeeded'): ( - "READY", - lambda self: self._set_obs_state(ObsState.READY) - ), - ('SCANNING', 'end_scan_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('SCANNING', 'abort_started'): ( - "ABORTING", - lambda self: self._set_obs_state(ObsState.ABORTING) - ), - ('SCANNING', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('ABORTING', 'abort_succeeded'): ( - "ABORTED", - lambda self: self._set_obs_state(ObsState.ABORTED) - ), - ('ABORTING', 'abort_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('ABORTING', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('ABORTED', 'obs_reset_started'): ( - "RESETTING", - lambda self: self._set_obs_state(ObsState.RESETTING) - ), - ('ABORTED', 'restart_started'): ( - "RESTARTING", - lambda self: self._set_obs_state(ObsState.RESTARTING) - ), - ('ABORTED', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('OBSFAULT', 'obs_reset_started'): ( - "RESETTING", - lambda self: self._set_obs_state(ObsState.RESETTING) - ), - ('OBSFAULT', 'restart_started'): ( - "RESTARTING", - lambda self: self._set_obs_state(ObsState.RESTARTING) - ), - ('OBSFAULT', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('RESETTING', 'obs_reset_succeeded'): ( - "IDLE", - lambda self: self._set_obs_state(ObsState.IDLE) - ), - ('RESETTING', 'obs_reset_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('RESETTING', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('RESTARTING', 'restart_succeeded'): ( - "EMPTY", - lambda self: self._set_obs_state(ObsState.EMPTY) - ), - ('RESTARTING', 'restart_failed'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - ('RESTARTING', 'fatal_error'): ( - "OBSFAULT", - lambda self: self._set_obs_state(ObsState.FAULT) - ), - } def __init__( self, - dev_state_callback=None, + logger, + op_state_callback=None, admin_mode_callback=None, obs_state_callback=None ): @@ -229,9 +47,12 @@ class SKASubarrayStateModel(SKAObsDeviceStateModel): Initialises the model. Note that this does not imply moving to INIT state. The INIT state is managed by the model itself. - :param dev_state_callback: A callback to be called when a - transition implies a change to device state - :type dev_state_callback: callable + :param logger: the logger to be used by this state model. + :type logger: a logger that implements the standard library + logger interface + :param op_state_callback: A callback to be called when a + transition implies a change to op state + :type op_state_callback: callable :param admin_mode_callback: A callback to be called when a transition causes a change to device admin_mode :type admin_mode_callback: callable @@ -240,11 +61,147 @@ class SKASubarrayStateModel(SKAObsDeviceStateModel): :type obs_state_callback: callable """ super().__init__( - dev_state_callback=dev_state_callback, + logger, + op_state_callback=op_state_callback, admin_mode_callback=admin_mode_callback, - obs_state_callback=obs_state_callback ) - self.update_transitions(self.__transitions) + + self._obs_state = ObsState.EMPTY + self._obs_state_callback = obs_state_callback + + self._observation_state_machine = ObservationStateMachine( + self._update_obs_state + ) + + @property + def obs_state(self): + """ + Returns the obs_state + + :returns: obs_state of this state model + :rtype: ObsState + """ + return self._obs_state + + def _update_obs_state(self, obs_state): + """ + Helper method that updates obs_state, ensuring that the callback + is called if one exists. + + :param obs_state: the new obsState attribute value + :type obs_state: ObsState + """ + if self._obs_state != obs_state: + self._obs_state = obs_state + if self._obs_state_callback is not None: + self._obs_state_callback(obs_state) + + def is_action_allowed(self, action): + """ + Whether a given action is allowed in the current state. + + :param action: an action, as given in the transitions table + :type action: ANY + """ + if self._state_machine.state == "ON": + if action in self._observation_state_machine.get_triggers( + self._observation_state_machine.state + ): + return True + + return action in self._state_machine.get_triggers(self._state_machine.state) + + def perform_action(self, action): + """ + Performs an action on the state model + + :param action: an action, as given in the transitions table + :type action: ANY + + :raises StateModelError: if the action is not allowed in the + current state + + """ + if self._state_machine.state == "ON": + if action in self._observation_state_machine.get_triggers( + self._observation_state_machine.state + ): + self._observation_state_machine.trigger(action) + return + + if action in self._state_machine.get_triggers( + self._state_machine.state + ): + if self._observation_state_machine.state != "EMPTY": + message = ( + "Changing device state of a non-EMPTY observing device " + "should only be done as an emergency measure and may be " + "disallowed in future." + ) + self.logger.warning(message) + warnings.warn(message, PendingDeprecationWarning) + self._observation_state_machine.to_EMPTY() + self._state_machine.trigger(action) + return + + raise StateModelError( + f"Action {action} is not allowed in device state " + "{self._state_machine.state}, observation_state " + "{self._observation_state_machine.state}." + ) + + @for_testing_only + def _straight_to_state(self, state): + """ + Takes the DeviceStateModel straight to the specified state. This method + exists to simplify testing; for example, if testing that a command may + be run in a given state, one can push the state model straight to that + state, rather than having to drive it to that state through a sequence + of actions. It is not intended that this method would be called outside + of test setups. A warning is raised if it is. + + Note that states are non-deterministics with respect to adminMode. For + example, in state "FAULT-DISABLED", the adminMode could be OFFLINE or + NOT_FITTED. When you drive the state machine through its transitions, + the adminMode will be set accordingly. When using this method, the + adminMode will simply be set to something sensible. + + :param state: the target state + :type state: string + """ + if state == "UNINITIALISED": + pass + elif "DISABLED" in state: + if self._admin_mode not in [AdminMode.OFFLINE, AdminMode.NOT_FITTED]: + self._state_machine._update_admin_mode(AdminMode.OFFLINE) + else: + if self._admin_mode not in [AdminMode.ONLINE, AdminMode.MAINTENANCE]: + self._state_machine._update_admin_mode(AdminMode.ONLINE) + + to_state = getattr(self._observation_state_machine, f"to_{state}", None) + if to_state is not None: + self._state_machine.to_ON() + to_state() + return + + to_state = getattr(self._state_machine, f"to_{state}", None) + if to_state is not None: + self._observation_state_machine.to_EMPTY() + to_state() + return + + raise StateModelError(f"No such state {state}") + + @property + def _state(self): + """ + Returns the state of the underlying state machine. This would normally + be a hidden implementation detail, but is exposed here for testing + purposes. + """ + if self._state_machine.state == "ON": + return self._observation_state_machine.state + return self._state_machine.state class SKASubarrayResourceManager: @@ -275,6 +232,7 @@ class SKASubarrayResourceManager: :todo: Currently implemented for testing purposes to take a JSON string encoding a dictionary with key 'example'. In future this will take a collection of resources. + :param resources: JSON-encoding of a dictionary, with resources to assign under key 'example' :type resources: JSON string @@ -290,6 +248,7 @@ class SKASubarrayResourceManager: :todo: Currently implemented for testing purposes to take a JSON string encoding a dictionary with key 'example'. In future this will take a collection of resources. + :param resources: JSON-encoding of a dictionary, with resources to assign under key 'example' :type resources: JSON string @@ -369,8 +328,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel instance :param action_hook: a hook for the command, used to build actions that will be sent to the state model; for example, if the hook is "scan", then success of the command will @@ -418,8 +376,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel instance :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -433,6 +390,7 @@ class SKASubarray(SKAObsDevice): :param argin: The resources to be assigned :type argin: list of str + :return: A tuple containing a return code and a string message indicating status. The message is for information purpose only. @@ -460,8 +418,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel instance :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -475,6 +432,7 @@ class SKASubarray(SKAObsDevice): :param argin: The resources to be released :type argin: list of str + :return: A tuple containing a return code and a string message indicating status. The message is for information purpose only. @@ -527,8 +485,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel instance :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -544,6 +501,7 @@ class SKASubarray(SKAObsDevice): :param argin: The configuration as JSON :type argin: str + :return: A tuple containing a return code and a string message indicating status. The message is for information purpose only. @@ -582,8 +540,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -599,6 +556,7 @@ class SKASubarray(SKAObsDevice): :param argin: Scan info :type argin: str + :return: A tuple containing a return code and a string message indicating status. The message is for information purpose only. @@ -624,8 +582,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -661,8 +618,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -701,8 +657,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -740,8 +695,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -787,8 +741,7 @@ class SKASubarray(SKAObsDevice): :param state_model: the state model that this command uses to check that it is allowed to run, and that it drives with actions. - :type state_model: SKABaseClassStateModel or a subclass of - same + :type state_model: SKASubarrayStateModel :param logger: the logger to be used by this Command. If not provided, then a default module logger will be used. :type logger: a logger that implements the standard library @@ -828,7 +781,8 @@ class SKASubarray(SKAObsDevice): Sets up the state model for the device """ self.state_model = SKASubarrayStateModel( - dev_state_callback=self._update_state, + logger=self.logger, + op_state_callback=self._update_state, admin_mode_callback=self._update_admin_mode, obs_state_callback=self._update_obs_state ) @@ -885,6 +839,7 @@ class SKASubarray(SKAObsDevice): :param capability_types: a list strings representing capability types. :type capability_types: list + :raises ValueError: If any of the capabilities requested are not valid. """ @@ -946,11 +901,21 @@ class SKASubarray(SKAObsDevice): # --------------- def always_executed_hook(self): # PROTECTED REGION ID(SKASubarray.always_executed_hook) ENABLED START # + """ + Method that is always executed before any device command gets executed. + + :return: None + """ pass # PROTECTED REGION END # // SKASubarray.always_executed_hook def delete_device(self): # PROTECTED REGION ID(SKASubarray.delete_device) ENABLED START # + """ + Method to cleanup when device is stopped. + + :return: None + """ pass # PROTECTED REGION END # // SKASubarray.delete_device @@ -1003,6 +968,7 @@ class SKASubarray(SKAObsDevice): device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1036,6 +1002,7 @@ class SKASubarray(SKAObsDevice): device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1069,6 +1036,7 @@ class SKASubarray(SKAObsDevice): device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1100,6 +1068,7 @@ class SKASubarray(SKAObsDevice): device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1132,6 +1101,7 @@ class SKASubarray(SKAObsDevice): Check if command `Scan` is allowed in the current device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1164,6 +1134,7 @@ class SKASubarray(SKAObsDevice): Check if command `EndScan` is allowed in the current device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1191,6 +1162,7 @@ class SKASubarray(SKAObsDevice): Check if command `End` is allowed in the current device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1219,6 +1191,7 @@ class SKASubarray(SKAObsDevice): Check if command `Abort` is allowed in the current device state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1248,6 +1221,7 @@ class SKASubarray(SKAObsDevice): state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ @@ -1276,6 +1250,7 @@ class SKASubarray(SKAObsDevice): state. :raises ``tango.DevFailed``: if the command is not allowed + :return: ``True`` if the command is allowed :rtype: boolean """ diff --git a/src/ska/base/utils.py b/src/ska/base/utils.py index a1fe8ca0abce482b70152fd1ff21df306e28c44a..c5fd840835e34ee8dd913398a55b82cae271a242 100644 --- a/src/ska/base/utils.py +++ b/src/ska/base/utils.py @@ -1,11 +1,13 @@ """General utilities that may be useful to SKA devices and clients.""" from builtins import str import ast +import functools import inspect import json import pydoc import traceback import sys +import warnings from datetime import datetime @@ -462,3 +464,26 @@ def convert_dict_to_list(dictionary): the_list.append("{}:{}".format(key, value)) return sorted(the_list) + + +def for_testing_only(func, _testing_check=lambda: 'pytest' in sys.modules): + """ + A decorator that marks a function as available for testing purposes only. + If the decorated function is called outside of testing, a warning is raised. + + Testing this decorator leads to a Godelian paradox: how to test that a + warning is raised when we are not testing. Monkeypatching sys.modules would + break everything, so instead, the condition that we evaluate to decide + whether we are testing or not is exposed through a `_testing_check` + argument, allowing for it to be replaced in testing. (The `_testing_check` + argument is inaccessible via the @-syntax, which is a nice bonus.) + """ + @functools.wraps(func) + def wrapper(*args, **kwargs): + """ + Function wrapper for `testing_only` decorator. + """ + if not _testing_check(): + warnings.warn(f"{func.__name__} should only be used for testing purposes.") + return func(*args, **kwargs) + return wrapper diff --git a/tests/conftest.py b/tests/conftest.py index 081bfde7aa24b99c8ba7d5d5827d75552ed536fb..f3299213d1964c75f26dcd1fad3b324c8f712605 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,238 @@ A module defining a list of fixtures that are shared across all ska.base tests. """ import importlib +import itertools +import json import pytest from queue import Empty, Queue +from transitions import MachineError from tango import EventType from tango.test_context import DeviceTestContext +def pytest_configure(config): + """ + pytest hook, used here to register custom "state_machine_tester" marks + """ + config.addinivalue_line( + "markers", + "state_machine_tester: indicate that this class is state machine " + "tester class, and tests should be parameterised by the states and " + "actions in the specification provided in its argument." + ) + + +def pytest_generate_tests(metafunc): + """ + pytest hook that generates tests; this hook ensures that any test + class that is marked with the `state_machine_tester` custom marker + will have its tests parameterised by the states and actions in the + specification provided by that mark + """ + # called once per each test function + mark = metafunc.definition.get_closest_marker("state_machine_tester") + if mark: + spec = mark.args[0] + states = set() + triggers = set() + expected = {} + + for (from_state, trigger, to_state) in spec: + states.add(from_state) + states.add(to_state) + triggers.add(trigger) + expected[(from_state, trigger)] = to_state + + states = sorted(states) + triggers = sorted(triggers) + + metafunc.parametrize( + "state_under_test, action_under_test, expected_state", + [ + ( + state, + trigger, + expected[(state, trigger)] if (state, trigger) in expected else None + ) for (state, trigger) in itertools.product(states, triggers) + ] + ) + + +class StateMachineTester: + """ + Abstract base class for a class for testing state machines + """ + + def test_state_machine( + self, machine, state_under_test, action_under_test, expected_state, + ): + """ + Implements the unit test for a state machine: for a given + initial state and an action, does execution of that action, from + that state, yield the expected results? If the action was + allowed from that state, does the machine transition to the + correct state? If the action was not allowed from that state, + does the machine reject the action (e.g. raise an exception or + return an error code) and remain in the current state? + + :param machine: the state machine under test + :type machine: state machine object instance + :param state_under_test: the state from which the + `action_under_test` is being tested + :type state_under_test: string + :param action_under_test: the action being tested from the + `state_under_test` + :type action_under_test: string + :param expected_state: the state to which the machine is + expected to transition, as a result of performing the + `action_under_test` in the `state_under_test`. If None, then + the action should be disallowed and result in no change of + state. + :type expected_state: string + + """ + # Put the device into the state under test + self.to_state(machine, state_under_test) + + # Check that we are in the state under test + self.assert_state(machine, state_under_test) + + # Test that the action under test does what we expect it to + if expected_state is None: + # Action should fail and the state should not change + self.check_action_disallowed(machine, action_under_test) + self.assert_state(machine, state_under_test) + else: + # Action should succeed + self.perform_action(machine, action_under_test) + self.assert_state(machine, expected_state) + + def assert_state(self, machine, state): + """ + Abstract method for asserting the current state of the state + machine under test + + :param machine: the state machine under test + :type machine: state machine object instance + :param state: the state that we are asserting to be the current + state of the state machine under test + :type state: string + """ + raise NotImplementedError() + + def perform_action(self, machine, action): + """ + Abstract method for performing an action on the state machine + + :param machine: the state machine under test + :type machine: state machine object instance + :param action: action to be performed on the state machine + :type action: str + """ + raise NotImplementedError() + + def check_action_disallowed(self, machine, action): + """ + Abstract method for asserting that an action fails if performed + on the state machine under test in its current state. + + :param machine: the state machine under test + :type machine: state machine object instance + :param action: action to be performed on the state machine + :type action: str + """ + raise NotImplementedError() + + def to_state(self, machine, target_state): + """ + Abstract method for getting the state machine into a target + state. + + :param machine: the state machine under test + :type machine: state machine object instance + :param target_state: the state that we want to get the state + machine under test into + :type target_state: str + """ + raise NotImplementedError() + + +class TransitionsStateMachineTester(StateMachineTester): + """ + Concrete implementation of a StateMachineTester for a pytransitions + state machine (with autotransitions turned on). The states and + actions in the state machine specification must correspond exactly + with the machine's states and triggers. + """ + + def assert_state(self, machine, state): + """ + Assert the current state of the state machine under test. + + :param machine: the state machine under test + :type machine: state machine object instance + :param state: the state that we are asserting to be the current + state of the state machine under test + :type state: str + """ + assert machine.state == state + + def perform_action(self, machine, action): + """ + Perform a given action on the state machine under test. + + :param machine: the state machine under test + :type machine: state machine object instance + :param action: action to be performed on the state machine + :type action: str + """ + machine.trigger(action) + + def check_action_disallowed(self, machine, action): + """ + Assert that performing a given action on the state maching under + test fails in its current state. + + :param machine: the state machine under test + :type machine: state machine object instance + :param action: action to be performed on the state machine + :type action: str + """ + with pytest.raises(MachineError): + self.perform_action(machine, action) + + def to_state(self, machine, target_state): + """ + Transition the state machine to a target state. This + implementation uses autotransitions. If the pytransitions state + machine under test has autotransitions turned off, then this + method will need to be overridden by some other method of + putting the machine into the state under test. + + :param machine: the state machine under test + :type machine: state machine object instance + :param target_state: the state that we want to get the state + machine under test into + :type target_state: str + """ + machine.trigger(f"to_{target_state}") + + +def load_data(name): + """ + Loads a dataset by name. This implementation uses the name to find a + JSON file containing the data to be loaded. + + :param name: name of the dataset to be loaded; this implementation + uses the name to find a JSON file containing the data to be + loaded. + :type name: string + """ + with open(f"tests/data/{name}.json", "r") as json_file: + return json.load(json_file) + + @pytest.fixture(scope="class") def tango_context(request): """Creates and returns a TANGO DeviceTestContext object. @@ -95,6 +320,11 @@ def tango_change_event_helper(tango_context): """ class _Callback: + """ + Private callback handler class, an instance of which is returned + by the tango_change_event_helper each time it is used to + subscribe to a change event. + """ @staticmethod def subscribe(attribute_name): """ diff --git a/tests/data/base_device_state_machine.json b/tests/data/base_device_state_machine.json new file mode 100644 index 0000000000000000000000000000000000000000..a9891a842b798cb6c0ceff7fbd0c129ba9e42dd5 --- /dev/null +++ b/tests/data/base_device_state_machine.json @@ -0,0 +1,222 @@ +[ + [ + "UNINITIALISED", + "init_started", + "INIT_ENABLED" + ], + [ + "INIT_ENABLED", + "to_notfitted", + "INIT_DISABLED" + ], + [ + "INIT_ENABLED", + "to_offline", + "INIT_DISABLED" + ], + [ + "INIT_ENABLED", + "to_online", + "INIT_ENABLED" + ], + [ + "INIT_ENABLED", + "to_maintenance", + "INIT_ENABLED" + ], + [ + "INIT_ENABLED", + "init_succeeded", + "OFF" + ], + [ + "INIT_ENABLED", + "init_failed", + "FAULT_ENABLED" + ], + [ + "INIT_ENABLED", + "fatal_error", + "FAULT_ENABLED" + ], + [ + "INIT_DISABLED", + "to_notfitted", + "INIT_DISABLED" + ], + [ + "INIT_DISABLED", + "to_offline", + "INIT_DISABLED" + ], + [ + "INIT_DISABLED", + "to_online", + "INIT_ENABLED" + ], + [ + "INIT_DISABLED", + "to_maintenance", + "INIT_ENABLED" + ], + [ + "INIT_DISABLED", + "init_succeeded", + "DISABLED" + ], + [ + "INIT_DISABLED", + "init_failed", + "FAULT_DISABLED" + ], + [ + "INIT_DISABLED", + "fatal_error", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "to_notfitted", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "to_offline", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "to_online", + "FAULT_ENABLED" + ], + [ + "FAULT_DISABLED", + "to_maintenance", + "FAULT_ENABLED" + ], + [ + "FAULT_DISABLED", + "reset_succeeded", + "DISABLED" + ], + [ + "FAULT_DISABLED", + "reset_failed", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "fatal_error", + "FAULT_DISABLED" + ], + [ + "FAULT_ENABLED", + "to_notfitted", + "FAULT_DISABLED" + ], + [ + "FAULT_ENABLED", + "to_offline", + "FAULT_DISABLED" + ], + [ + "FAULT_ENABLED", + "to_online", + "FAULT_ENABLED" + ], + [ + "FAULT_ENABLED", + "to_maintenance", + "FAULT_ENABLED" + ], + [ + "FAULT_ENABLED", + "reset_succeeded", + "OFF" + ], + [ + "FAULT_ENABLED", + "reset_failed", + "FAULT_ENABLED" + ], + [ + "FAULT_ENABLED", + "fatal_error", + "FAULT_ENABLED" + ], + [ + "DISABLED", + "to_notfitted", + "DISABLED" + ], + [ + "DISABLED", + "to_offline", + "DISABLED" + ], + [ + "DISABLED", + "to_online", + "OFF" + ], + [ + "DISABLED", + "to_maintenance", + "OFF" + ], + [ + "DISABLED", + "fatal_error", + "FAULT_DISABLED" + ], + [ + "OFF", + "to_notfitted", + "DISABLED" + ], + [ + "OFF", + "to_offline", + "DISABLED" + ], + [ + "OFF", + "to_online", + "OFF" + ], + [ + "OFF", + "to_maintenance", + "OFF" + ], + [ + "OFF", + "on_succeeded", + "ON" + ], + [ + "OFF", + "on_failed", + "FAULT_ENABLED" + ], + [ + "OFF", + "fatal_error", + "FAULT_ENABLED" + ], + [ + "ON", + "off_succeeded", + "OFF" + ], + [ + "ON", + "off_failed", + "FAULT_ENABLED" + ], + [ + "ON", + "fatal_error", + "FAULT_ENABLED" + ] +] \ No newline at end of file diff --git a/tests/data/observation_state_machine.json b/tests/data/observation_state_machine.json new file mode 100644 index 0000000000000000000000000000000000000000..4165bae0b69ed7e85936e133b050d3b79f93b8ad --- /dev/null +++ b/tests/data/observation_state_machine.json @@ -0,0 +1,212 @@ +[ + [ + "EMPTY", + "assign_started", + "RESOURCING" + ], + [ + "EMPTY", + "fatal_error", + "FAULT" + ], + [ + "RESOURCING", + "resourcing_succeeded_some_resources", + "IDLE" + ], + [ + "RESOURCING", + "resourcing_succeeded_no_resources", + "EMPTY" + ], + [ + "RESOURCING", + "resourcing_failed", + "FAULT" + ], + [ + "RESOURCING", + "fatal_error", + "FAULT" + ], + [ + "IDLE", + "assign_started", + "RESOURCING" + ], + [ + "IDLE", + "release_started", + "RESOURCING" + ], + [ + "IDLE", + "configure_started", + "CONFIGURING" + ], + [ + "IDLE", + "abort_started", + "ABORTING" + ], + [ + "IDLE", + "fatal_error", + "FAULT" + ], + [ + "CONFIGURING", + "configure_succeeded", + "READY" + ], + [ + "CONFIGURING", + "configure_failed", + "FAULT" + ], + [ + "CONFIGURING", + "abort_started", + "ABORTING" + ], + [ + "CONFIGURING", + "fatal_error", + "FAULT" + ], + [ + "READY", + "end_succeeded", + "IDLE" + ], + [ + "READY", + "end_failed", + "FAULT" + ], + [ + "READY", + "configure_started", + "CONFIGURING" + ], + [ + "READY", + "abort_started", + "ABORTING" + ], + [ + "READY", + "scan_started", + "SCANNING" + ], + [ + "READY", + "fatal_error", + "FAULT" + ], + [ + "SCANNING", + "scan_succeeded", + "READY" + ], + [ + "SCANNING", + "scan_failed", + "FAULT" + ], + [ + "SCANNING", + "end_scan_succeeded", + "READY" + ], + [ + "SCANNING", + "end_scan_failed", + "FAULT" + ], + [ + "SCANNING", + "abort_started", + "ABORTING" + ], + [ + "SCANNING", + "fatal_error", + "FAULT" + ], + [ + "ABORTING", + "abort_succeeded", + "ABORTED" + ], + [ + "ABORTING", + "abort_failed", + "FAULT" + ], + [ + "ABORTING", + "fatal_error", + "FAULT" + ], + [ + "ABORTED", + "obs_reset_started", + "RESETTING" + ], + [ + "ABORTED", + "restart_started", + "RESTARTING" + ], + [ + "ABORTED", + "fatal_error", + "FAULT" + ], + [ + "FAULT", + "obs_reset_started", + "RESETTING" + ], + [ + "FAULT", + "restart_started", + "RESTARTING" + ], + [ + "FAULT", + "fatal_error", + "FAULT" + ], + [ + "RESETTING", + "obs_reset_succeeded", + "IDLE" + ], + [ + "RESETTING", + "obs_reset_failed", + "FAULT" + ], + [ + "RESETTING", + "fatal_error", + "FAULT" + ], + [ + "RESTARTING", + "restart_succeeded", + "EMPTY" + ], + [ + "RESTARTING", + "restart_failed", + "FAULT" + ], + [ + "RESTARTING", + "fatal_error", + "FAULT" + ] +] \ No newline at end of file diff --git a/tests/data/subarray_state_machine.json b/tests/data/subarray_state_machine.json new file mode 100644 index 0000000000000000000000000000000000000000..2c416dcd8dbb6a5732952f7ba2fc38e9fa51a3fd --- /dev/null +++ b/tests/data/subarray_state_machine.json @@ -0,0 +1,527 @@ +[ + [ + "UNINITIALISED", + "init_started", + "INIT_ENABLED" + ], + [ + "INIT_ENABLED", + "to_notfitted", + "INIT_DISABLED" + ], + [ + "INIT_ENABLED", + "to_offline", + "INIT_DISABLED" + ], + [ + "INIT_ENABLED", + "to_online", + "INIT_ENABLED" + ], + [ + "INIT_ENABLED", + "to_maintenance", + "INIT_ENABLED" + ], + [ + "INIT_ENABLED", + "init_succeeded", + "OFF" + ], + [ + "INIT_ENABLED", + "init_failed", + "FAULT_ENABLED" + ], + [ + "INIT_ENABLED", + "fatal_error", + "FAULT_ENABLED" + ], + [ + "INIT_DISABLED", + "to_notfitted", + "INIT_DISABLED" + ], + [ + "INIT_DISABLED", + "to_offline", + "INIT_DISABLED" + ], + [ + "INIT_DISABLED", + "to_online", + "INIT_ENABLED" + ], + [ + "INIT_DISABLED", + "to_maintenance", + "INIT_ENABLED" + ], + [ + "INIT_DISABLED", + "init_succeeded", + "DISABLED" + ], + [ + "INIT_DISABLED", + "init_failed", + "FAULT_DISABLED" + ], + [ + "INIT_DISABLED", + "fatal_error", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "to_notfitted", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "to_offline", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "to_online", + "FAULT_ENABLED" + ], + [ + "FAULT_DISABLED", + "to_maintenance", + "FAULT_ENABLED" + ], + [ + "FAULT_DISABLED", + "reset_succeeded", + "DISABLED" + ], + [ + "FAULT_DISABLED", + "reset_failed", + "FAULT_DISABLED" + ], + [ + "FAULT_DISABLED", + "fatal_error", + "FAULT_DISABLED" + ], + [ + "FAULT_ENABLED", + "to_notfitted", + "FAULT_DISABLED" + ], + [ + "FAULT_ENABLED", + "to_offline", + "FAULT_DISABLED" + ], + [ + "FAULT_ENABLED", + "to_online", + "FAULT_ENABLED" + ], + [ + "FAULT_ENABLED", + "to_maintenance", + "FAULT_ENABLED" + ], + [ + "FAULT_ENABLED", + "reset_succeeded", + "OFF" + ], + [ + "FAULT_ENABLED", + "reset_failed", + "FAULT_ENABLED" + ], + [ + "FAULT_ENABLED", + "fatal_error", + "FAULT_ENABLED" + ], + [ + "DISABLED", + "to_notfitted", + "DISABLED" + ], + [ + "DISABLED", + "to_offline", + "DISABLED" + ], + [ + "DISABLED", + "to_online", + "OFF" + ], + [ + "DISABLED", + "to_maintenance", + "OFF" + ], + [ + "DISABLED", + "fatal_error", + "FAULT_DISABLED" + ], + [ + "OFF", + "to_notfitted", + "DISABLED" + ], + [ + "OFF", + "to_offline", + "DISABLED" + ], + [ + "OFF", + "to_online", + "OFF" + ], + [ + "OFF", + "to_maintenance", + "OFF" + ], + [ + "OFF", + "on_succeeded", + "EMPTY" + ], + [ + "OFF", + "on_failed", + "FAULT_ENABLED" + ], + [ + "OFF", + "fatal_error", + "FAULT_ENABLED" + ], + [ + "EMPTY", + "off_succeeded", + "OFF" + ], + [ + "EMPTY", + "off_failed", + "FAULT_ENABLED" + ], + [ + "EMPTY", + "assign_started", + "RESOURCING" + ], + [ + "EMPTY", + "fatal_error", + "FAULT" + ], + [ + "RESOURCING", + "off_succeeded", + "OFF" + ], + [ + "RESOURCING", + "off_failed", + "FAULT_ENABLED" + ], + [ + "RESOURCING", + "resourcing_succeeded_some_resources", + "IDLE" + ], + [ + "RESOURCING", + "resourcing_succeeded_no_resources", + "EMPTY" + ], + [ + "RESOURCING", + "resourcing_failed", + "FAULT" + ], + [ + "RESOURCING", + "fatal_error", + "FAULT" + ], + [ + "IDLE", + "off_succeeded", + "OFF" + ], + [ + "IDLE", + "off_failed", + "FAULT_ENABLED" + ], + [ + "IDLE", + "assign_started", + "RESOURCING" + ], + [ + "IDLE", + "release_started", + "RESOURCING" + ], + [ + "IDLE", + "configure_started", + "CONFIGURING" + ], + [ + "IDLE", + "abort_started", + "ABORTING" + ], + [ + "IDLE", + "fatal_error", + "FAULT" + ], + [ + "CONFIGURING", + "off_succeeded", + "OFF" + ], + [ + "CONFIGURING", + "off_failed", + "FAULT_ENABLED" + ], + [ + "CONFIGURING", + "configure_succeeded", + "READY" + ], + [ + "CONFIGURING", + "configure_failed", + "FAULT" + ], + [ + "CONFIGURING", + "abort_started", + "ABORTING" + ], + [ + "CONFIGURING", + "fatal_error", + "FAULT" + ], + [ + "READY", + "off_succeeded", + "OFF" + ], + [ + "READY", + "off_failed", + "FAULT_ENABLED" + ], + [ + "READY", + "end_succeeded", + "IDLE" + ], + [ + "READY", + "end_failed", + "FAULT" + ], + [ + "READY", + "configure_started", + "CONFIGURING" + ], + [ + "READY", + "abort_started", + "ABORTING" + ], + [ + "READY", + "scan_started", + "SCANNING" + ], + [ + "READY", + "fatal_error", + "FAULT" + ], + [ + "SCANNING", + "off_succeeded", + "OFF" + ], + [ + "SCANNING", + "off_failed", + "FAULT_ENABLED" + ], + [ + "SCANNING", + "scan_succeeded", + "READY" + ], + [ + "SCANNING", + "scan_failed", + "FAULT" + ], + [ + "SCANNING", + "end_scan_succeeded", + "READY" + ], + [ + "SCANNING", + "end_scan_failed", + "FAULT" + ], + [ + "SCANNING", + "abort_started", + "ABORTING" + ], + [ + "SCANNING", + "fatal_error", + "FAULT" + ], + [ + "ABORTING", + "off_succeeded", + "OFF" + ], + [ + "ABORTING", + "off_failed", + "FAULT_ENABLED" + ], + [ + "ABORTING", + "abort_succeeded", + "ABORTED" + ], + [ + "ABORTING", + "abort_failed", + "FAULT" + ], + [ + "ABORTING", + "fatal_error", + "FAULT" + ], + [ + "ABORTED", + "off_succeeded", + "OFF" + ], + [ + "ABORTED", + "off_failed", + "FAULT_ENABLED" + ], + [ + "ABORTED", + "obs_reset_started", + "RESETTING" + ], + [ + "ABORTED", + "restart_started", + "RESTARTING" + ], + [ + "ABORTED", + "fatal_error", + "FAULT" + ], + [ + "FAULT", + "off_succeeded", + "OFF" + ], + [ + "FAULT", + "off_failed", + "FAULT_ENABLED" + ], + [ + "FAULT", + "obs_reset_started", + "RESETTING" + ], + [ + "FAULT", + "restart_started", + "RESTARTING" + ], + [ + "FAULT", + "fatal_error", + "FAULT" + ], + [ + "RESETTING", + "off_succeeded", + "OFF" + ], + [ + "RESETTING", + "off_failed", + "FAULT_ENABLED" + ], + [ + "RESETTING", + "obs_reset_succeeded", + "IDLE" + ], + [ + "RESETTING", + "obs_reset_failed", + "FAULT" + ], + [ + "RESETTING", + "fatal_error", + "FAULT" + ], + [ + "RESTARTING", + "off_succeeded", + "OFF" + ], + [ + "RESTARTING", + "off_failed", + "FAULT_ENABLED" + ], + [ + "RESTARTING", + "restart_succeeded", + "EMPTY" + ], + [ + "RESTARTING", + "restart_failed", + "FAULT" + ], + [ + "RESTARTING", + "fatal_error", + "FAULT" + ] +] \ No newline at end of file diff --git a/tests/test_base_device.py b/tests/test_base_device.py index dfe59795963417f50af18cfea014b32fffdd837d..be517b0e744fcbbd5beb528e24f82587dd533386 100644 --- a/tests/test_base_device.py +++ b/tests/test_base_device.py @@ -6,20 +6,19 @@ # # ######################################################################################### -"""Contain the tests for the SKABASE.""" - -import itertools -import re -import pytest +""" +This module contains the tests for the SKABaseDevice. +""" # PROTECTED REGION ID(SKABaseDevice.test_additional_imports) ENABLED START # import logging +import re +import pytest import socket import tango from unittest import mock from tango import DevFailed, DevState -from ska.base import SKABaseDeviceStateModel from ska.base.control_model import ( AdminMode, ControlMode, HealthState, LoggingLevel, SimulationMode, TestMode ) @@ -28,10 +27,13 @@ from ska.base.base_device import ( _PYTHON_TO_TANGO_LOGGING_LEVEL, LoggingUtils, LoggingTargetError, + SKABaseDeviceStateModel, TangoLoggingServiceHandler, ) from ska.base.faults import StateModelError +from .conftest import load_data, StateMachineTester + # PROTECTED REGION END # // SKABaseDevice.test_additional_imports # Device test case # PROTECTED REGION ID(SKABaseDevice.test_SKABaseDevice_decorators) ENABLED START # @@ -321,7 +323,103 @@ class TestLoggingUtils: LoggingUtils.create_logging_handler = orig_create_logging_handler -@pytest.mark.usefixtures("tango_context", "initialize_device") +@pytest.fixture +def device_state_model(): + """ + Yields a new SKABaseDeviceStateModel for testing + """ + yield SKABaseDeviceStateModel(logging.getLogger()) + + +@pytest.mark.state_machine_tester(load_data("base_device_state_machine")) +class TestSKABaseDeviceStateModel(StateMachineTester): + """ + This class contains the test suite for the ska.base.SKABaseDevice class. + """ + @pytest.fixture + def machine(self, device_state_model): + """ + Fixture that returns the state machine under test in this class + """ + yield device_state_model + + state_checks = { + "UNINITIALISED": + (None, None), + "FAULT_ENABLED": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.FAULT), + "FAULT_DISABLED": + ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.FAULT), + "INIT_ENABLED": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.INIT), + "INIT_DISABLED": + ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.INIT), + "DISABLED": + ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.DISABLE), + "OFF": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.OFF), + "ON": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON), + } + + def assert_state(self, machine, state): + """ + Assert the current state of this state machine, based on the + values of the adminMode, opState and obsState attributes of this + model. + + :param machine: the state machine under test + :type machine: state machine object instance + :param state: the state that we are asserting to be the current + state of the state machine under test + :type state: str + """ + + (admin_modes, op_state) = self.state_checks[state] + if admin_modes is None: + assert machine.admin_mode is None + else: + assert machine.admin_mode in admin_modes + if op_state is None: + assert machine.op_state is None + else: + assert machine.op_state == op_state + + def perform_action(self, machine, action): + """ + Perform a given action on the state machine under test. + + :param action: action to be performed on the state machine + :type action: str + """ + machine.perform_action(action) + + def check_action_disallowed(self, machine, action): + """ + Assert that performing a given action on the state maching under + test fails in its current state. + + :param machine: the state machine under test + :type machine: state machine object instance + :param action: action to be performed on the state machine + :type action: str + """ + with pytest.raises(StateModelError): + self.perform_action(machine, action) + + def to_state(self, machine, target_state): + """ + Transition the state machine to a target state. + + :param machine: the state machine under test + :type machine: state machine object instance + :param target_state: the state that we want to get the state + machine under test into + :type target_state: str + """ + machine._straight_to_state(target_state) + + # PROTECTED REGION END # // SKABaseDevice.test_SKABaseDevice_decorators class TestSKABaseDevice(object): """ @@ -587,145 +685,3 @@ class TestSKABaseDevice(object): # PROTECTED REGION ID(SKABaseDevice.test_testMode) ENABLED START # assert tango_context.device.testMode == TestMode.NONE # PROTECTED REGION END # // SKABaseDevice.test_testMode - - -@pytest.fixture -def state_model(): - yield SKABaseDeviceStateModel() - - -class TestSKABaseDeviceStateModel(): - """ - Test cases for SKABaseDeviceStateModel. - """ - - @pytest.mark.parametrize( - 'state_under_test, action_under_test', - itertools.product( - ["UNINITIALISED", "INIT_ENABLED", "INIT_DISABLED", "FAULT_ENABLED", - "FAULT_DISABLED", "DISABLED", "OFF", "ON"], - ["init_started", "init_succeeded", "init_failed", "fatal_error", - "reset_succeeded", "reset_failed", "to_notfitted", - "to_offline", "to_online", "to_maintenance", "on_succeeded", - "on_failed", "off_succeeded", "off_failed"] - ) - ) - def test_state_machine( - self, state_model, state_under_test, action_under_test - ): - """ - Test the subarray state machine: for a given initial state and - an action, does execution of that action, from that initial - state, yield the expected results? If the action was not allowed - from that initial state, does the device raise a DevFailed - exception? If the action was allowed, does it result in the - correct state transition? - - :todo: support starting in different memorised adminModes - """ - states = { - "UNINITIALISED": - (None, None), - "FAULT_ENABLED": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.FAULT), - "FAULT_DISABLED": - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.FAULT), - "INIT_ENABLED": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.INIT), - "INIT_DISABLED": - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.INIT), - "DISABLED": - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.DISABLE), - "OFF": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.OFF), - "ON": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON), - } - - def assert_state(state): - (admin_modes, state) = states[state] - if admin_modes is not None: - assert state_model.admin_mode in admin_modes - if state is not None: - assert state_model.dev_state == state - - transitions = { - ('UNINITIALISED', 'init_started'): "INIT_ENABLED", - ('INIT_ENABLED', 'to_notfitted'): "INIT_DISABLED", - ('INIT_ENABLED', 'to_offline'): "INIT_DISABLED", - ('INIT_ENABLED', 'to_online'): "INIT_ENABLED", - ('INIT_ENABLED', 'to_maintenance'): "INIT_ENABLED", - ('INIT_ENABLED', 'init_succeeded'): 'OFF', - ('INIT_ENABLED', 'init_failed'): 'FAULT_ENABLED', - ('INIT_ENABLED', 'fatal_error'): "FAULT_ENABLED", - ('INIT_DISABLED', 'to_notfitted'): "INIT_DISABLED", - ('INIT_DISABLED', 'to_offline'): "INIT_DISABLED", - ('INIT_DISABLED', 'to_online'): "INIT_ENABLED", - ('INIT_DISABLED', 'to_maintenance'): "INIT_ENABLED", - ('INIT_DISABLED', 'init_succeeded'): 'DISABLED', - ('INIT_DISABLED', 'init_failed'): 'FAULT_DISABLED', - ('INIT_DISABLED', 'fatal_error'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'to_notfitted'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'to_offline'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'to_online'): "FAULT_ENABLED", - ('FAULT_DISABLED', 'to_maintenance'): "FAULT_ENABLED", - ('FAULT_DISABLED', 'reset_succeeded'): "DISABLED", - ('FAULT_DISABLED', 'reset_failed'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'fatal_error'): "FAULT_DISABLED", - ('FAULT_ENABLED', 'to_notfitted'): "FAULT_DISABLED", - ('FAULT_ENABLED', 'to_offline'): "FAULT_DISABLED", - ('FAULT_ENABLED', 'to_online'): "FAULT_ENABLED", - ('FAULT_ENABLED', 'to_maintenance'): "FAULT_ENABLED", - ('FAULT_ENABLED', 'reset_succeeded'): "OFF", - ('FAULT_ENABLED', 'reset_failed'): "FAULT_ENABLED", - ('FAULT_ENABLED', 'fatal_error'): "FAULT_ENABLED", - ('DISABLED', 'to_notfitted'): "DISABLED", - ('DISABLED', 'to_offline'): "DISABLED", - ('DISABLED', 'to_online'): "OFF", - ('DISABLED', 'to_maintenance'): "OFF", - ('DISABLED', 'fatal_error'): "FAULT_DISABLED", - ('OFF', 'to_notfitted'): "DISABLED", - ('OFF', 'to_offline'): "DISABLED", - ('OFF', 'to_online'): "OFF", - ('OFF', 'to_maintenance'): "OFF", - ('OFF', 'on_succeeded'): "ON", - ('OFF', 'on_failed'): "FAULT_ENABLED", - ('OFF', 'fatal_error'): "FAULT_ENABLED", - ('ON', 'off_succeeded'): "OFF", - ('ON', 'off_failed'): "FAULT_ENABLED", - ('ON', 'fatal_error'): "FAULT_ENABLED", - } - - setups = { - "UNINITIALISED": [], - "INIT_ENABLED": ['init_started'], - "INIT_DISABLED": ['init_started', 'to_offline'], - "FAULT_ENABLED": ['init_started', 'init_failed'], - "FAULT_DISABLED": ['init_started', 'to_offline', 'init_failed'], - "OFF": ['init_started', 'init_succeeded'], - "DISABLED": ['init_started', 'init_succeeded', 'to_offline'], - "ON": ['init_started', 'init_succeeded', 'on_succeeded'], - } - - # state = "UNINITIALISED" # for test debugging only - # assert_state(state) # for test debugging only - - # Put the device into the state under test - for action in setups[state_under_test]: - state_model.perform_action(action) - # state = transitions[state, action] # for test debugging only - # assert_state(state) # for test debugging only - - # Check that we are in the state under test - assert_state(state_under_test) - - # Test that the action under test does what we expect it to - if (state_under_test, action_under_test) in transitions: - # Action should succeed - state_model.perform_action(action_under_test) - assert_state(transitions[(state_under_test, action_under_test)]) - else: - # Action should fail and the state should not change - with pytest.raises(StateModelError): - state_model.perform_action(action_under_test) - assert_state(state_under_test) diff --git a/tests/test_control_model.py b/tests/test_control_model.py deleted file mode 100644 index 130077fd7e3ef059e5a210e5ab517ddb81007f63..0000000000000000000000000000000000000000 --- a/tests/test_control_model.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Tests for ska.base.control_model.""" -import pytest - -from collections import Counter - -from ska.base.control_model import DeviceStateModel -from ska.base.faults import StateModelError - - -class OnOffModel(DeviceStateModel): - __transitions = { - ("ON", "off"): ( - "OFF", - lambda self: self.count_transition("on->off"), - ), - ("OFF", "on"): ( - "ON", - lambda self: self.count_transition("off->on"), - ), - } - - def __init__(self): - super().__init__(self.__transitions, "ON") - self.counter = Counter() - - def count_transition(self, name): - self.counter[name] += 1 - - -def test_initial_state(): - on_off = OnOffModel() - assert on_off.state == "ON" - - -def test_try_valid_action_returns_true_and_does_not_change_state(): - on_off = OnOffModel() - assert on_off.try_action("off") - assert on_off.state == "ON" - - -def test_try_invalid_action_raises_and_does_not_change_state(): - on_off = OnOffModel() - with pytest.raises(StateModelError): - on_off.try_action("on") - assert on_off.state == "ON" - - -def test_valid_state_transitions_succeed(): - on_off = OnOffModel() - - on_off.perform_action("off") - assert on_off.state == "OFF" - on_off.perform_action("on") - assert on_off.state == "ON" - - -def test_invalid_state_transitions_fail(): - on_off = OnOffModel() - - with pytest.raises(StateModelError): - on_off.perform_action("on") - on_off.perform_action("off") - with pytest.raises(StateModelError): - on_off.perform_action("off") - with pytest.raises(StateModelError): - on_off.perform_action("invalid") - - -def test_side_effect_is_called(): - on_off = OnOffModel() - - on_off.perform_action("off") - on_off.perform_action("on") - on_off.perform_action("off") - - assert on_off.counter["on->off"] == 2 - assert on_off.counter["off->on"] == 1 - - -def test_update_transitions_only_applies_to_instances(): - on_off = OnOffModel() - - on_off_updated = OnOffModel() - on_off_updated.update_transitions({("ON", "break"): ("BROKEN", None)}) - - assert on_off.is_action_allowed("off") - assert not on_off.is_action_allowed("break") - assert on_off_updated.is_action_allowed("off") - assert on_off_updated.is_action_allowed("break") diff --git a/tests/test_state_machines.py b/tests/test_state_machines.py new file mode 100644 index 0000000000000000000000000000000000000000..d44b641b982f856ed419691b5c16c4ee9ba28a94 --- /dev/null +++ b/tests/test_state_machines.py @@ -0,0 +1,33 @@ +""" +This module contains the tests for the ska.base.state_machine module. +""" +import pytest + +from ska.base.state_machine import BaseDeviceStateMachine, ObservationStateMachine +from .conftest import load_data, TransitionsStateMachineTester + + +@pytest.mark.state_machine_tester(load_data("base_device_state_machine")) +class BaseDeviceStateMachineTester(TransitionsStateMachineTester): + """ + This class contains the test for the BaseDeviceStateMachine class. + """ + @pytest.fixture + def machine(self): + """ + Fixture that returns the state machine under test in this class + """ + yield BaseDeviceStateMachine() + + +@pytest.mark.state_machine_tester(load_data("observation_state_machine")) +class TestObservationStateMachine(TransitionsStateMachineTester): + """ + This class contains the test for the ObservationStateMachine class. + """ + @pytest.fixture + def machine(self): + """ + Fixture that returns the state machine under test in this class + """ + yield ObservationStateMachine() diff --git a/tests/test_subarray_device.py b/tests/test_subarray_device.py index ae4a8cade5bc4f254d7ef459aa40ab95d93c1e4b..7f91bb27e9bd4db38f136af1ef1d3aa19cfdfa1d 100644 --- a/tests/test_subarray_device.py +++ b/tests/test_subarray_device.py @@ -8,7 +8,7 @@ ######################################################################################### """Contain the tests for the SKASubarray.""" -import itertools +import logging import re import pytest @@ -20,13 +20,142 @@ from ska.base.commands import ResultCode from ska.base.control_model import ( AdminMode, ControlMode, HealthState, ObsMode, ObsState, SimulationMode, TestMode ) -from ska.base.faults import CommandError +from ska.base.faults import CommandError, StateModelError + +from .conftest import load_data, StateMachineTester # PROTECTED REGION END # // SKASubarray.test_additional_imports -@pytest.mark.usefixtures("tango_context", "initialize_device") -class TestSKASubarray(object): - """Test case for packet generation.""" +@pytest.fixture +def subarray_state_model(): + """ + Yields a new SKASubarrayStateModel for testing + """ + yield SKASubarrayStateModel(logging.getLogger()) + + +@pytest.mark.state_machine_tester(load_data("subarray_state_machine")) +class TestSKASubarrayStateModel(StateMachineTester): + """ + This class contains the test for the SKASubarrayStateModel class. + """ + @pytest.fixture + def machine(self, subarray_state_model): + """ + Fixture that returns the state machine under test in this class + """ + yield subarray_state_model + + state_checks = { + "UNINITIALISED": + (None, None, ObsState.EMPTY), + "FAULT_ENABLED": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.FAULT, ObsState.EMPTY), + "FAULT_DISABLED": + ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.FAULT, ObsState.EMPTY), + "INIT_ENABLED": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.INIT, ObsState.EMPTY), + "INIT_DISABLED": + ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.INIT, ObsState.EMPTY), + "DISABLED": + ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.DISABLE, ObsState.EMPTY), + "OFF": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.OFF, ObsState.EMPTY), + "EMPTY": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.EMPTY), + "RESOURCING": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.RESOURCING), + "IDLE": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.IDLE), + "CONFIGURING": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.CONFIGURING), + "READY": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.READY), + "SCANNING": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.SCANNING), + "ABORTING": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.ABORTING), + "ABORTED": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.ABORTED), + "FAULT": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.FAULT), + "RESETTING": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.RESETTING), + "RESTARTING": + ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.RESTARTING), + + } + + def assert_state(self, machine, state): + """ + Assert the current state of this state machine, based on the + values of the adminMode, opState and obsState attributes of this + model. + + :param machine: the state machine under test + :type machine: state machine object instance + :param state: the state that we are asserting to be the current + state of the state machine under test + :type state: str + """ + # Debugging only -- machine is already tested + # assert self.model._state == state + # print(f"State is {state}") + (admin_modes, op_state, obs_state) = self.state_checks[state] + if admin_modes is None: + assert machine.admin_mode is None + else: + assert machine.admin_mode in admin_modes + if op_state is None: + assert machine.op_state is None + else: + assert machine.op_state == op_state + if obs_state is None: + assert machine.obs_state is None + else: + assert machine.obs_state == obs_state + + def perform_action(self, machine, action): + """ + Perform a given action on the state machine under test. + + :param machine: the state machine under test + :type machine: state machine object instance + :param action: action to be performed on the state machine + :type action: str + """ + machine.perform_action(action) + + def check_action_disallowed(self, machine, action): + """ + Assert that performing a given action on the state maching under + test fails in its current state. + + :param machine: the state machine under test + :type machine: state machine object instance + :param action: action to be performed on the state machine + :type action: str + """ + with pytest.raises(StateModelError): + self.perform_action(machine, action) + + def to_state(self, machine, target_state): + """ + Transition the state machine to a target state. + + :param machine: the state machine under test + :type machine: state machine object instance + :param target_state: the state that we want to get the state + machine under test into + :type target_state: str + """ + machine._straight_to_state(target_state) + + +class TestSKASubarray: + """ + Test cases for SKASubarray device. + """ properties = { 'CapabilityTypes': '', @@ -44,11 +173,12 @@ class TestSKASubarray(object): # PROTECTED REGION ID(SKASubarray.test_mocking) ENABLED START # # PROTECTED REGION END # // SKASubarray.test_mocking + @pytest.mark.skip(reason="Not implemented") def test_properties(self, tango_context): # Test the properties # PROTECTED REGION ID(SKASubarray.test_properties) ENABLED START # # PROTECTED REGION END # // SKASubarray.test_properties - pass + """Test the Tango device properties of this subarray device""" # PROTECTED REGION ID(SKASubarray.test_Abort_decorators) ENABLED START # # PROTECTED REGION END # // SKASubarray.test_Abort_decorators @@ -403,189 +533,23 @@ class TestSKASubarray(object): assert tango_context.device.configuredCapabilities == ("BAND1:0", "BAND2:0") # PROTECTED REGION END # // SKASubarray.test_configuredCapabilities - @pytest.mark.parametrize( - 'state_under_test, action_under_test', - itertools.product( - [ - # not testing FAULT or OBSFAULT states because in the current - # implementation the interface cannot be used to get the device - # into these states - "DISABLED", "OFF", "EMPTY", "IDLE", "READY", "SCANNING", - "ABORTED", - ], - [ - # not testing 'reset' action because in the current - # implementation the interface cannot be used to get the device - # into a state from which 'reset' is a valid action - "notfitted", "offline", "online", "maintenance", "on", "off", - "assign", "release", "release (all)", "releaseall", - "configure", "scan", "endscan", "end", "abort", "obsreset", - "restart"] - ) - ) - def test_state_machine(self, tango_context, state_under_test, action_under_test): - """ - Test the subarray state machine: for a given initial state and - an action, does execution of that action, from that initial - state, yield the expected results? If the action was not allowed - from that initial state, does the device raise a DevFailed - exception? If the action was allowed, does it result in the - correct state transition? - """ - states = { - "DISABLED": - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.DISABLE, ObsState.EMPTY), - "FAULT": # not tested - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE, AdminMode.ONLINE, AdminMode.MAINTENANCE], - DevState.FAULT, ObsState.EMPTY), - "OFF": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.OFF, ObsState.EMPTY), - "EMPTY": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.EMPTY), - "IDLE": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.IDLE), - "READY": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.READY), - "SCANNING": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.SCANNING), - "ABORTED": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.ABORTED), - "OBSFAULT": # not tested - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.FAULT), - } - - def assert_state(state): - """ - Check that the device is in the state we think it should be in - """ - (admin_modes, dev_state, obs_state) = states[state] - assert tango_context.device.adminMode in admin_modes - assert tango_context.device.state() == dev_state - assert tango_context.device.obsState == obs_state - - actions = { - "notfitted": - lambda d: d.write_attribute("adminMode", AdminMode.NOT_FITTED), - "offline": - lambda d: d.write_attribute("adminMode", AdminMode.OFFLINE), - "online": - lambda d: d.write_attribute("adminMode", AdminMode.ONLINE), - "maintenance": - lambda d: d.write_attribute("adminMode", AdminMode.MAINTENANCE), - "on": - lambda d: d.On(), - "off": - lambda d: d.Off(), - "reset": - lambda d: d.Reset(), # not tested - "assign": - lambda d: d.AssignResources('{"example": ["BAND1", "BAND2"]}'), - "release": - lambda d: d.ReleaseResources('{"example": ["BAND1"]}'), - "release (all)": - lambda d: d.ReleaseResources('{"example": ["BAND1", "BAND2"]}'), - "releaseall": - lambda d: d.ReleaseAllResources(), - "configure": - lambda d: d.Configure('{"BAND1": 2, "BAND2": 2}'), - "scan": - lambda d: d.Scan('{"id": 123}'), - "endscan": - lambda d: d.EndScan(), - "end": - lambda d: d.End(), - "abort": - lambda d: d.Abort(), - "obsreset": - lambda d: d.ObsReset(), - "restart": - lambda d: d.Restart(), - } - - def perform_action(action): - actions[action](tango_context.device) - - transitions = { - ("DISABLED", "notfitted"): "DISABLED", - ("DISABLED", "offline"): "DISABLED", - ("DISABLED", "online"): "OFF", - ("DISABLED", "maintenance"): "OFF", - ("OFF", "notfitted"): "DISABLED", - ("OFF", "offline"): "DISABLED", - ("OFF", "online"): "OFF", - ("OFF", "maintenance"): "OFF", - ("OFF", "on"): "EMPTY", - ("EMPTY", "off"): "OFF", - ("EMPTY", "assign"): "IDLE", - ("IDLE", "assign"): "IDLE", - ("IDLE", "release"): "IDLE", - ("IDLE", "release (all)"): "EMPTY", - ("IDLE", "releaseall"): "EMPTY", - ("IDLE", "configure"): "READY", - ("IDLE", "abort"): "ABORTED", - ("READY", "configure"): "READY", - ("READY", "end"): "IDLE", - ("READY", "abort"): "ABORTED", - ("READY", "scan"): "SCANNING", - ("SCANNING", "endscan"): "READY", - ("SCANNING", "abort"): "ABORTED", - ("ABORTED", "obsreset"): "IDLE", - ("ABORTED", "restart"): "EMPTY", - } - - setups = { - "DISABLED": - ['offline'], - "OFF": - [], - "EMPTY": - ['on'], - "IDLE": - ['on', 'assign'], - "READY": - ['on', 'assign', 'configure'], - "SCANNING": - ['on', 'assign', 'configure', 'scan'], - "ABORTED": - ['on', 'assign', 'abort'], - } - - # state = "OFF" # debugging only - # assert_state(state) # debugging only - - # Put the device into the state under test - for action in setups[state_under_test]: - perform_action(action) - # state = transitions[state, action] # debugging only - # assert_state(state) # debugging only - - # Check that we are in the state under test - assert_state(state_under_test) - - # Test that the action under test does what we expect it to - if (state_under_test, action_under_test) in transitions: - # Action should succeed - perform_action(action_under_test) - assert_state(transitions[(state_under_test, action_under_test)]) - else: - # Action should fail and the state should not change - with pytest.raises(DevFailed): - perform_action(action_under_test) - assert_state(state_under_test) - @pytest.fixture def resource_manager(): + """ + Fixture that yields an SKASubarrayResourceManager + """ yield SKASubarrayResourceManager() -@pytest.fixture -def state_model(): - yield SKASubarrayStateModel() - - class TestSKASubarrayResourceManager: + """ + Test suite for SKASubarrayResourceManager class + """ def test_ResourceManager_assign(self, resource_manager): + """ + Test that the ResourceManager assigns resource correctly. + """ # create a resource manager and check that it is empty assert not len(resource_manager) assert resource_manager.get() == set() @@ -615,6 +579,9 @@ class TestSKASubarrayResourceManager: assert resource_manager.get() == set(["A", "B", "C", "D"]) def test_ResourceManager_release(self, resource_manager): + """ + Test that the ResourceManager releases resource correctly. + """ resource_manager = SKASubarrayResourceManager() resource_manager.assign('{"example": ["A", "B", "C", "D"]}') @@ -649,34 +616,49 @@ class TestSKASubarray_commands: This class contains tests of SKASubarray commands """ - def test_AssignCommand(self, resource_manager, state_model): + def test_AssignCommand(self, resource_manager, subarray_state_model): """ Test for SKASubarray.AssignResourcesCommand """ assign_resources = SKASubarray.AssignResourcesCommand( resource_manager, - state_model + subarray_state_model ) - # until the state_model is in the right state for it, the - # command's is_allowed() method will return False, and an - # attempt to call the command will raise a CommandError, and - # there will be no side-effect on the resource manager - for action in ["init_started", "init_succeeded", "on_succeeded"]: + all_states = { + "UNINITIALISED", "FAULT_ENABLED", "FAULT_DISABLED", "INIT_ENABLED", + "INIT_DISABLED", "DISABLED", "OFF", "EMPTY", "RESOURCING", "IDLE", + "CONFIGURING", "READY", "SCANNING", "ABORTING", "ABORTED", "FAULT", + "RESETTING", "RESTARTING", + } + + # in all states except EMPTY and IDLE, the assign resources command is + # not permitted, should not be allowed, should fail, should have no + # side-effect + for state in all_states - {"EMPTY", "IDLE"}: + subarray_state_model._straight_to_state(state) assert not assign_resources.is_allowed() with pytest.raises(CommandError): assign_resources('{"example": ["foo"]}') assert not len(resource_manager) assert resource_manager.get() == set() + assert subarray_state_model._state == state - state_model.perform_action(action) - - # now that the state_model is in the right state, is_allowed() - # should return True, and the command should succeed, and we - # should see the result in the resource manager + # now push to empty, a state in which is IS allowed + subarray_state_model._straight_to_state("EMPTY") assert assign_resources.is_allowed() assert assign_resources('{"example": ["foo"]}') == ( ResultCode.OK, "AssignResources command completed OK" ) assert len(resource_manager) == 1 assert resource_manager.get() == set(["foo"]) + + assert subarray_state_model._state == "IDLE" + + # AssignResources is still allowed in ON_IDLE + assert assign_resources.is_allowed() + assert assign_resources('{"example": ["bar"]}') == ( + ResultCode.OK, "AssignResources command completed OK" + ) + assert len(resource_manager) == 2 + assert resource_manager.get() == set(["foo", "bar"]) diff --git a/tests/test_subarray_state_model.py b/tests/test_subarray_state_model.py deleted file mode 100644 index 804b8cf98f39e2252297856b24ed29f645395248..0000000000000000000000000000000000000000 --- a/tests/test_subarray_state_model.py +++ /dev/null @@ -1,280 +0,0 @@ -######################################################################################### -# -*- coding: utf-8 -*- -# -# This file is part of the SKASubarray project -# -# -# -######################################################################################### -"""Contain the tests for the SKASubarray.""" - -import itertools -import pytest - -from tango import DevState - -from ska.base import SKASubarrayStateModel -from ska.base.control_model import AdminMode, ObsState -from ska.base.faults import StateModelError - - -@pytest.fixture -def state_model(): - yield SKASubarrayStateModel() - - -class TestSKASubarrayStateModel: - """ - Test cases for SKASubarrayStateModel. - """ - - @pytest.mark.parametrize( - 'state_under_test, action_under_test', - itertools.product( - ["UNINITIALISED", "INIT_ENABLED", "INIT_DISABLED", "FAULT_ENABLED", - "FAULT_DISABLED", "DISABLED", "OFF", "EMPTY", - "RESOURCING", "IDLE", "CONFIGURING", "READY", "SCANNING", - "ABORTING", "ABORTED", "OBSFAULT"], - ["init_started", "init_succeeded", "init_failed", "fatal_error", - "reset_succeeded", "reset_failed", "to_notfitted", - "to_offline", "to_online", "to_maintenance", "on_succeeded", - "on_failed", "off_succeeded", "off_failed", "assign_started", - "resourcing_succeeded_no_resources", "resourcing_succeeded_some_resources", - "resourcing_failed", "release_started", "configure_started", - "configure_succeeded", "configure_failed", "scan_started", - "scan_succeeded", "scan_failed", "end_scan_succeeded", - "end_scan_failed", "abort_started", "abort_succeeded", - "abort_failed", "obs_reset_started", "obs_reset_succeeded", - "obs_reset_failed", "restart_started", "restart_succeeded", - "restart_failed"] - ) - ) - def test_state_machine( - self, state_model, state_under_test, action_under_test - ): - """ - Test the subarray state machine: for a given initial state and - an action, does execution of that action, from that initial - state, yield the expected results? If the action was not allowed - from that initial state, does the device raise a DevFailed - exception? If the action was allowed, does it result in the - correct state transition? - - :todo: support starting in different memorised adminModes - """ - - states = { - "UNINITIALISED": - (None, None, None), - "FAULT_ENABLED": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.FAULT, None), - "FAULT_DISABLED": - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.FAULT, None), - "INIT_ENABLED": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.INIT, ObsState.EMPTY), - "INIT_DISABLED": - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.INIT, ObsState.EMPTY), - "DISABLED": - ([AdminMode.NOT_FITTED, AdminMode.OFFLINE], DevState.DISABLE, ObsState.EMPTY), - "OFF": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.OFF, ObsState.EMPTY), - "EMPTY": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.EMPTY), - "RESOURCING": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.RESOURCING), - "IDLE": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.IDLE), - "CONFIGURING": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.CONFIGURING), - "READY": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.READY), - "SCANNING": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.SCANNING), - "ABORTING": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.ABORTING), - "ABORTED": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.ABORTED), - "RESETTING": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.RESETTING), - "RESTARTING": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.RESTARTING), - "OBSFAULT": - ([AdminMode.ONLINE, AdminMode.MAINTENANCE], DevState.ON, ObsState.FAULT), - } - - def assert_state(state): - (admin_modes, state, obs_state) = states[state] - if admin_modes is not None: - assert state_model.admin_mode in admin_modes - if state is not None: - assert state_model.dev_state == state - if obs_state is not None: - assert state_model.obs_state == obs_state - - transitions = { - ('UNINITIALISED', 'init_started'): "INIT_ENABLED", - ('INIT_ENABLED', 'to_notfitted'): "INIT_DISABLED", - ('INIT_ENABLED', 'to_offline'): "INIT_DISABLED", - ('INIT_ENABLED', 'to_online'): "INIT_ENABLED", - ('INIT_ENABLED', 'to_maintenance'): "INIT_ENABLED", - ('INIT_ENABLED', 'init_succeeded'): 'OFF', - ('INIT_ENABLED', 'init_failed'): 'FAULT_ENABLED', - ('INIT_ENABLED', 'fatal_error'): "FAULT_ENABLED", - ('INIT_DISABLED', 'to_notfitted'): "INIT_DISABLED", - ('INIT_DISABLED', 'to_offline'): "INIT_DISABLED", - ('INIT_DISABLED', 'to_online'): "INIT_ENABLED", - ('INIT_DISABLED', 'to_maintenance'): "INIT_ENABLED", - ('INIT_DISABLED', 'init_succeeded'): 'DISABLED', - ('INIT_DISABLED', 'init_failed'): 'FAULT_DISABLED', - ('INIT_DISABLED', 'fatal_error'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'to_notfitted'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'to_offline'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'to_online'): "FAULT_ENABLED", - ('FAULT_DISABLED', 'to_maintenance'): "FAULT_ENABLED", - ('FAULT_DISABLED', 'reset_succeeded'): "DISABLED", - ('FAULT_DISABLED', 'reset_failed'): "FAULT_DISABLED", - ('FAULT_DISABLED', 'fatal_error'): "FAULT_DISABLED", - ('FAULT_ENABLED', 'to_notfitted'): "FAULT_DISABLED", - ('FAULT_ENABLED', 'to_offline'): "FAULT_DISABLED", - ('FAULT_ENABLED', 'to_online'): "FAULT_ENABLED", - ('FAULT_ENABLED', 'to_maintenance'): "FAULT_ENABLED", - ('FAULT_ENABLED', 'reset_succeeded'): "OFF", - ('FAULT_ENABLED', 'reset_failed'): "FAULT_ENABLED", - ('FAULT_ENABLED', 'fatal_error'): "FAULT_ENABLED", - ('DISABLED', 'to_notfitted'): "DISABLED", - ('DISABLED', 'to_offline'): "DISABLED", - ('DISABLED', 'to_online'): "OFF", - ('DISABLED', 'to_maintenance'): "OFF", - ('DISABLED', 'fatal_error'): "FAULT_DISABLED", - ('OFF', 'to_notfitted'): "DISABLED", - ('OFF', 'to_offline'): "DISABLED", - ('OFF', 'to_online'): "OFF", - ('OFF', 'to_maintenance'): "OFF", - ('OFF', 'on_succeeded'): "EMPTY", - ('OFF', 'on_failed'): "FAULT_ENABLED", - ('OFF', 'fatal_error'): "FAULT_ENABLED", - ('EMPTY', 'off_succeeded'): "OFF", - ('EMPTY', 'off_failed'): "FAULT_ENABLED", - ('EMPTY', 'assign_started'): "RESOURCING", - ('EMPTY', 'fatal_error'): "OBSFAULT", - ('RESOURCING', 'resourcing_succeeded_some_resources'): "IDLE", - ('RESOURCING', 'resourcing_succeeded_no_resources'): "EMPTY", - ('RESOURCING', 'resourcing_failed'): "OBSFAULT", - ('RESOURCING', 'fatal_error'): "OBSFAULT", - ('IDLE', 'assign_started'): "RESOURCING", - ('IDLE', 'release_started'): "RESOURCING", - ('IDLE', 'configure_started'): "CONFIGURING", - ('IDLE', 'abort_started'): "ABORTING", - ('IDLE', 'fatal_error'): "OBSFAULT", - ('CONFIGURING', 'configure_succeeded'): "READY", - ('CONFIGURING', 'configure_failed'): "OBSFAULT", - ('CONFIGURING', 'abort_started'): "ABORTING", - ('CONFIGURING', 'fatal_error'): "OBSFAULT", - ('READY', 'end_succeeded'): "IDLE", - ('READY', 'end_failed'): "OBSFAULT", - ('READY', 'configure_started'): "CONFIGURING", - ('READY', 'abort_started'): "ABORTING", - ('READY', 'scan_started'): "SCANNING", - ('READY', 'fatal_error'): "OBSFAULT", - ('SCANNING', 'scan_succeeded'): "READY", - ('SCANNING', 'scan_failed'): "OBSFAULT", - ('SCANNING', 'end_scan_succeeded'): "READY", - ('SCANNING', 'end_scan_failed'): "OBSFAULT", - ('SCANNING', 'abort_started'): "ABORTING", - ('SCANNING', 'fatal_error'): "OBSFAULT", - ('ABORTING', 'abort_succeeded'): "ABORTED", - ('ABORTING', 'abort_failed'): "OBSFAULT", - ('ABORTING', 'fatal_error'): "OBSFAULT", - ('ABORTED', 'obs_reset_started'): "RESETTING", - ('ABORTED', 'restart_started'): "RESTARTING", - ('ABORTED', 'fatal_error'): "OBSFAULT", - ('OBSFAULT', 'obs_reset_started'): "RESETTING", - ('OBSFAULT', 'restart_started'): "RESTARTING", - ('OBSFAULT', 'fatal_error'): "OBSFAULT", - ('RESETTING', 'obs_reset_succeeded'): "IDLE", - ('RESETTING', 'obs_reset_failed'): "OBSFAULT", - ('RESETTING', 'fatal_error'): "OBSFAULT", - ('RESTARTING', 'restart_succeeded'): "EMPTY", - ('RESTARTING', 'restart_failed'): "OBSFAULT", - ('RESTARTING', 'fatal_error'): "OBSFAULT", - } - - setups = { - "UNINITIALISED": [], - "INIT_ENABLED": ['init_started'], - "INIT_DISABLED": ['init_started', 'to_offline'], - "FAULT_ENABLED": ['init_started', 'init_failed'], - "FAULT_DISABLED": ['init_started', 'to_offline', 'init_failed'], - "OFF": ['init_started', 'init_succeeded'], - "DISABLED": ['init_started', 'init_succeeded', 'to_offline'], - "EMPTY": ['init_started', 'init_succeeded', 'on_succeeded'], - "RESOURCING": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started' - ], - "IDLE": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources'], - "CONFIGURING": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources', - 'configure_started' - ], - "READY": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources', - 'configure_started', 'configure_succeeded' - ], - "SCANNING": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources', - 'configure_started', 'configure_succeeded', 'scan_started' - ], - "ABORTING": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources', - 'abort_started' - ], - "ABORTED": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources', - 'abort_started', 'abort_succeeded' - ], - "OBSFAULT": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'fatal_error' - ], - "RESETTING": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources', - 'abort_started', 'abort_succeeded', 'obs_reset_started' - ], - "RESTARTING": [ - 'init_started', 'init_succeeded', 'on_succeeded', - 'assign_started', 'resourcing_succeeded_some_resources', - 'abort_started', 'abort_succeeded', 'restart_started' - ], - } - - # state = "UNINITIALISED" # for test debugging only - # assert_state(state) # for test debugging only - - # Put the device into the state under test - for action in setups[state_under_test]: - state_model.perform_action(action) - # state = transitions[state, action] # for test debugging only - # assert_state(state) # for test debugging only - - # Check that we are in the state under test - assert_state(state_under_test) - - # Test that the action under test does what we expect it to - if (state_under_test, action_under_test) in transitions: - # Action should succeed - state_model.perform_action(action_under_test) - assert_state(transitions[(state_under_test, action_under_test)]) - else: - # Action should fail and the state should not change - with pytest.raises(StateModelError): - state_model.perform_action(action_under_test) - assert_state(state_under_test) diff --git a/tests/test_utils.py b/tests/test_utils.py index fb749b7f630cec318d74020ff6e5e5d236e87ccb..f40481273b365fe0e1a5eb176387dc985c649e7f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ """Tests for skabase.utils.""" +from contextlib import nullcontext import json import pytest @@ -6,6 +7,7 @@ from ska.base.utils import ( get_groups_from_json, get_tango_device_type_id, GroupDefinitionsError, + for_testing_only ) TEST_GROUPS = { @@ -209,3 +211,46 @@ def test_get_tango_device_type_id(): device_name = "domain/family/member" result = get_tango_device_type_id(device_name) assert result == ["family", "member"] + + +@pytest.mark.parametrize( + "in_test, context", + [ + ( + False, + pytest.warns( + UserWarning, + match='foo should only be used for testing purposes' + ) + ), + (True, nullcontext()), + ] +) +def test_for_testing_only(in_test, context): + """ + Test the @for_testing_only decorator, to ensure that a warning is raised if and only + if we are NOT testing. This is achieved by patching the test, which cannot be done + using the @decorator syntax. + """ + def foo(): + """Dummy function for wrapping by decorator under test.""" + return "foo" + + foo = for_testing_only(foo, _testing_check=lambda: in_test) + + with context: + assert foo() == "foo" + + +def test_for_testing_only_decorator(): + """ + Test the unpatched for_testing_only decorator using the usual @decorator syntax + """ + @for_testing_only + def bah(): + """Dummy function for wrapping by decorator under test.""" + return "bah" + + with pytest.warns(None) as warning_record: + assert bah() == "bah" + assert len(warning_record) == 0 # no warning was raised because we are testing