Merge lp:~frankban/juju-quickstart/env-manage-remove-environment into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 40
Proposed branch: lp:~frankban/juju-quickstart/env-manage-remove-environment
Merge into: lp:juju-quickstart
Diff against target: 562 lines (+322/-39)
7 files modified
quickstart/cli/ui.py (+67/-0)
quickstart/cli/views.py (+22/-2)
quickstart/models/envs.py (+15/-0)
quickstart/tests/cli/helpers.py (+35/-0)
quickstart/tests/cli/test_ui.py (+70/-0)
quickstart/tests/cli/test_views.py (+79/-31)
quickstart/tests/models/test_envs.py (+34/-6)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/env-manage-remove-environment
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+199698@code.launchpad.net

Description of the change

Implement the remove env functionality.

It is now possible to remove environments
using the interactive session.

A confirmation dialog prevents the user
to accidentally remove an environment.

Tests: `make check`.
QA: start the demo app
(`make` and `./cli-app-demo.py).
Remove environments at will: exiting
from the app you should see the
new environments YAML without the
environments you deleted.
Any UX suggestion welcome.
Thank you!

https://codereview.appspot.com/44310043/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+199698_code.launchpad.net,

Message:
Please take a look.

Description:
Implement the remove env functionality.

It is now possible to remove environments
using the interactive session.

A confirmation dialog prevents the user
to accidentally remove an environment.

Tests: `make check`.
QA: start the demo app
(`make` and `./cli-app-demo.py).
Remove environments at will: exiting
from the app you should see the
new environments YAML without the
environments you deleted.
Any UX suggestion welcome.
Thank you!

https://code.launchpad.net/~frankban/juju-quickstart/env-manage-remove-environment/+merge/199698

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/44310043/

Affected files (+306, -30 lines):
   A [revision details]
   M quickstart/cli/ui.py
   M quickstart/cli/views.py
   M quickstart/models/envs.py
   M quickstart/tests/cli/helpers.py
   M quickstart/tests/cli/test_ui.py
   M quickstart/tests/cli/test_views.py
   M quickstart/tests/models/test_envs.py

Revision history for this message
Gary Poster (gary) wrote :

LGTM.

The warning says "This action cannot be undone" but really it won't take
effect if the person exits early (^X), right? Because we only will save
at the end? If I'm right, maybe the following would work?

"""
Remove the private-cloud-errors-environment

This change will be saved once you choose an environment to deploy. The
action cannot be undone.
"""

I deleted my default. What is supposed to happen then? You don't have
a default anymore? (That's what happens now.)

QA good with those notes.

Thanks!

Gary

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py
File quickstart/cli/ui.py (right):

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py#newcode129
quickstart/cli/ui.py:129: original_contents = app.get_contents()
The way you can use this with the dismiss thunk is cool. Feels nice.

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/views.py#newcode277
quickstart/cli/views.py:277: ('control alert', 'remove'),
ui.thunk(confirm_removal, env_data)),
cool. I really like how this and the show_dialog actions work.

https://codereview.appspot.com/44310043/diff/1/quickstart/tests/cli/helpers.py
File quickstart/tests/cli/helpers.py (right):

https://codereview.appspot.com/44310043/diff/1/quickstart/tests/cli/helpers.py#newcode39
quickstart/tests/cli/helpers.py:39: def emit(self, widget):
Don't you have this in another base class? If so, maybe this ought to
be a standalone function? it does not require "self."

https://codereview.appspot.com/44310043/

Revision history for this message
Brad Crittenden (bac) wrote :

LGTM

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py
File quickstart/cli/ui.py (right):

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py#newcode125
quickstart/cli/ui.py:125: the function tries to generate suitable
defaults.
If the units are pixels please say so.

https://codereview.appspot.com/44310043/

Revision history for this message
Francesco Banconi (frankban) wrote :

On 2013/12/19 19:11:13, gary.poster wrote:
> LGTM.

> The warning says "This action cannot be undone" but really it won't
take effect
> if the person exits early (^X), right? Because we only will save at
the end?
> If I'm right, maybe the following would work?

In the current implementation, you pass a save_callable to the views,
which is
called when required, e.g. when the user removes an environment.
The save_callable accepts an env_db and it's not yet implemented:
it will likely be something like functools.partial(envs.save, env_file).
We can decide to change this, but I think this strategy gives us two
things:
1) the UX is less surprising for the user: when he performs an action,
that
action is done, and not just scheduled;
2) it is possible to use the quickstart interactive session to manage
environments without starting Juju: the user starts quickstart,
creates/edits/removes environments and then exits (^X).

> """
> Remove the private-cloud-errors-environment

> This change will be saved once you choose an environment to deploy.
The action
> cannot be undone.
> """

