Merge lp:~makyo/juju-quickstart/env-manage-edit into lp:juju-quickstart
- env-manage-edit
- Merge into trunk
Proposed by
Madison Scott-Clary
Status: | Work in progress |
---|---|
Proposed branch: | lp:~makyo/juju-quickstart/env-manage-edit |
Merge into: | lp:juju-quickstart |
Diff against target: |
739 lines (+547/-18) 7 files modified
quickstart/cli/forms.py (+199/-0) quickstart/cli/ui.py (+3/-0) quickstart/cli/views.py (+123/-6) quickstart/models/envs.py (+1/-1) quickstart/tests/cli/test_forms.py (+148/-0) quickstart/tests/cli/test_views.py (+59/-11) quickstart/tests/models/test_envs.py (+14/-0) |
To merge this branch: | bzr merge lp:~makyo/juju-quickstart/env-manage-edit |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email: mp+200024@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Unmerged revisions
- 51. By Madison Scott-Clary
-
Lint for wip checkpoint
- 50. By Madison Scott-Clary
-
Checkpoint
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === added file 'quickstart/cli/forms.py' |
2 | --- quickstart/cli/forms.py 1970-01-01 00:00:00 +0000 |
3 | +++ quickstart/cli/forms.py 2013-12-24 19:19:29 +0000 |
4 | @@ -0,0 +1,199 @@ |
5 | +# This file is part of the Juju Quickstart Plugin, which lets users set up a |
6 | +# Juju environment in very few steps (https://launchpad.net/juju-quickstart). |
7 | +# Copyright (C) 2013 Canonical Ltd. |
8 | +# |
9 | +# This program is free software: you can redistribute it and/or modify it under |
10 | +# the terms of the GNU Affero General Public License version 3, as published by |
11 | +# the Free Software Foundation. |
12 | +# |
13 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
14 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
15 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
16 | +# Affero General Public License for more details. |
17 | +# |
18 | +# You should have received a copy of the GNU Affero General Public License |
19 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
20 | + |
21 | +"""Juju Quickstart CLI forms management. |
22 | + |
23 | +This module contains a collection of functions which help creating and |
24 | +manipulating forms in Urwid. |
25 | +""" |
26 | + |
27 | +from __future__ import unicode_literals |
28 | + |
29 | +import functools |
30 | + |
31 | +import urwid |
32 | + |
33 | +from quickstart.cli import ui |
34 | + |
35 | + |
36 | +# Define the value used in boolean widget to specify they allow mixed state. |
37 | +MIXED = 'mixed' |
38 | + |
39 | + |
40 | +def _generate(generate_callable, edit_widget): |
41 | + """Update the widget contents using generate_callable. |
42 | + |
43 | + The given callable function takes no arguments and returns a string. |
44 | + """ |
45 | + edit_widget.set_edit_text(generate_callable()) |
46 | + |
47 | + |
48 | +def create_string_widget(field, value, error): |
49 | + """Create a string widget and return a tuple (widget, value_getter). |
50 | + |
51 | + Receives a Field instance (see quickstart.models.fields), the field value, |
52 | + and an error string (or None if the field has no errors). |
53 | + |
54 | + In the tuple, widget is a Urwid widget suitable for editing string values, |
55 | + and value_getter is a callable returning the value currently stored in the |
56 | + widget. The value_getter callable must be called without arguments. |
57 | + """ |
58 | + if value is None: |
59 | + # Unset values are converted to empty strings. |
60 | + value = '' |
61 | + elif not isinstance(value, unicode): |
62 | + # We do not expect byte strings, and all other values are converted to |
63 | + # unicode strings. |
64 | + value = unicode(value) |
65 | + caption_class = 'highlight' if field.required else 'optional' |
66 | + caption = [] |
67 | + widgets = [] |
68 | + if error: |
69 | + caption.append(('error', '\N{BULLET} ')) |
70 | + # Display the error message above the edit widget. |
71 | + widgets.append(urwid.Text(('error', error))) |
72 | + caption.append((caption_class, '{}: '.format(field.label))) |
73 | + edit_widget = urwid.Edit(edit_text=value) |
74 | + widget = urwid.Columns([('pack', urwid.Text(caption)), edit_widget]) |
75 | + if field.readonly: |
76 | + widgets.append(urwid.WidgetDisable(widget)) |
77 | + else: |
78 | + widgets.append(urwid.AttrMap(widget, 'edit')) |
79 | + if field.help: |
80 | + # Display the field help message below the edit widget. |
81 | + widgets.append(urwid.Text(field.help)) |
82 | + if not field.readonly: |
83 | + # Can the value be automatically generated? |
84 | + generate_method = getattr(field, 'generate', None) |
85 | + if generate_method is not None: |
86 | + generate_callable = ui.thunk( |
87 | + _generate, generate_method, edit_widget) |
88 | + generate_button = ui.MenuButton( |
89 | + ('highlight', 'automatically generate'), generate_callable) |
90 | + generate_columns = urwid.Columns([ |
91 | + ('pack', urwid.Text('it is possible to ')), |
92 | + ('pack', generate_button), |
93 | + urwid.Text(' this value'), |
94 | + ]) |
95 | + widgets.append(generate_columns) |
96 | + # If the value must be in a range of choices, display the possible choices |
97 | + # as part of the help message. |
98 | + choices = getattr(field, 'choices', None) |
99 | + if choices is not None: |
100 | + # TODO: the choices are clickable and update the edit widget. |
101 | + text = 'possible values are {}'.format(', '.join(choices)) |
102 | + if not field.required: |
103 | + text = '{}, but this field can also be left empty'.format(text) |
104 | + widgets.append(urwid.Text(text)) |
105 | + if field.default is not None: |
106 | + widgets.append( |
107 | + urwid.Text('default if not set: {}'.format(field.default))) |
108 | + widgets.append(urwid.Divider()) |
109 | + return urwid.Pile(widgets), edit_widget.get_edit_text |
110 | + |
111 | + |
112 | +def create_bool_widget(field, value, error): |
113 | + """Create a boolean widget and return a tuple (widget, value_getter). |
114 | + |
115 | + Receives a Field instance (see quickstart.models.fields), the field value, |
116 | + and an error string (or None if the field has no errors). |
117 | + |
118 | + In the tuple, widget is a Urwid widget suitable for editing boolean values |
119 | + (a checkbox), and value_getter is a callable returning the value currently |
120 | + stored in the widget. The value_getter callable receives no arguments. |
121 | + """ |
122 | + if value is None: |
123 | + # Unset values are converted to a more convenient value for the |
124 | + # checkbox (a boolean or a mixed state if allowed by the field). |
125 | + value = MIXED if field.allow_mixed else bool(field.default) |
126 | + label = ('highlight', field.label) |
127 | + widget = urwid.CheckBox(label, state=value, has_mixed=field.allow_mixed) |
128 | + widgets = [] |
129 | + if error: |
130 | + # Display the error message above the checkbox. |
131 | + widgets.append(urwid.Text(('error', error))) |
132 | + widgets.append(widget) |
133 | + if field.help: |
134 | + # Display the field help message below the checkbox. |
135 | + widgets.append(urwid.Text(field.help)) |
136 | + widgets.append(urwid.Divider()) |
137 | + |
138 | + def get_state(): |
139 | + # Reconvert mixed value to None value. |
140 | + state = widget.get_state() |
141 | + return None if state == MIXED else state |
142 | + |
143 | + return urwid.Pile(widgets), get_state |
144 | + |
145 | + |
146 | +def create_form(field_value_pairs, errors): |
147 | + """Create and return the form widgets for each field/value pair. |
148 | + |
149 | + Return a tuple (widgets, values_getter) in which: |
150 | + - widgets is a list if Urwid objects that can be used to build view |
151 | + contents (e.g. by wrapping them in a urwid.ListBox); |
152 | + - data_getter is a function returning a dictionary mapping field names |
153 | + to values. Using the data_getter function it is always possible to |
154 | + retrieve the actual new field values as changed by the user. |
155 | + |
156 | + The field_value_pairs argument is a list of (field, value) tuples where |
157 | + field is a Field instance (see quickstart.models.fields) and value is |
158 | + the corresponding field value. |
159 | + |
160 | + The errors argument is a dictionary mapping field names to error messages. |
161 | + Passing an empty dictionary means the form has no errors. |
162 | + """ |
163 | + form = {} |
164 | + widgets = [] |
165 | + if errors: |
166 | + # Inform the user that the form has errors that need to be fixed. |
167 | + widgets.extend([ |
168 | + urwid.Text(('error', 'please correct the errors below')), |
169 | + urwid.Divider(), |
170 | + ]) |
171 | + # Build a widget and populate the form for each field/value pair. |
172 | + for field, value in field_value_pairs: |
173 | + error = errors.get(field.name) |
174 | + if field.field_type == 'bool': |
175 | + # Boolean values as represented as a checkbox. |
176 | + widget, value_getter = create_bool_widget(field, value, error) |
177 | + else: |
178 | + # All the other field types are displayed as strings. |
179 | + widget, value_getter = create_string_widget(field, value, error) |
180 | + widgets.append(widget) |
181 | + form[field.name] = value_getter |
182 | + return widgets, functools.partial(_get_data, form) |
183 | + |
184 | + |
185 | +def _get_data(form): |
186 | + """Return a dictionary mapping the given form field names to values. |
187 | + |
188 | + This is done just calling all the value getters in the form. |
189 | + """ |
190 | + return dict((key, value_getter()) for key, value_getter in form.items()) |
191 | + |
192 | + |
193 | +def create_actions(actions): |
194 | + """Return the control widgets based on the given actions. |
195 | + |
196 | + The actions arguments is as a sequence of (caption, callback) tuples. Those |
197 | + pairs are used to generate the clickable controls (MenuButton instances) |
198 | + used to manipulate the form. |
199 | + """ |
200 | + controls = [ |
201 | + ui.MenuButton(caption, callback) for caption, callback in actions |
202 | + ] |
203 | + return ui.create_controls(*controls) |
204 | |
205 | === modified file 'quickstart/cli/ui.py' |
206 | --- quickstart/cli/ui.py 2013-12-20 10:30:37 +0000 |
207 | +++ quickstart/cli/ui.py 2013-12-24 19:19:29 +0000 |
208 | @@ -45,6 +45,8 @@ |
209 | ('highlight', 'white', 'black'), |
210 | ('line header', 'dark gray', 'dark magenta'), |
211 | ('line footer', 'light gray', 'light gray'), |
212 | + ('optional', 'light magenta', 'black'), |
213 | + ('optional status', 'light magenta', 'light gray'), |
214 | ('selected', 'white', 'dark blue'), |
215 | ] |
216 | # Map attributes to new attributes to apply when the widget is selected. |
217 | @@ -52,6 +54,7 @@ |
218 | None: 'selected', |
219 | 'control alert': 'error selected', |
220 | 'error': 'error selected', |
221 | + 'highlight': 'selected', |
222 | } |
223 | # Define a default padding for the Urwid application. |
224 | padding = functools.partial(urwid.Padding, left=2, right=2) |
225 | |
226 | === modified file 'quickstart/cli/views.py' |
227 | --- quickstart/cli/views.py 2013-12-19 17:40:36 +0000 |
228 | +++ quickstart/cli/views.py 2013-12-24 19:19:29 +0000 |
229 | @@ -104,6 +104,7 @@ |
230 | |
231 | from quickstart.cli import ( |
232 | base, |
233 | + forms, |
234 | ui, |
235 | ) |
236 | from quickstart.models import envs |
237 | @@ -132,6 +133,8 @@ |
238 | """Show the Juju environments list. |
239 | |
240 | The env_detail view is displayed when the user clicks on an environment. |
241 | + From here it is also possible to switch to the edit view in order to create |
242 | + a new environment. |
243 | |
244 | Receives: |
245 | - env_type_db: the environments meta information; |
246 | @@ -145,6 +148,8 @@ |
247 | app.set_return_value_on_exit((env_db, None)) |
248 | detail_view = functools.partial( |
249 | env_detail, app, env_type_db, env_db, save_callable) |
250 | + edit_view = functools.partial( |
251 | + env_edit, app, env_type_db, env_db, save_callable) |
252 | # Alphabetically sort the existing environments. |
253 | environments = sorted([ |
254 | envs.get_env_data(env_db, env_name) |
255 | @@ -153,7 +158,6 @@ |
256 | if environments: |
257 | title = 'Select the Juju environment you want to use' |
258 | else: |
259 | - # XXX frankban 2013-12-18: implement the env creation view. |
260 | title = 'No Juju environments already set up: please create one' |
261 | app.set_title(title) |
262 | app.set_status('') |
263 | @@ -179,9 +183,13 @@ |
264 | text = [bullet, ' {}'.format(env_short_description)] |
265 | widgets.append(ui.MenuButton(text, ui.thunk(detail_view, env_data))) |
266 | widgets.append(urwid.Divider()) |
267 | - contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) |
268 | - if focus_position is not None: |
269 | - contents.set_focus(focus_position) |
270 | + # Add the buttons used to create new environments. |
271 | + widgets.extend([ |
272 | + ui.MenuButton( |
273 | + '\N{BULLET} create new {} environment'.format(env_type), |
274 | + ui.thunk(edit_view, {'type': env_type})) |
275 | + for env_type in envs.get_supported_env_types(env_type_db) |
276 | + ]) |
277 | # Set up the application status messages. |
278 | status = [] |
279 | if default_found: |
280 | @@ -190,6 +198,10 @@ |
281 | status.extend([('error status', ' \N{BULLET}'), ' has errors ']) |
282 | if status: |
283 | app.set_status(status) |
284 | + # Set up the application contents. |
285 | + contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) |
286 | + if focus_position is not None: |
287 | + contents.set_focus(focus_position) |
288 | app.set_contents(contents) |
289 | |
290 | |
291 | @@ -212,6 +224,8 @@ |
292 | app.set_return_value_on_exit((env_db, None)) |
293 | index_view = functools.partial( |
294 | env_index, app, env_type_db, env_db, save_callable) |
295 | + edit_view = functools.partial( |
296 | + env_edit, app, env_type_db, env_db, save_callable, env_data) |
297 | |
298 | def use(env_data): |
299 | # Quit the interactive session returning the (possibly modified) |
300 | @@ -231,8 +245,10 @@ |
301 | def remove(env_data): |
302 | # The environment deletion is confirmed: remove the environment from |
303 | # the database, save the new env_db and return to the index view. |
304 | - envs.remove_env(env_db, env_data['name']) |
305 | + env_name = env_data['name'] |
306 | + envs.remove_env(env_db, env_name) |
307 | save_callable(env_db) |
308 | + app.set_message('{} successfully removed'.format(env_name)) |
309 | index_view() |
310 | |
311 | def confirm_removal(env_data): |
312 | @@ -272,10 +288,111 @@ |
313 | controls.append( |
314 | ui.MenuButton('set default', ui.thunk(set_default, env_data))) |
315 | controls.extend([ |
316 | - # XXX frankban 2013-12-18: implement the env modification view. |
317 | + ui.MenuButton('edit', ui.thunk(edit_view)), |
318 | ui.MenuButton( |
319 | ('control alert', 'remove'), ui.thunk(confirm_removal, env_data)), |
320 | ]) |
321 | widgets.append(ui.create_controls(*controls)) |
322 | listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) |
323 | app.set_contents(listbox) |
324 | + |
325 | + |
326 | +def env_edit(app, env_type_db, env_db, save_callable, env_data): |
327 | + """Create or modify a Juju environment. |
328 | + |
329 | + This view displays an edit form allowing for environment |
330 | + creation/modification. Saving the form redirects to the environment detail |
331 | + view if the values are valid. |
332 | + |
333 | + Receives: |
334 | + - env_type_db: the environments meta information; |
335 | + - env_db: the environments database; |
336 | + - save_callable: a function called to save a new environment database. |
337 | + - env_data: the environment data. |
338 | + |
339 | + The last value (env_data) indicates whether this view is used to create a |
340 | + new environment or to change an existing one. In the former case, env_data |
341 | + only includes the "type" key. If instead the environment already exists, |
342 | + env_data includes the "name" key and all the other environment info. |
343 | + """ |
344 | + env_db = copy.deepcopy(env_db) |
345 | + # All the environment views return a tuple (new_env_db, env_data). |
346 | + # Set the env_data to None in the case the user quits the application |
347 | + # without selecting an environment to use. |
348 | + app.set_return_value_on_exit((env_db, None)) |
349 | + env_metadata = envs.get_env_metadata(env_type_db, env_data) |
350 | + index_view = functools.partial( |
351 | + env_index, app, env_type_db, env_db, save_callable) |
352 | + detail_view = functools.partial( |
353 | + env_detail, app, env_type_db, env_db, save_callable) |
354 | + if 'name' in env_data: |
355 | + exists = True |
356 | + title = 'Edit the {} environment' |
357 | + # Retrieve all the errors for the existing environment. |
358 | + initial_errors = envs.validate(env_metadata, env_data) |
359 | + else: |
360 | + exists = False |
361 | + title = 'Create a new {} environment' |
362 | + # The environment does not exist: avoid bothering the user with errors |
363 | + # before the form is submitted. |
364 | + initial_errors = {} |
365 | + app.set_title(title.format(env_data['type'])) |
366 | + app.set_status([ |
367 | + ('optional status', ' \N{LOWER SEVEN EIGHTHS BLOCK}'), |
368 | + ' optional field ', |
369 | + ('error status', ' \N{BULLET}'), |
370 | + ' field errors ', |
371 | + ]) |
372 | + |
373 | + def save(env_data, get_new_env_data): |
374 | + # Create a new environment or save changes for an existing one. |
375 | + # The new values are saved only if the new env_data is valid, in which |
376 | + # case also redirect to the environment detail view. |
377 | + new_env_data = get_new_env_data() |
378 | + # Validate the new env_data. |
379 | + errors = envs.validate(env_metadata, new_env_data) |
380 | + new_name = new_env_data['name'] |
381 | + initial_name = env_data.get('name') |
382 | + if (new_name != initial_name) and new_name in env_db['environments']: |
383 | + errors['name'] = 'an environment with this name already exists' |
384 | + # If errors are found, re-render the form passing the errors. This way |
385 | + # the errors are displayed as part of the form and the user is given |
386 | + # the opportunity to fix the invalid values. |
387 | + if errors: |
388 | + return render_form(new_env_data, errors) |
389 | + # Without errors, normalize the new values, update the env_db and save |
390 | + # the resulting environments database. |
391 | + env_data = envs.normalize(env_metadata, new_env_data) |
392 | + envs.set_env_data(env_db, initial_name, env_data) |
393 | + save_callable(env_db) |
394 | + verb = 'modified' if exists else 'created' |
395 | + app.set_message('{} successfully {}'.format(new_name, verb)) |
396 | + return detail_view(env_data) |
397 | + |
398 | + def cancel(env_data): |
399 | + # Dismiss any changes and return to the index or detail view. |
400 | + return detail_view(env_data) if exists else index_view() |
401 | + |
402 | + def render_form(data, errors): |
403 | + widgets = [ |
404 | + urwid.Text(env_metadata['description']), |
405 | + urwid.Divider(), |
406 | + ] |
407 | + field_value_pairs = envs.map_fields_to_env_data(env_metadata, data) |
408 | + # Retrieve the form widgets and the data getter function. The latter |
409 | + # can be used to retrieve the field name/value pairs included in the |
410 | + # displayed form, including user's changes (see quickstart.cli.forms). |
411 | + form_widgets, get_new_env_data = forms.create_form( |
412 | + field_value_pairs, errors) |
413 | + widgets.extend(form_widgets) |
414 | + actions = ( |
415 | + ('save', ui.thunk(save, env_data, get_new_env_data)), |
416 | + ('cancel', ui.thunk(cancel, env_data)), |
417 | + ('restore', ui.thunk(render_form, env_data, initial_errors)), |
418 | + ) |
419 | + widgets.append(forms.create_actions(actions)) |
420 | + contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) |
421 | + app.set_contents(contents) |
422 | + |
423 | + # Render the initial form. |
424 | + render_form(env_data, initial_errors) |
425 | |
426 | === modified file 'quickstart/models/envs.py' |
427 | --- quickstart/models/envs.py 2013-12-19 16:48:38 +0000 |
428 | +++ quickstart/models/envs.py 2013-12-24 19:19:29 +0000 |
429 | @@ -292,7 +292,7 @@ |
430 | environments[new_name] = new_env_data |
431 | if is_default: |
432 | env_db['default'] = new_name |
433 | - elif env_db.get('default') == env_name: |
434 | + elif (env_db.get('default') == env_name) and (env_name is not None): |
435 | del env_db['default'] |
436 | |
437 | |
438 | |
439 | === added file 'quickstart/tests/cli/test_forms.py' |
440 | --- quickstart/tests/cli/test_forms.py 1970-01-01 00:00:00 +0000 |
441 | +++ quickstart/tests/cli/test_forms.py 2013-12-24 19:19:29 +0000 |
442 | @@ -0,0 +1,148 @@ |
443 | +# This file is part of the Juju Quickstart Plugin, which lets users set up a |
444 | +# Juju environment in very few steps (https://launchpad.net/juju-quickstart). |
445 | +# Copyright (C) 2013 Canonical Ltd. |
446 | +# |
447 | +# This program is free software: you can redistribute it and/or modify it under |
448 | +# the terms of the GNU Affero General Public License version 3, as published by |
449 | +# the Free Software Foundation. |
450 | +# |
451 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
452 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
453 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
454 | +# Affero General Public License for more details. |
455 | +# |
456 | +# You should have received a copy of the GNU Affero General Public License |
457 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
458 | + |
459 | +"""Tests for the Juju Quickstart CLI forms management.""" |
460 | + |
461 | +from __future__ import unicode_literals |
462 | + |
463 | +import unittest |
464 | + |
465 | +import mock |
466 | +import urwid |
467 | + |
468 | +from quickstart.cli import forms |
469 | +from quickstart.models import fields |
470 | + |
471 | + |
472 | +class TestGenerate(unittest.TestCase): |
473 | + |
474 | + def test_generation(self): |
475 | + # The text of the given widget is set to the return value of the given |
476 | + # callable. |
477 | + generate_callable = lambda: 'generated' |
478 | + mock_edit_widget = mock.Mock() |
479 | + forms._generate(generate_callable, mock_edit_widget) |
480 | + mock_edit_widget.set_edit_text.assert_called_once_with('generated') |
481 | + |
482 | + |
483 | +class TestCreateStringWidget(unittest.TestCase): |
484 | + |
485 | + def inspect_widget(self, widget): |
486 | + """Return a list of sub-widgets composing the given string widget. |
487 | + |
488 | + The sub-widgets are: |
489 | + - the wrapper edit widget; |
490 | + - the base edit widget; |
491 | + - the caption text widget; |
492 | + - the help widget; |
493 | + - the error text widget (or None if not present); |
494 | + - the generate text widget (or None if not present); |
495 | + - the choices text widget (or None if not present). |
496 | + """ |
497 | + help = error = generate = choices = None |
498 | + # Retrieve the Pile contents ignoring the last Divider widget. |
499 | + contents = list(widget.contents)[:-1] |
500 | + first_widget = contents.pop(0)[0] |
501 | + if isinstance(first_widget, urwid.Text): |
502 | + # This is the error message. |
503 | + error = first_widget |
504 | + wrapper = contents.pop(0)[0] |
505 | + else: |
506 | + # The widget has no errors. |
507 | + wrapper = first_widget |
508 | + caption_attrs, edit_attrs = wrapper.base_widget.contents |
509 | + if contents: |
510 | + help = contents.pop(0)[0] |
511 | + if contents: |
512 | + if isinstance(contents[0][0], urwid.Text): |
513 | + choices = contents.pop(0)[0] |
514 | + else: |
515 | + generate = contents.pop(0)[0] |
516 | + if contents: |
517 | + choices = contents.pop(0)[0] |
518 | + if 'default if not set' in choices.get_text()[0]: |
519 | + choices = None |
520 | + return ( |
521 | + wrapper, edit_attrs[0], caption_attrs[0], |
522 | + help, error, generate, choices, |
523 | + ) |
524 | + |
525 | + def test_widget_structure(self): |
526 | + # The widget includes all the information about a field. |
527 | + field = fields.StringField( |
528 | + 'first-name', label='first name', help='your first name', |
529 | + default='Jean') |
530 | + widget, value_getter = forms.create_string_widget( |
531 | + field, 'Luc', 'invalid name') |
532 | + sub_widgets = self.inspect_widget(widget) |
533 | + wrapper, edit, caption, help, error, generate, choices = sub_widgets |
534 | + # Since the field is not read-only, the widget is properly enabled. |
535 | + self.assertNotIsInstance(wrapper, urwid.WidgetDisable) |
536 | + # The edit widget is set to the given value. |
537 | + self.assertEqual('Luc', edit.get_edit_text()) |
538 | + # The caption and help are properly set. |
539 | + self.assertEqual('\N{BULLET} first name: ', caption.text) |
540 | + self.assertEqual('your first name', help.text) |
541 | + # The error is displayed. |
542 | + self.assertEqual('invalid name', error.text) |
543 | + # The field is not able to generate a value, and there are no choices. |
544 | + self.assertIsNone(generate) |
545 | + self.assertIsNone(choices) |
546 | + |
547 | + def test_empty_value(self): |
548 | + # The widget includes all the information about a field. |
549 | + field = fields.StringField( |
550 | + 'first-name', label='first name', help='your first name', |
551 | + default='Jean') |
552 | + widget, value_getter = forms.create_string_widget( |
553 | + field, None, 'empty name') |
554 | + sub_widgets = self.inspect_widget(widget) |
555 | + error = sub_widgets[4] |
556 | + self.assertEqual('empty name', error.text) |
557 | + |
558 | + def test_nonunicode_value(self): |
559 | + # The widget includes all the information about a field. |
560 | + field = fields.StringField( |
561 | + 'first-name', label='first name', help='your first name', |
562 | + default='Jean') |
563 | + widget, value_getter = forms.create_string_widget( |
564 | + field, 42, 'non-unicode name') |
565 | + edit = self.inspect_widget(widget)[1] |
566 | + self.assertIsInstance(edit.get_edit_text(), unicode) |
567 | + |
568 | + def test_readonly(self): |
569 | + # The widget includes all the information about a field. |
570 | + field = fields.StringField( |
571 | + 'first-name', label='first name', help='your first name', |
572 | + default='Jean', readonly=True) |
573 | + widget, value_getter = forms.create_string_widget( |
574 | + field, 42, 'disabled') |
575 | + disable = self.inspect_widget(widget)[0] |
576 | + self.assertIsInstance(disable, urwid.WidgetDisable) |
577 | + |
578 | + def test_generate_method(self): |
579 | + # The widget includes all the information about a field. |
580 | + field = fields.AutoGeneratedStringField( |
581 | + 'first-name', label='first name', help='your first name', |
582 | + default='Jean') |
583 | + widget, value_getter = forms.create_string_widget( |
584 | + field, 42, 'disabled') |
585 | + generate = self.inspect_widget(widget)[5] |
586 | + # Check that an autogenerate button is included. |
587 | + self.assertIsInstance(generate.contents[1][0], urwid.Button) |
588 | + |
589 | + def test_choices(self): |
590 | + pass |
591 | |
592 | === modified file 'quickstart/tests/cli/test_views.py' |
593 | --- quickstart/tests/cli/test_views.py 2013-12-20 10:30:37 +0000 |
594 | +++ quickstart/tests/cli/test_views.py 2013-12-24 19:19:29 +0000 |
595 | @@ -149,17 +149,28 @@ |
596 | 'No Juju environments already set up: please create one', |
597 | self.app.get_title()) |
598 | |
599 | + def test_view_contents(self): |
600 | + # The view displays a list of the environments in env_db, and buttons |
601 | + # to create new environments. |
602 | + env_db = helpers.make_env_db() |
603 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
604 | + buttons = self.get_widgets_in_contents( |
605 | + filter_function=self.is_a(ui.MenuButton)) |
606 | + # A button is created for each existing environment (see details) and |
607 | + # for each environment type supported by quickstart (create). |
608 | + env_types = envs.get_supported_env_types(self.env_type_db) |
609 | + expected_buttons_number = len(env_db['environments']) + len(env_types) |
610 | + self.assertEqual(expected_buttons_number, len(buttons)) |
611 | + |
612 | @mock.patch('quickstart.cli.views.env_detail') |
613 | - def test_view_contents(self, mock_env_detail): |
614 | - # The view displays a list of the environments in env_db. |
615 | + def test_environment_clicked(self, mock_env_detail): |
616 | + # The environment detail view is called when clicking an environment. |
617 | env_db = helpers.make_env_db() |
618 | views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
619 | buttons = self.get_widgets_in_contents( |
620 | filter_function=self.is_a(ui.MenuButton)) |
621 | # The environments are listed in alphabetical order. |
622 | environments = sorted(env_db['environments']) |
623 | - # A button is created for each environment. |
624 | - self.assertEqual(len(environments), len(buttons)) |
625 | for env_name, button in zip(environments, buttons): |
626 | env_data = envs.get_env_data(env_db, env_name) |
627 | # The caption includes the environment description. |
628 | @@ -176,6 +187,30 @@ |
629 | # loop cycle. |
630 | mock_env_detail.reset_mock() |
631 | |
632 | + @mock.patch('quickstart.cli.views.env_edit') |
633 | + def test_create_new_environment_clicked(self, mock_env_edit): |
634 | + # The environment edit view is called when clicking to create a new |
635 | + # environment. |
636 | + env_db = helpers.make_env_db() |
637 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
638 | + buttons = self.get_widgets_in_contents( |
639 | + filter_function=self.is_a(ui.MenuButton)) |
640 | + env_types = envs.get_supported_env_types(self.env_type_db) |
641 | + for env_type, button in zip(env_types, buttons[-len(env_types):]): |
642 | + # The caption includes the environment type. |
643 | + expected_caption = 'create new {} environment'.format(env_type) |
644 | + caption = cli_helpers.get_button_caption(button) |
645 | + self.assertIn(expected_caption, caption) |
646 | + # When the button is clicked, the edit view is called passing the |
647 | + # corresponding environment data. |
648 | + cli_helpers.emit(button) |
649 | + mock_env_edit.assert_called_once_with( |
650 | + self.app, self.env_type_db, env_db, self.save_callable, |
651 | + {'type': env_type}) |
652 | + # Reset the mock so that it does not include any calls on the next |
653 | + # loop cycle. |
654 | + mock_env_edit.reset_mock() |
655 | + |
656 | def test_selected_environment(self): |
657 | # The default environment is already selected in the list. |
658 | env_db = helpers.make_env_db(default='lxc') |
659 | @@ -261,28 +296,30 @@ |
660 | self.assertEqual(expected_text, widget.text) |
661 | |
662 | def test_view_buttons(self): |
663 | - # The following buttons are displayed: "back", "use", "set default" and |
664 | - # "remove". |
665 | + # The following buttons are displayed: "back", "use", "set default", |
666 | + # "edit" and "remove". |
667 | self.call_view(env_name='ec2-west') |
668 | buttons = self.get_control_buttons() |
669 | captions = map(cli_helpers.get_button_caption, buttons) |
670 | - self.assertEqual(['back', 'use', 'set default', 'remove'], captions) |
671 | + self.assertEqual( |
672 | + ['back', 'use', 'set default', 'edit', 'remove'], captions) |
673 | |
674 | def test_view_buttons_default(self): |
675 | # If the environment is the default one, the "set default" button is |
676 | - # not displayed. The buttons we expect are "back", "use" and "remove". |
677 | + # not displayed. The buttons we expect are "back", "use", "edit" and |
678 | + # "remove". |
679 | self.call_view(env_name='lxc') |
680 | buttons = self.get_control_buttons() |
681 | captions = map(cli_helpers.get_button_caption, buttons) |
682 | - self.assertEqual(['back', 'use', 'remove'], captions) |
683 | + self.assertEqual(['back', 'use', 'edit', 'remove'], captions) |
684 | |
685 | def test_view_buttons_error(self): |
686 | # If the environment is not valid, the "use" button is not displayed. |
687 | - # The buttons we expect are "back", "set default" and "remove". |
688 | + # The buttons we expect are "back", "set default", "edit" and "remove". |
689 | self.call_view(env_name='local-with-errors') |
690 | buttons = self.get_control_buttons() |
691 | captions = map(cli_helpers.get_button_caption, buttons) |
692 | - self.assertEqual(['back', 'set default', 'remove'], captions) |
693 | + self.assertEqual(['back', 'set default', 'edit', 'remove'], captions) |
694 | |
695 | @mock.patch('quickstart.cli.views.env_index') |
696 | def test_back_button(self, mock_env_index): |
697 | @@ -323,6 +360,17 @@ |
698 | # The new env_db has been saved. |
699 | self.save_callable.assert_called_once_with(new_env_db) |
700 | |
701 | + @mock.patch('quickstart.cli.views.env_edit') |
702 | + def test_edit_button(self, mock_env_edit): |
703 | + # The edit view is called if the "edit" button is clicked. |
704 | + self.call_view(env_name='ec2-west') |
705 | + # The "edit" button is the fourth one. |
706 | + edit_button = self.get_control_buttons()[3] |
707 | + cli_helpers.emit(edit_button) |
708 | + mock_env_edit.assert_called_once_with( |
709 | + self.app, self.env_type_db, self.env_db, self.save_callable, |
710 | + self.env_data) |
711 | + |
712 | def test_remove_button(self): |
713 | # A confirmation dialog is displayed if the "remove" button is clicked. |
714 | self.call_view(env_name='ec2-west') |
715 | |
716 | === modified file 'quickstart/tests/models/test_envs.py' |
717 | --- quickstart/tests/models/test_envs.py 2013-12-19 17:00:36 +0000 |
718 | +++ quickstart/tests/models/test_envs.py 2013-12-24 19:19:29 +0000 |
719 | @@ -386,6 +386,20 @@ |
720 | self.env_db['environments']['new-one']) |
721 | self.assertEqual('new-one', self.env_db['default']) |
722 | |
723 | + def test_new_environment_with_no_default(self): |
724 | + # A new environment is properly added in an env_db with no default. |
725 | + env_data = { |
726 | + 'default-series': 'edgy', |
727 | + 'is-default': False, |
728 | + 'name': 'new-one', |
729 | + } |
730 | + del self.env_db['default'] |
731 | + envs.set_env_data(self.env_db, None, env_data) |
732 | + self.assertEqual( |
733 | + {'default-series': 'edgy'}, |
734 | + self.env_db['environments']['new-one']) |
735 | + self.assertNotIn('default', self.env_db) |
736 | + |
737 | def test_existing_environment_updated(self): |
738 | # An existing environment is properly updated. |
739 | env_data = { |