Skip to content

Commit

Permalink
Test and update the wizard module (#466)
Browse files Browse the repository at this point in the history
Issue #343 is also fixed in this commit. 
The issue caused from when the accordion widget has all steps folder, the selected_index can be `None`, when the status of a step widget is reset, it triggers a widget update and hits at the exception.
  • Loading branch information
yakutovicha authored Mar 23, 2023
1 parent 3b7aff7 commit f3fdff2
Show file tree
Hide file tree
Showing 2 changed files with 107 additions and 14 deletions.
32 changes: 18 additions & 14 deletions aiidalab_widgets_base/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
* Carl Simon Adorf <[email protected]>
"""
from enum import Enum
from threading import Thread
from time import sleep, time
import enum
import threading
import time

import ipywidgets as ipw
import traitlets
import traitlets as tl


class AtLeastTwoStepsWizardError(ValueError):
Expand All @@ -21,10 +21,10 @@ def __init__(self, steps):
)


class WizardAppWidgetStep(traitlets.HasTraits):
class WizardAppWidgetStep(tl.HasTraits):
"One step of a WizardAppWidget."

class State(Enum):
class State(enum.Enum):
"""Each step is always in one specific state.
The state is used to determine:
Expand Down Expand Up @@ -68,8 +68,8 @@ class State(Enum):
# All error states have negative codes
FAIL = -1 # the step has unrecoverably failed

state = traitlets.UseEnum(State)
auto_advance = traitlets.Bool()
state = tl.UseEnum(State)
auto_advance = tl.Bool()

def can_reset(self):
return hasattr(self, "reset")
Expand All @@ -90,13 +90,13 @@ class WizardAppWidget(ipw.VBox):
@classmethod
def icons(cls):
"""Return the icon set and return animated icons based on the current time stamp."""
t = time()
t = time.time()
return {
key: item if isinstance(item, str) else item[int(t * len(item) % len(item))]
for key, item in cls.ICONS.items()
}

selected_index = traitlets.Int(allow_none=True)
selected_index = tl.Int(allow_none=True)

def __init__(self, steps, **kwargs):
# The number of steps must be greater than one
Expand All @@ -116,12 +116,14 @@ def __init__(self, steps, **kwargs):

# Automatically update titles to implement the "spinner"

self._run_update_thread = True

def spinner_thread():
while True:
sleep(0.1)
while self._run_update_thread:
time.sleep(0.1)
self._update_titles()

Thread(target=spinner_thread).start()
threading.Thread(target=spinner_thread).start()

# Watch for changes to each step's state
for widget in widgets:
Expand Down Expand Up @@ -178,6 +180,8 @@ def _consider_auto_advance(self, _=None):
This is performed whenever the current step is within the SUCCESS state and has
the auto_advance attribute set to True.
"""
if self.accordion.selected_index is None: # All children are hidden
return

with self.hold_trait_notifications():
index = self.accordion.selected_index
Expand All @@ -196,7 +200,7 @@ def _update_step_state(self, _):
self._update_buttons()
self._consider_auto_advance()

@traitlets.observe("selected_index")
@tl.observe("selected_index")
def _observe_selected_index(self, change):
"Activate/deactivate the next-button based on which step is selected."
self._update_buttons()
Expand Down
89 changes: 89 additions & 0 deletions tests/test_wizard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import ipywidgets as ipw
import traitlets as tl


def test_wizard_app_widget():
from aiidalab_widgets_base import WizardAppWidget, WizardAppWidgetStep

class Step1(ipw.HBox, WizardAppWidgetStep):
config = tl.Bool()

def __init__(self, **kwargs):
self.order_button = ipw.Button(description="Submit order", disabled=False)
self.order_button.on_click(self.submit_order)
super().__init__(children=[self.order_button], **kwargs)

def submit_order(self, _=None):
self.config = True

@tl.default("config")
def _default_config(self):
return False

def reset(self):
self.config = False

@tl.observe("config")
def _observe_config(self, _=None):
self.state = self.State.SUCCESS if self.config else self.State.INIT

class Step2(ipw.HBox, WizardAppWidgetStep):
config = tl.Bool()

def __init__(self, **kwargs):
self.results = ipw.HTML("Results")
super().__init__(children=[self.results], **kwargs)

def submit_order(self, _=None):
pass

@tl.default("config")
def _default_config(self):
return False

@tl.observe("config")
def _observe_config(self, change):
if self.config:
self.state = self.State.READY
else:
self.state = self.State.INIT

s1 = Step1(auto_advance=True)
s2 = Step2(auto_advance=True)
tl.dlink((s1, "config"), (s2, "config"))

widget = WizardAppWidget(
steps=[
("Setup", s1),
("View results", s2),
],
testing=True,
)

# Check initial state.
assert s1.state == s1.State.INIT
assert s2.state == s2.State.INIT
assert widget.accordion.selected_index == 0

s1.order_button.click()

# Check state after finishing the first step.
assert s1.state == s1.State.SUCCESS
assert s2.state == s2.State.READY
assert widget.accordion.selected_index == 1
assert widget.next_button.disabled is True

# Check state after resetting the app.
widget.reset()
assert s1.state == s1.State.INIT
assert s2.state == s2.State.INIT
assert widget.back_button.disabled is True

# Check state after finishing the first step again.
s1.order_button.click()
assert s1.state == s1.State.SUCCESS
widget.accordion.selected_index = None # All steps are closed.
s1.config = False # This should trigger an attempt to advance to the next step.

# Stop the thread that is running the header update to let pytest finish.
widget._run_update_thread = False

0 comments on commit f3fdff2

Please sign in to comment.