> I deleted my default. What is supposed to happen then? You don't
have a
> default anymore? (That's what happens now.)

Yes, in that case there is no longer a default in the environments.yaml
file, and
I have to check if juju-core is ok with this. If not, we need to change
this behavior. It's also important to define the concept of "default"
in the quickstart context: for now in Urwid it is just the YAML default,
but
in the real world it can be overwritten by juju switch (which, in turn,
looks for that value in the JUJU_ENV env var and in the current_env
file,
the latter being an implementation detail). When you run quickstart
without specifying -e, the default environment from juju switch is
started.
In the interactive session, the default environment is instead the one
in the
YAML file, and that's because the interactive session is intended to be
a CRUD for envs.yaml. We might want to either unify those two concepts
or
keep them separate and show both in the UX.

> QA good with those notes.

> Thanks!

Thank you!

https://codereview.appspot.com/44310043/diff/1/quickstart/tests/cli/helpers.py#newcode39
> quickstart/tests/cli/helpers.py:39: def emit(self, widget):
> Don't you have this in another base class? If so, maybe this ought to
be a
> standalone function? it does not require "self."

We had that, but now it is abstracted in this Mixin, used by
both test cases. That said, you are right, this does not
require self, so it can be just a function. The same for
get_button_caption and inspect_dialog. Fixed.

https://codereview.appspot.com/44310043/

52. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

Implement the remove env functionality.

It is now possible to remove environments
using the interactive session.

A confirmation dialog prevents the user
to accidentally remove an environment.

Tests: `make check`.
QA: start the demo app
(`make` and `./cli-app-demo.py).
Remove environments at will: exiting
from the app you should see the
new environments YAML without the
environments you deleted.
Any UX suggestion welcome.
Thank you!

R=gary.poster, bac
CC=
https://codereview.appspot.com/44310043

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py
File quickstart/cli/ui.py (right):

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py#newcode125
quickstart/cli/ui.py:125: the function tries to generate suitable
defaults.
On 2013/12/19 20:29:17, bac wrote:
> If the units are pixels please say so.

Those are Urwid specific values: if you pass
a number, the units are rows/columns, but you
can provide also other values: see
http://excess.org/urwid/docs/reference/widget.html#overlay
Updated the docstring.

https://codereview.appspot.com/44310043/

Revision history for this message
Francesco Banconi (frankban) wrote :

Thank you both for the reviews!

https://codereview.appspot.com/44310043/

Revision history for this message
Gary Poster (gary) wrote :

On 12/20/2013 05:24 AM, Francesco Banconi wrote:
> On 2013/12/19 19:11:13, gary.poster wrote:
>> LGTM.
>
>> The warning says "This action cannot be undone" but really it won't
> take effect
>> if the person exits early (^X), right? Because we only will save at
> the end?
>> If I'm right, maybe the following would work?
>
> In the current implementation, you pass a save_callable to the views,
> which is
> called when required, e.g. when the user removes an environment.
> The save_callable accepts an env_db and it's not yet implemented:
> it will likely be something like functools.partial(envs.save, env_file).
> We can decide to change this, but I think this strategy gives us two
> things:
> 1) the UX is less surprising for the user: when he performs an action,
> that
> action is done, and not just scheduled;

+1. I misunderstood the plan.

> 2) it is possible to use the quickstart interactive session to manage
> environments without starting Juju: the user starts quickstart,
> creates/edits/removes environments and then exits (^X).
>
>
>> """
>> Remove the private-cloud-errors-environment
>
>> This change will be saved once you choose an environment to deploy.
> The action
>> cannot be undone.
>> """
>
>> I deleted my default. What is supposed to happen then? You don't
> have a
>> default anymore? (That's what happens now.)
>
> Yes, in that case there is no longer a default in the environments.yaml
> file, and
> I have to check if juju-core is ok with this. If not, we need to change
> this behavior. It's also important to define the concept of "default"
> in the quickstart context: for now in Urwid it is just the YAML default,
> but
> in the real world it can be overwritten by juju switch (which, in turn,
> looks for that value in the JUJU_ENV env var and in the current_env
> file,
> the latter being an implementation detail). When you run quickstart
> without specifying -e, the default environment from juju switch is
> started.
> In the interactive session, the default environment is instead the one
> in the
> YAML file, and that's because the interactive session is intended to be
> a CRUD for envs.yaml. We might want to either unify those two concepts
> or
> keep them separate and show both in the UX.

Ah, yeah, that's a bit confusing (in Juju itself).

>
>
>> QA good with those notes.
>
>> Thanks!
>
> Thank you!
>
>
>
> https://codereview.appspot.com/44310043/diff/1/quickstart/tests/cli/helpers.py#newcode39
>> quickstart/tests/cli/helpers.py:39: def emit(self, widget):
>> Don't you have this in another base class? If so, maybe this ought to
> be a
>> standalone function? it does not require "self."
>
> We had that, but now it is abstracted in this Mixin, used by
> both test cases. That said, you are right, this does not
> require self, so it can be just a function. The same for
> get_button_caption and inspect_dialog. Fixed.
>
>
>
> https://codereview.appspot.com/44310043/
>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'quickstart/cli/ui.py'
--- quickstart/cli/ui.py 2013-12-18 11:34:12 +0000
+++ quickstart/cli/ui.py 2013-12-20 10:30:54 +0000
@@ -31,6 +31,9 @@
31 # See <http://excess.org/urwid/docs/reference/constants.html31 # See <http://excess.org/urwid/docs/reference/constants.html
32 # foreground-and-background-colors>.32 # foreground-and-background-colors>.
33 (None, 'light gray', 'black'),33 (None, 'light gray', 'black'),
34 ('dialog', 'dark gray', 'light gray'),
35 ('dialog header', 'light gray,bold', 'dark blue'),
36 ('control alert', 'light red', 'light gray'),
34 ('controls', 'dark gray', 'light gray'),37 ('controls', 'dark gray', 'light gray'),
35 ('edit', 'white,underline', 'black'),38 ('edit', 'white,underline', 'black'),
36 ('error', 'light red', 'black'),39 ('error', 'light red', 'black'),
@@ -47,6 +50,7 @@
47# Map attributes to new attributes to apply when the widget is selected.50# Map attributes to new attributes to apply when the widget is selected.
48FOCUS_MAP = {51FOCUS_MAP = {
49 None: 'selected',52 None: 'selected',
53 'control alert': 'error selected',
50 'error': 'error selected',54 'error': 'error selected',
51}55}
52# Define a default padding for the Urwid application.56# Define a default padding for the Urwid application.
@@ -100,6 +104,69 @@
100 self._w = urwid.AttrMap(icon, None, FOCUS_MAP)104 self._w = urwid.AttrMap(icon, None, FOCUS_MAP)
101105
102106
107def show_dialog(
108 app, title, message, actions=None, dismissable=True,
109 width=None, height=None):
110 """Display an interactive modal dialog.
111
112 This function receives the following arguments:
113 - app: the App named tuple used to configure the current interactive
114 session (see the quickstart.cli.base module);
115 - title: the title of the message dialog;
116 - message: the help message displayed in the dialog;
117 - actions: the actions which can be executed from the dialog, as a
118 sequence of (caption, callback) tuples. Those pairs are used to
119 generate the clickable controls (MenuButton instances) displayed
120 in the dialog;
121 - dismissable: if set to True, a "cancel" button is prepended to the
122 list of controls. Clicking the "cancel" button, the dialog just
123 disappears without further changes to the state of the application;
124 - width and height: optional dialog width and height as defined in
125 Urwid, e.g. 'pack', 'relative' or the number of rows/columns.
126 If not provided, the function tries to generate suitable defaults.
127
128 Return a function that can be called to dismiss the dialog.
129 """
130 original_contents = app.get_contents()
131 # Set up the dialog's header.
132 header = urwid.Pile([
133 urwid.Divider(),
134 urwid.Text(title, align='center'),
135 urwid.Divider(),
136 ])
137 # Set up the controls displayed in the dialog.
138 if actions is None:
139 controls = []
140 else:
141 controls = [
142 MenuButton(caption, callback) for caption, callback in actions]
143 # The dialog is removed by restoring the view original contents.
144 dismiss = thunk(app.set_contents, original_contents)
145 if dismissable:
146 controls.insert(0, MenuButton('cancel', dismiss))
147 # Create the listbox that replaces the original view contents.
148 widgets = [
149 urwid.AttrMap(header, 'dialog header'),
150 urwid.Divider(),
151 urwid.Text(message, align='center'),
152 create_controls(*controls),
153 ]
154 listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
155 if width is None:
156 # Calculate the dialog width: the max from the title/message length +
157 # two padding spaces for each side.
158 width = max(len(title), len(message)) + 4
159 if height is None:
160 # A height of eleven is usually enough for the dialog.
161 height = 11
162 contents = urwid.Overlay(
163 urwid.AttrMap(listbox, 'dialog'), original_contents,
164 align='center', width=width,
165 valign='middle', height=height)
166 app.set_contents(contents)
167 return dismiss
168
169
103def thunk(function, *args, **kwargs):170def thunk(function, *args, **kwargs):
104 """Create and return a callable binding the given method and args/kwargs.171 """Create and return a callable binding the given method and args/kwargs.
105172
106173
=== modified file 'quickstart/cli/views.py'
--- quickstart/cli/views.py 2013-12-19 11:10:46 +0000
+++ quickstart/cli/views.py 2013-12-20 10:30:54 +0000
@@ -228,6 +228,23 @@
228 app.set_message('{} successfully set as default'.format(env_name))228 app.set_message('{} successfully set as default'.format(env_name))
229 index_view()229 index_view()
230230
231 def remove(env_data):
232 # The environment deletion is confirmed: remove the environment from
233 # the database, save the new env_db and return to the index view.
234 envs.remove_env(env_db, env_data['name'])
235 save_callable(env_db)
236 index_view()
237
238 def confirm_removal(env_data):
239 # Ask confirmation before removing an environment.
240 ui.show_dialog(
241 app, 'Remove the {} environment'.format(env_data['name']),
242 'This action cannot be undone!',
243 actions=[
244 (('control alert', 'confirm'), ui.thunk(remove, env_data)),
245 ],
246 )
247
231 env_metadata = envs.get_env_metadata(env_type_db, env_data)248 env_metadata = envs.get_env_metadata(env_type_db, env_data)
232 app.set_title(envs.get_env_short_description(env_data))249 app.set_title(envs.get_env_short_description(env_data))
233 app.set_status('')250 app.set_status('')
@@ -254,8 +271,11 @@
254 if not env_data['is-default']:271 if not env_data['is-default']:
255 controls.append(272 controls.append(
256 ui.MenuButton('set default', ui.thunk(set_default, env_data)))273 ui.MenuButton('set default', ui.thunk(set_default, env_data)))
257 # XXX frankban 2013-12-18: implement the "remove env" functionality.274 controls.extend([
258 # XXX frankban 2013-12-18: implement the env modification view.275 # XXX frankban 2013-12-18: implement the env modification view.
276 ui.MenuButton(
277 ('control alert', 'remove'), ui.thunk(confirm_removal, env_data)),
278 ])
259 widgets.append(ui.create_controls(*controls))279 widgets.append(ui.create_controls(*controls))
260 listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))280 listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
261 app.set_contents(listbox)281 app.set_contents(listbox)
262282
=== modified file 'quickstart/models/envs.py'
--- quickstart/models/envs.py 2013-12-17 15:30:48 +0000
+++ quickstart/models/envs.py 2013-12-20 10:30:54 +0000
@@ -296,6 +296,21 @@
296 del env_db['default']296 del env_db['default']
297297
298298
299def remove_env(env_db, env_name):
300 """Remove the environment named env_name from the environments database.
301
302 Raise a ValueError if the environment is not present in env_db.
303 Without errors, the env_db is modified in place and None is returned.
304 """
305 try:
306 del env_db['environments'][env_name]
307 except KeyError:
308 raise ValueError(
309 b'the environment named {!r} does not exist'.format(env_name))
310 if env_db.get('default') == env_name:
311 del env_db['default']
312
313
299def get_env_type_db():314def get_env_type_db():
300 """Return the environments meta information based on provider types.315 """Return the environments meta information based on provider types.
301316
302317
=== modified file 'quickstart/tests/cli/helpers.py'
--- quickstart/tests/cli/helpers.py 2013-12-19 17:41:40 +0000
+++ quickstart/tests/cli/helpers.py 2013-12-20 10:30:54 +0000
@@ -18,6 +18,8 @@
1818
19from __future__ import unicode_literals19from __future__ import unicode_literals
2020
21import urwid
22
21from quickstart.cli import ui23from quickstart.cli import ui
2224
2325
@@ -29,3 +31,36 @@
29 with self.assertRaises(ui.AppExit) as context_manager:31 with self.assertRaises(ui.AppExit) as context_manager:
30 loop.unhandled_input(ui.EXIT_KEY)32 loop.unhandled_input(ui.EXIT_KEY)
31 return context_manager.exception.return_value33 return context_manager.exception.return_value
34
35
36def get_button_caption(button):
37 """Return the button caption as a string."""
38 return button._w.original_widget.text
39
40
41def emit(widget):
42 """Emit the first signal associated withe the given widget.
43
44 This is usually invoked to click buttons.
45 """
46 # Retrieve the first signal name (usually is 'click').
47 signal_name = widget.signals[0]
48 urwid.emit_signal(widget, signal_name, widget)
49
50
51def inspect_dialog(contents):
52 """Inspect the widgets composing the dialog.
53
54 Return a tuple (title_widget, messaqe_widget, buttons) where:
55 - title_widget is the Text widget displaying the dialog title;
56 - message_widget is the Text widget displaying the dialog message;
57 - buttons is a list of MenuButton widgets included in the dialog.
58 """
59 listbox = contents.top_w.base_widget
60 header, _, message_widget, controls = listbox.body
61 # The title widget is the second one in the header Pile.
62 title_widget = header.base_widget.contents[1][0]
63 # The button columns is the second widget in the Pile.
64 columns = controls.contents[1][0].base_widget
65 buttons = [content[0].base_widget for content in columns.contents]
66 return title_widget, message_widget, buttons
3267
=== modified file 'quickstart/tests/cli/test_ui.py'
--- quickstart/tests/cli/test_ui.py 2013-12-18 14:04:14 +0000
+++ quickstart/tests/cli/test_ui.py 2013-12-20 10:30:54 +0000
@@ -27,6 +27,7 @@
27 base,27 base,
28 ui,28 ui,
29)29)
30from quickstart.tests.cli import helpers as cli_helpers
3031
3132
32class TestAppExit(unittest.TestCase):33class TestAppExit(unittest.TestCase):
@@ -95,6 +96,75 @@
95 callback.assert_called_once_with(button)96 callback.assert_called_once_with(button)
9697
9798
99class TestShowDialog(cli_helpers.CliAppTestsMixin, unittest.TestCase):
100
101 def setUp(self):
102 # Set up the base Urwid application.
103 _, self.app = base.setup_urwid_app()
104 self.original_contents = self.app.get_contents()
105
106 def test_dialog_rendering(self):
107 # The dialog is correctly displayed without additional controls.
108 ui.show_dialog(self.app, 'my title', 'my message')
109 contents = self.app.get_contents()
110 self.assertIsNot(contents, self.original_contents)
111 title_widget, message_widget, buttons = cli_helpers.inspect_dialog(
112 contents)
113 self.assertEqual('my title', title_widget.text)
114 self.assertEqual('my message', message_widget.text)
115 # The dialog only includes the "cancel" button.
116 self.assertEqual(1, len(buttons))
117 caption = cli_helpers.get_button_caption(buttons[0])
118 self.assertEqual('cancel', caption)
119
120 def test_dialog_cancel_action(self):
121 # A dialog can be properly dismissed clicking the cancel button.
122 ui.show_dialog(self.app, 'my title', 'my message')
123 contents = self.app.get_contents()
124 buttons = cli_helpers.inspect_dialog(contents)[2]
125 cancel_button = buttons[0]
126 cli_helpers.emit(cancel_button)
127 # The original contents have been restored.
128 self.assertIs(self.original_contents, self.app.get_contents())
129
130 def test_dialog_customized_actions(self):
131 # A button is displayed for each customized action provided.
132 performed = []
133 actions = [
134 ('push me', ui.thunk(performed.append, 'push')),
135 ('pull me', ui.thunk(performed.append, 'pull')),
136 ]
137 ui.show_dialog(self.app, 'my title', 'my message', actions=actions)
138 contents = self.app.get_contents()
139 buttons = cli_helpers.inspect_dialog(contents)[2]
140 # Three buttons are displayed: "cancel", "push me" and "pull me".
141 self.assertEqual(3, len(buttons))
142 captions = [
143 cli_helpers.get_button_caption(button) for button in buttons]
144 self.assertEqual(['cancel', 'push me', 'pull me'], captions)
145 push, pull = buttons[1:]
146 # Click the "push me" button.
147 cli_helpers.emit(push)
148 self.assertEqual(['push'], performed)
149 # Click the "pull me" button two times.
150 cli_helpers.emit(pull)
151 cli_helpers.emit(pull)
152 self.assertEqual(['push', 'pull', 'pull'], performed)
153
154 def test_not_dismissable(self):
155 # The cancel button is not present if the dialog is not dismissable.
156 actions = [('push me', lambda *args: None)]
157 ui.show_dialog(
158 self.app, 'my title', 'my message',
159 actions=actions, dismissable=False)
160 contents = self.app.get_contents()
161 buttons = cli_helpers.inspect_dialog(contents)[2]
162 # Only the "push me" button is displayed.
163 self.assertEqual(1, len(buttons))
164 caption = cli_helpers.get_button_caption(buttons[0])
165 self.assertEqual('push me', caption)
166
167
98class TestThunk(unittest.TestCase):168class TestThunk(unittest.TestCase):
99169
100 widget = 'test-widget'170 widget = 'test-widget'
101171
=== modified file 'quickstart/tests/cli/test_views.py'
--- quickstart/tests/cli/test_views.py 2013-12-19 10:59:57 +0000
+++ quickstart/tests/cli/test_views.py 2013-12-20 10:30:54 +0000
@@ -119,19 +119,6 @@
119 """119 """
120 return lambda arg: isinstance(arg, cls)120 return lambda arg: isinstance(arg, cls)
121121
122 def get_button_caption(self, button):
123 """Return the button caption as a string."""
124 return button._w.original_widget.text
125
126 def emit(self, widget):
127 """Emit the first signal associated withe the given widget.
128
129 This is usually invoked to click buttons.
130 """
131 # Retrieve the first signal name (usually is 'click').
132 signal_name = widget.signals[0]
133 urwid.emit_signal(widget, signal_name, widget)
134
135122
136class TestEnvIndex(EnvViewTestsMixin, unittest.TestCase):123class TestEnvIndex(EnvViewTestsMixin, unittest.TestCase):
137124
@@ -177,10 +164,11 @@
177 env_data = envs.get_env_data(env_db, env_name)164 env_data = envs.get_env_data(env_db, env_name)
178 # The caption includes the environment description.165 # The caption includes the environment description.
179 env_description = envs.get_env_short_description(env_data)166 env_description = envs.get_env_short_description(env_data)
180 self.assertIn(env_description, self.get_button_caption(button))167 self.assertIn(
168 env_description, cli_helpers.get_button_caption(button))
181 # When the button is clicked, the detail view is called passing the169 # When the button is clicked, the detail view is called passing the
182 # corresponding environment data.170 # corresponding environment data.
183 self.emit(button)171 cli_helpers.emit(button)
184 mock_env_detail.assert_called_once_with(172 mock_env_detail.assert_called_once_with(
185 self.app, self.env_type_db, env_db, self.save_callable,173 self.app, self.env_type_db, env_db, self.save_callable,
186 env_data)174 env_data)
@@ -197,7 +185,8 @@
197 contents = self.app.get_contents()185 contents = self.app.get_contents()
198 focused_widget = contents.get_focus()[0]186 focused_widget = contents.get_focus()[0]
199 self.assertIsInstance(focused_widget, ui.MenuButton)187 self.assertIsInstance(focused_widget, ui.MenuButton)
200 self.assertIn(env_description, self.get_button_caption(focused_widget))188 self.assertIn(
189 env_description, cli_helpers.get_button_caption(focused_widget))
201190
202 def test_status_with_errors(self):191 def test_status_with_errors(self):
203 # The status message explains how errors are displayed.192 # The status message explains how errors are displayed.
@@ -272,35 +261,36 @@
272 self.assertEqual(expected_text, widget.text)261 self.assertEqual(expected_text, widget.text)
273262
274 def test_view_buttons(self):263 def test_view_buttons(self):
275 # The following buttons are displayed: "back", "use" and "set default".264 # The following buttons are displayed: "back", "use", "set default" and
265 # "remove".
276 self.call_view(env_name='ec2-west')266 self.call_view(env_name='ec2-west')
277 buttons = self.get_control_buttons()267 buttons = self.get_control_buttons()
278 captions = [self.get_button_caption(button) for button in buttons]268 captions = map(cli_helpers.get_button_caption, buttons)
279 self.assertEqual(['back', 'use', 'set default'], captions)269 self.assertEqual(['back', 'use', 'set default', 'remove'], captions)
280270
281 def test_view_buttons_default(self):271 def test_view_buttons_default(self):
282 # If the environment is the default one, the "set default" button is272 # If the environment is the default one, the "set default" button is
283 # not displayed. The buttons we expect are "back" and "use".273 # not displayed. The buttons we expect are "back", "use" and "remove".
284 self.call_view(env_name='lxc')274 self.call_view(env_name='lxc')
285 buttons = self.get_control_buttons()275 buttons = self.get_control_buttons()
286 captions = [self.get_button_caption(button) for button in buttons]276 captions = map(cli_helpers.get_button_caption, buttons)
287 self.assertEqual(['back', 'use'], captions)277 self.assertEqual(['back', 'use', 'remove'], captions)
288278
289 def test_view_buttons_error(self):279 def test_view_buttons_error(self):
290 # If the environment is not valid, the "use" button is not displayed.280 # If the environment is not valid, the "use" button is not displayed.
291 # The buttons we expect are "back" and "set default".281 # The buttons we expect are "back", "set default" and "remove".
292 self.call_view(env_name='local-with-errors')282 self.call_view(env_name='local-with-errors')
293 buttons = self.get_control_buttons()283 buttons = self.get_control_buttons()
294 captions = [self.get_button_caption(button) for button in buttons]284 captions = map(cli_helpers.get_button_caption, buttons)
295 self.assertEqual(['back', 'set default'], captions)285 self.assertEqual(['back', 'set default', 'remove'], captions)
296286
297 @mock.patch('quickstart.cli.views.env_index')287 @mock.patch('quickstart.cli.views.env_index')
298 def test_back_button(self, mock_env_index):288 def test_back_button(self, mock_env_index):
299 # The index view is called if the "back" button is clicked.289 # The index view is called if the "back" button is clicked.
300 self.call_view(env_name='ec2-west')290 self.call_view(env_name='ec2-west')
301 # The control buttons are: "back", "use" and "set default".291 # The "back" button is the first one.
302 back_button = self.get_control_buttons()[0]292 back_button = self.get_control_buttons()[0]
303 self.emit(back_button)293 cli_helpers.emit(back_button)
304 mock_env_index.assert_called_once_with(294 mock_env_index.assert_called_once_with(
305 self.app, self.env_type_db, self.env_db, self.save_callable)295 self.app, self.env_type_db, self.env_db, self.save_callable)
306296
@@ -308,10 +298,10 @@
308 # The application exits if the "use" button is clicked.298 # The application exits if the "use" button is clicked.
309 # The env_db and the current environment data are returned.299 # The env_db and the current environment data are returned.
310 self.call_view(env_name='ec2-west')300 self.call_view(env_name='ec2-west')
311 # The control buttons are: "back", "use" and "set default".301 # The "use" button is the second one.
312 use_button = self.get_control_buttons()[1]302 use_button = self.get_control_buttons()[1]
313 with self.assertRaises(ui.AppExit) as context_manager:303 with self.assertRaises(ui.AppExit) as context_manager:
314 self.emit(use_button)304 cli_helpers.emit(use_button)
315 expected_return_value = (self.env_db, self.env_data)305 expected_return_value = (self.env_db, self.env_data)
316 self.assertEqual(306 self.assertEqual(
317 expected_return_value, context_manager.exception.return_value)307 expected_return_value, context_manager.exception.return_value)
@@ -321,9 +311,9 @@
321 # The current environment is set as default if the "set default" button311 # The current environment is set as default if the "set default" button
322 # is clicked. Subsequently the application switches to the index view.312 # is clicked. Subsequently the application switches to the index view.
323 self.call_view(env_name='ec2-west')313 self.call_view(env_name='ec2-west')
324 # The control buttons are: "back", "use" and "set default".314 # The "set default" button is the third one.
325 set_default_button = self.get_control_buttons()[2]315 set_default_button = self.get_control_buttons()[2]
326 self.emit(set_default_button)316 cli_helpers.emit(set_default_button)
327 # The index view has been called passing the modified env_db as third317 # The index view has been called passing the modified env_db as third
328 # argument.318 # argument.
329 self.assertTrue(mock_env_index.called)319 self.assertTrue(mock_env_index.called)
@@ -333,6 +323,64 @@
333 # The new env_db has been saved.323 # The new env_db has been saved.
334 self.save_callable.assert_called_once_with(new_env_db)324 self.save_callable.assert_called_once_with(new_env_db)
335325
326 def test_remove_button(self):
327 # A confirmation dialog is displayed if the "remove" button is clicked.
328 self.call_view(env_name='ec2-west')
329 original_contents = self.app.get_contents()
330 # The "remove" button is the last one.
331 remove_button = self.get_control_buttons()[-1]
332 cli_helpers.emit(remove_button)
333 # The original env detail contents have been replaced.
334 contents = self.app.get_contents()
335 self.assertIsNot(contents, original_contents)
336 # A "remove" confirmation dialog is displayed.
337 title_widget, message_widget, buttons = cli_helpers.inspect_dialog(
338 contents)
339 self.assertEqual('Remove the ec2-west environment', title_widget.text)
340 self.assertEqual('This action cannot be undone!', message_widget.text)
341 # The dialog includes the "cancel" and "confirm" buttons.
342 self.assertEqual(2, len(buttons))
343 captions = map(cli_helpers.get_button_caption, buttons)
344 self.assertEqual(['cancel', 'confirm'], captions)
345
346 def test_remove_cancelled(self):
347 # The "remove" confirmation dialog can be safely dismissed.
348 self.call_view(env_name='ec2-west')
349 original_contents = self.app.get_contents()
350 # The "remove" button is the last one.
351 remove_button = self.get_control_buttons()[-1]
352 cli_helpers.emit(remove_button)
353 contents = self.app.get_contents()
354 buttons = cli_helpers.inspect_dialog(contents)[2]
355 # The "cancel" button is the first one in the dialog.
356 cancel_button = buttons[0]
357 cli_helpers.emit(cancel_button)
358 # The original contents have been restored.
359 self.assertIs(original_contents, self.app.get_contents())
360
361 @mock.patch('quickstart.cli.views.env_index')
362 def test_remove_confirmed(self, mock_env_index):
363 # The current environment is removed if the "remove" button is clicked
364 # and then the deletion is confirmed. Subsequently the application
365 # switches to the index view.
366 self.call_view(env_name='ec2-west')
367 # The "remove" button is the last one.
368 remove_button = self.get_control_buttons()[-1]
369 cli_helpers.emit(remove_button)
370 contents = self.app.get_contents()
371 buttons = cli_helpers.inspect_dialog(contents)[2]
372 # The "confirm" button is the second one in the dialog.
373 confirm_button = buttons[1]
374 cli_helpers.emit(confirm_button)
375 # The index view has been called passing the modified env_db as third
376 # argument.
377 self.assertTrue(mock_env_index.called)
378 new_env_db = mock_env_index.call_args[0][2]
379 # The new env_db no longer includes the "ec2-west" environment.
380 self.assertNotIn('ec2-west', new_env_db['environments'])
381 # The new env_db has been saved.
382 self.save_callable.assert_called_once_with(new_env_db)
383
336 def test_status_with_errors(self):384 def test_status_with_errors(self):
337 # The status message explains how field errors are displayed.385 # The status message explains how field errors are displayed.
338 self.call_view(env_name='local-with-errors')386 self.call_view(env_name='local-with-errors')
339387
=== modified file 'quickstart/tests/models/test_envs.py'
--- quickstart/tests/models/test_envs.py 2013-12-17 15:30:48 +0000
+++ quickstart/tests/models/test_envs.py 2013-12-20 10:30:54 +0000
@@ -225,7 +225,7 @@
225 self.assertIn('new: contents', contents)225 self.assertIn('new: contents', contents)
226226
227227
228class GetSetEnvDataTestsMixin(object):228class EnvDataTestsMixin(object):
229 """Set up an initial environments dictionary."""229 """Set up an initial environments dictionary."""
230230
231 def setUp(self):231 def setUp(self):
@@ -245,7 +245,7 @@
245 },245 },
246 }246 }
247 self.original = copy.deepcopy(self.env_db)247 self.original = copy.deepcopy(self.env_db)
248 super(GetSetEnvDataTestsMixin, self).setUp()248 super(EnvDataTestsMixin, self).setUp()
249249
250 def assert_env_db_not_modified(self):250 def assert_env_db_not_modified(self):
251 """Ensure the stored env_db is not modified."""251 """Ensure the stored env_db is not modified."""
@@ -253,8 +253,7 @@
253253
254254
255class TestGetEnvData(255class TestGetEnvData(
256 GetSetEnvDataTestsMixin, helpers.ValueErrorTestsMixin,256 EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
257 unittest.TestCase):
258257
259 def test_env_not_found(self):258 def test_env_not_found(self):
260 # A ValueError is raised if an environment with the given name is not259 # A ValueError is raised if an environment with the given name is not
@@ -301,8 +300,7 @@
301300
302301
303class TestSetEnvData(302class TestSetEnvData(
304 GetSetEnvDataTestsMixin, helpers.ValueErrorTestsMixin,303 EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
305 unittest.TestCase):
306304
307 def test_error_no_name_key(self):305 def test_error_no_name_key(self):
308 # A ValueError is raised if the env_data dictionary does not include306 # A ValueError is raised if the env_data dictionary does not include
@@ -469,6 +467,36 @@
469 self.assertNotIn('default', self.env_db)467 self.assertNotIn('default', self.env_db)
470468
471469
470class TestRemoveEnv(
471 EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
472
473 def test_removal(self):
474 # An environment is successfully removed from the environments db.
475 envs.remove_env(self.env_db, 'aws')
476 environments = self.env_db['environments']
477 self.assertNotIn('aws', environments)
478 # The other environments are not removed.
479 self.assertIn('lxc', environments)
480 # The default environment is not modified.
481 self.assertEqual('lxc', self.env_db['default'])
482
483 def test_default_environment_removal(self):
484 # An environment is successfully removed even if it is the default one.
485 envs.remove_env(self.env_db, 'lxc')
486 environments = self.env_db['environments']
487 self.assertNotIn('lxc', environments)
488 # The other environments are not removed.
489 self.assertIn('aws', environments)
490 # The environments database no longer includes a default environment.
491 self.assertNotIn('default', self.env_db)
492
493 def test_invalid_environment_name(self):
494 # A ValueError is raised if the environment is not present in env_db.
495 expected = "the environment named u'no-such' does not exist"
496 with self.assert_value_error(expected):
497 envs.remove_env(self.env_db, 'no-such')
498
499
472class TestGetEnvTypeDb(unittest.TestCase):500class TestGetEnvTypeDb(unittest.TestCase):
473501
474 def setUp(self):502 def setUp(self):

Subscribers

People subscribed via source and target branches