@@ -0,0 +1,737 @@
1+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2+#
3+# Unity Autopilot Test Suite
4+# Copyright (C) 2012-2013 Canonical
5+#
6+# This program is free software: you can redistribute it and/or modify
7+# it under the terms of the GNU General Public License as published by
8+# the Free Software Foundation, either version 3 of the License, or
9+# (at your option) any later version.
10+#
11+# This program is distributed in the hope that it will be useful,
12+# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+# GNU General Public License for more details.
15+#
16+# You should have received a copy of the GNU General Public License
17+# along with this program. If not, see <http://www.gnu.org/licenses/>.
18+#
19+
20+"""Tests for Notifications"""
21+
22+from __future__ import absolute_import
23+
24+from unity8.shell.tests import UnityTestCase, _get_device_emulation_scenarios
25+
26+from testtools.matchers import Equals, NotEquals
27+from autopilot.matchers import Eventually
28+
29+from gi.repository import Notify
30+import time
31+import os
32+import logging
33+import signal
34+import subprocess
35+
36+logger = logging.getLogger(__name__)
37+
38+
39+class NotificationsBase(UnityTestCase):
40+ """Base class for all notification tests that provides helper methods."""
41+
42+ scenarios = _get_device_emulation_scenarios('Nexus4')
43+
44+ def _get_icon_path(self, icon_name):
45+ """Given an icons file name returns the full path (either system or
46+ source tree.
47+
48+ Consider the graphics directory as root so for example (runnign tests
49+ from installed unity8-autopilot package):
50+ >>> self.get_icon_path('clock.png')
51+ /usr/share/unity8/graphics/clock.png
52+
53+ >>> self.get_icon_path('applicationIcons/facebook.png')
54+ /usr/share/unity8/graphics/applicationIcons/facebook.png
55+
56+ """
57+ if os.path.abspath(__file__).startswith('/usr/'):
58+ return '/usr/share/unity8/graphics/' + icon_name
59+ else:
60+ return os.path.abspath(
61+ os.getcwd() + "/../../graphics/" + icon_name
62+ )
63+
64+ def _get_notifications_list(self):
65+ main_view = self.main_window.get_qml_view()
66+ return main_view.select_single(
67+ "QQuickListView",
68+ objectName='notificationList'
69+ )
70+
71+ def _assert_notification(
72+ self,
73+ notification,
74+ summary=None,
75+ body=None,
76+ icon=True,
77+ secondary_icon=False,
78+ opacity=None
79+ ):
80+ """Assert that the expected qualities of a notification are as
81+ expected.
82+
83+ """
84+
85+ if summary is not None:
86+ self.assertThat(notification.summary, Eventually(Equals(summary)))
87+
88+ if body is not None:
89+ self.assertThat(notification.body, Eventually(Equals(body)))
90+
91+ if icon:
92+ self.assertThat(notification.iconSource, Eventually(NotEquals("")))
93+ else:
94+ self.assertThat(notification.iconSource, Eventually(Equals("")))
95+
96+ if secondary_icon:
97+ self.assertThat(
98+ notification.secondaryIconSource,
99+ Eventually(NotEquals(""))
100+ )
101+ else:
102+ self.assertThat(
103+ notification.secondaryIconSource,
104+ Eventually(Equals(""))
105+ )
106+
107+ if opacity is not None:
108+ self.assertThat(notification.opacity, Eventually(Equals(opacity)))
109+
110+
111+class InteractiveNotificationBase(NotificationsBase):
112+ """Collection of test for Interactive tests including snap decisions."""
113+
114+ def setUp(self):
115+ super(InteractiveNotificationBase, self).setUp()
116+ # Need to keep track when we launch the notification script.
117+ self._notify_proc = None
118+
119+ def test_interactive(self):
120+ """Interactive notification must react upon click on itself."""
121+ self.launch_unity()
122+ greeter = self.main_window.get_greeter()
123+ greeter.swipe()
124+
125+ notify_list = self._get_notifications_list()
126+
127+ summary = "Interactive notification"
128+ body = "This notification can be clicked on to trigger an action."
129+ icon_path = self._get_icon_path('avatars/anna_olsson.png')
130+ actions = [("action_id", "dummy")]
131+ hints = [
132+ ("x-canonical-switch-to-application", "true"),
133+ (
134+ "x-canonical-secondary-icon",
135+ self._get_icon_path('applicationIcons/phone-app.png')
136+ )
137+ ]
138+
139+ self._create_interactive_notification(
140+ summary,
141+ body,
142+ icon_path,
143+ "NORMAL",
144+ actions,
145+ hints,
146+ )
147+
148+ get_notification = lambda: notify_list.select_single('Notification')
149+ self.assertThat(get_notification, Eventually(NotEquals(None)))
150+ notification = get_notification()
151+
152+ self.touch.tap_object(
153+ notification.select_single(objectName="interactiveArea")
154+ )
155+
156+ self.assert_notification_action_id_was_called('action_id')
157+
158+ def test_sd_incoming_call(self):
159+ """Rejecting a call should make notification expand and offer more options."""
160+ self.launch_unity()
161+ greeter = self.main_window.get_greeter()
162+ greeter.swipe()
163+
164+ notify_list = self._get_notifications_list()
165+
166+ summary = "Incoming call"
167+ body = "Frank Zappa\n+44 (0)7736 027340"
168+ icon_path = self._get_icon_path('avatars/anna_olsson.png')
169+ hints = [
170+ (
171+ "x-canonical-secondary-icon",
172+ self._get_icon_path('applicationIcons/phone-app.png')
173+ ),
174+ ("x-canonical-snap-decisions", "true"),
175+ ]
176+
177+ actions = [
178+ ('action_accept', 'Accept'),
179+ ('action_decline_1', 'Decline'),
180+ ('action_decline_2', '"Can\'t talk now, what\'s up?"'),
181+ ('action_decline_3', '"I call you back."'),
182+ ('action_decline_4', 'Send custom message...'),
183+ ]
184+
185+ self._create_interactive_notification(
186+ summary,
187+ body,
188+ icon_path,
189+ "NORMAL",
190+ actions,
191+ hints
192+ )
193+
194+ get_notification = lambda: notify_list.select_single('Notification')
195+ self.assertThat(get_notification, Eventually(NotEquals(None)))
196+ notification = get_notification()
197+ self._assert_notification(notification, None, None, True, True, 1.0)
198+ initial_height = notification.height
199+ self.touch.tap_object(notification.select_single(objectName="button1"))
200+ self.assertThat(
201+ notification.height,
202+ Eventually(Equals(initial_height +
203+ 3 * notification.select_single(objectName="buttonColumn").spacing +
204+ 3 * notification.select_single(objectName="button4").height)))
205+ self.touch.tap_object(notification.select_single(objectName="button4"))
206+ self.assert_notification_action_id_was_called("action_decline_4")
207+
208+ def _create_interactive_notification(
209+ self,
210+ summary="",
211+ body="",
212+ icon=None,
213+ urgency="NORMAL",
214+ actions=[],
215+ hints=[]
216+ ):
217+ """Create a interactive notification command.
218+
219+ :param summary: Summary text for the notification
220+ :param body: Body text to display in the notification
221+ :param icon: Path string to the icon to use
222+ :param urgency: Urgency string for the noticiation, either: 'LOW',
223+ 'NORMAL', 'CRITICAL'
224+ :param actions: List of tuples containing the 'id' and 'label' for all
225+ the actions to add
226+ :param hint_strings: List of tuples containing the 'name' and value for
227+ setting the hint strings for the notification
228+
229+ """
230+
231+ logger.info(
232+ "Creating snap-decision notification with summary(%s), body(%s) "
233+ "and urgency(%r)",
234+ summary,
235+ body,
236+ urgency
237+ )
238+
239+ script_args = [
240+ '--summary', summary,
241+ '--body', body,
242+ '--urgency', urgency
243+ ]
244+
245+ if icon is not None:
246+ script_args.extend(['--icon', icon])
247+
248+ for hint in hints:
249+ key, value = hint
250+ script_args.extend(['--hint', "%s,%s" % (key, value)])
251+
252+ for action in actions:
253+ action_id, action_label = action
254+ action_string = "%s,%s" % (action_id, action_label)
255+ script_args.extend(['--action', action_string])
256+
257+ python_bin = subprocess.check_output(['which', 'python']).strip()
258+ command = [python_bin, self._get_notify_script()] + script_args
259+ logger.info("Launching snap-decision notification as: %s", command)
260+ self._notify_proc = subprocess.Popen(
261+ command,
262+ stdin=subprocess.PIPE,
263+ stdout=subprocess.PIPE,
264+ stderr=subprocess.PIPE,
265+ close_fds=True,
266+ )
267+
268+ self.addCleanup(self._tidy_up_script_process)
269+
270+ poll_result = self._notify_proc.poll()
271+ if poll_result is not None and self._notify_proc.returncode != 0:
272+ error_output = self._notify_proc.communicate()[1]
273+ raise RuntimeError("Call to script failed with: %s" % error_output)
274+
275+ def _get_notify_script(self):
276+ """Returns the path to the interactive notification creation script."""
277+ file_path = "../../emulators/create_interactive_notification.py"
278+
279+ the_path = os.path.abspath(
280+ os.path.join(__file__, file_path))
281+
282+ return the_path
283+
284+ def _tidy_up_script_process(self):
285+ if self._notify_proc is not None and self._notify_proc.poll() is None:
286+ logger.error("Notification process wasn't killed, killing now.")
287+ os.killpg(self._notify_proc.pid, signal.SIGTERM)
288+
289+ def assert_notification_action_id_was_called(self, action_id, timeout=10):
290+ """Assert that the interactive notification callback of id *action_id*
291+ was called.
292+
293+ :raises AssertionError: If no interactive notification has actually
294+ been created.
295+ :raises AssertionError: When *action_id* does not match the actual
296+ returned.
297+ :raises AssertionError: If no callback was called at all.
298+ """
299+
300+ if self._notify_proc is None:
301+ raise AssertionError("No interactive notification was created.")
302+
303+ for i in xrange(timeout):
304+ self._notify_proc.poll()
305+ if self._notify_proc.returncode is not None:
306+ output = self._notify_proc.communicate()
307+ actual_action_id = output[0].strip("\n")
308+ if actual_action_id != action_id:
309+ raise AssertionError(
310+ "action id '%s' does not match actual returned '%s'"
311+ % (action_id, actual_action_id)
312+ )
313+ else:
314+ return
315+ time.sleep(1)
316+
317+ os.killpg(self._notify_proc.pid, signal.SIGTERM)
318+ self._notify_proc = None
319+ raise AssertionError(
320+ "No callback was called, killing interactivenotification script"
321+ )
322+
323+
324+class EphemeralNotificationsTests(NotificationsBase):
325+ """Collection of tests for Emphemeral notifications (non-interactive.)"""
326+
327+ def setUp(self):
328+ super(EphemeralNotificationsTests, self).setUp()
329+ # Because we are using the Notify library we need to init and un-init
330+ # otherwise we get crashes.
331+ Notify.init("Autopilot Ephemeral Notification Tests")
332+ self.addCleanup(Notify.uninit)
333+
334+ def test_icon_summary_body(self):
335+ """Notification must display the expected summary and body text."""
336+ self.launch_unity()
337+ greeter = self.main_window.get_greeter()
338+ greeter.swipe()
339+
340+ notify_list = self._get_notifications_list()
341+
342+ summary = "Icon-Summary-Body"
343+ body = "Hey pal, what's up with the party next weekend? Will you " \
344+ "join me and Anna?"
345+ icon_path = self._get_icon_path('avatars/anna_olsson.png')
346+ hints = [
347+ (
348+ "x-canonical-secondary-icon",
349+ self._get_icon_path('applicationIcons/phone-app.png')
350+ )
351+ ]
352+
353+ notification = self._create_ephemeral_notification(
354+ summary,
355+ body,
356+ icon_path,
357+ hints,
358+ "NORMAL",
359+ )
360+
361+ notification.show()
362+
363+ get_notification = lambda: notify_list.select_single('Notification')
364+ self.assertThat(get_notification, Eventually(NotEquals(None)))
365+ notification = get_notification()
366+ self._assert_notification(notification, summary, body, True, True, 1.0)
367+
368+ def test_icon_summary(self):
369+ """Notification must display the expected summary and secondary icon."""
370+ self.launch_unity()
371+ greeter = self.main_window.get_greeter()
372+ greeter.swipe()
373+
374+ notify_list = self._get_notifications_list()
375+
376+ summary = "Upload of image completed"
377+ hints = [
378+ (
379+ "x-canonical-secondary-icon",
380+ self._get_icon_path('applicationIcons/facebook.png')
381+ )
382+ ]
383+
384+ notification = self._create_ephemeral_notification(
385+ summary,
386+ None,
387+ None,
388+ hints,
389+ "NORMAL",
390+ )
391+
392+ notification.show()
393+
394+ get_notification = lambda: notify_list.select_single('Notification')
395+ self.assertThat(get_notification, Eventually(NotEquals(None)))
396+ notification = get_notification()
397+ self._assert_notification(
398+ notification,
399+ summary,
400+ None,
401+ False,
402+ True,
403+ 1.0
404+ )
405+
406+ def test_urgency_order(self):
407+ """Notifications must be displayed in order according to their urgency."""
408+ self.launch_unity()
409+ greeter = self.main_window.get_greeter()
410+ greeter.swipe()
411+
412+ notify_list = self._get_notifications_list()
413+
414+ summary_low = 'Low Urgency'
415+ body_low = "No, I'd rather see paint dry, pal *yawn*"
416+ icon_path_low = self._get_icon_path('avatars/amanda.png')
417+
418+ summary_normal = 'Normal Urgency'
419+ body_normal = "Hey pal, what's up with the party next weekend? Will " \
420+ "you join me and Anna?"
421+ icon_path_normal = self._get_icon_path('avatars/funky.png')
422+
423+ summary_critical = 'Critical Urgency'
424+ body_critical = 'Dude, this is so urgent you have no idea :)'
425+ icon_path_critical = self._get_icon_path('avatars/anna_olsson.png')
426+
427+ notification_normal = self._create_ephemeral_notification(
428+ summary_normal,
429+ body_normal,
430+ icon_path_normal,
431+ urgency="NORMAL"
432+ )
433+ notification_normal.show()
434+
435+ notification_low = self._create_ephemeral_notification(
436+ summary_low,
437+ body_low,
438+ icon_path_low,
439+ urgency="LOW"
440+ )
441+ notification_low.show()
442+
443+ notification_critical = self._create_ephemeral_notification(
444+ summary_critical,
445+ body_critical,
446+ icon_path_critical,
447+ urgency="CRITICAL"
448+ )
449+ notification_critical.show()
450+
451+ get_notification = lambda: notify_list.select_single(
452+ 'Notification',
453+ summary=summary_critical
454+ )
455+ self.assertThat(get_notification, Eventually(NotEquals(None)))
456+
457+ notification = get_notification()
458+ self._assert_notification(
459+ notification,
460+ summary_critical,
461+ body_critical,
462+ True,
463+ False,
464+ 1.0
465+ )
466+
467+ get_normal_notification = lambda: notify_list.select_single(
468+ 'Notification',
469+ summary=summary_normal
470+ )
471+ self.assertThat(get_normal_notification, Eventually(NotEquals(None)))
472+ notification = get_normal_notification()
473+ self._assert_notification(
474+ notification,
475+ summary_normal,
476+ body_normal,
477+ True,
478+ False,
479+ 1.0
480+ )
481+
482+ get_low_notification = lambda: notify_list.select_single(
483+ 'Notification',
484+ summary=summary_low
485+ )
486+ self.assertThat(get_low_notification, Eventually(NotEquals(None)))
487+ notification = get_low_notification()
488+ self._assert_notification(
489+ notification,
490+ summary_low,
491+ body_low,
492+ True,
493+ False,
494+ 1.0
495+ )
496+
497+ def test_summary_and_body(self):
498+ """Notification must display the expected summary- and body-text."""
499+ self.launch_unity()
500+ greeter = self.main_window.get_greeter()
501+ greeter.swipe()
502+
503+ notify_list = self._get_notifications_list()
504+
505+ summary = 'Summary-Body'
506+ body = 'This is a superfluous notification'
507+
508+ notification = self._create_ephemeral_notification(summary, body)
509+ notification.show()
510+
511+ get_notification = lambda: notify_list.select_single('Notification')
512+ self.assertThat(get_notification, Eventually(NotEquals(None)))
513+ notification = get_notification()
514+ self._assert_notification(
515+ notification,
516+ summary,
517+ body,
518+ False,
519+ False,
520+ 1.0
521+ )
522+
523+ def test_summary_only(self):
524+ """Notification must display only the expected summary-text."""
525+ self.launch_unity()
526+ greeter = self.main_window.get_greeter()
527+ greeter.swipe()
528+
529+ notify_list = self._get_notifications_list()
530+
531+ summary = 'Summary-Only'
532+
533+ notification = self._create_ephemeral_notification(summary)
534+ notification.show()
535+
536+ get_notification = lambda: notify_list.select_single('Notification')
537+ self.assertThat(get_notification, Eventually(NotEquals(None)))
538+ notification = get_notification()
539+
540+ self._assert_notification(notification, summary, '', False, False, 1.0)
541+
542+ def test_update_notification_same_layout(self):
543+ """Notification must allow updating its contents while being displayed."""
544+ self.launch_unity()
545+ greeter = self.main_window.get_greeter()
546+ greeter.swipe()
547+
548+ notify_list = self._get_notifications_list()
549+
550+ summary = 'Initial notification'
551+ body = 'This is the original content of this notification-bubble.'
552+ icon_path = self._get_icon_path('avatars/funky.png')
553+
554+ notification = self._create_ephemeral_notification(
555+ summary,
556+ body,
557+ icon_path
558+ )
559+ notification.show()
560+
561+ get_notification = lambda: notify_list.select_single('Notification', summary=summary)
562+ self.assertThat(get_notification, Eventually(NotEquals(None)))
563+ self._assert_notification(
564+ get_notification(),
565+ summary,
566+ body,
567+ True,
568+ False,
569+ 1.0
570+ )
571+
572+ summary = 'Updated notification'
573+ body = 'Here the same bubble with new title- and body-text, even ' \
574+ 'the icon can be changed on the update.'
575+ icon_path = self._get_icon_path('avatars/amanda.png')
576+ notification.update(summary, body, icon_path)
577+ notification.show()
578+ self.assertThat(get_notification, Eventually(NotEquals(None)))
579+ self._assert_notification(get_notification(), summary, body)
580+
581+ def test_update_notification_layout_change(self):
582+ """Notification must allow updating its contents and layout while being displayed."""
583+ self.launch_unity()
584+ greeter = self.main_window.get_greeter()
585+ greeter.swipe()
586+
587+ notify_list = self._get_notifications_list()
588+
589+ summary = 'Initial layout'
590+ body = 'This bubble uses the icon-title-body layout with a ' \
591+ 'secondary icon.'
592+ icon_path = self._get_icon_path('avatars/anna_olsson.png')
593+ hint_icon = self._get_icon_path('applicationIcons/phone-app.png')
594+
595+ notification = self._create_ephemeral_notification(
596+ summary,
597+ body,
598+ icon_path
599+ )
600+ notification.set_hint_string(
601+ 'x-canonical-secondary-icon',
602+ hint_icon
603+ )
604+ notification.show()
605+
606+ get_notification = lambda: notify_list.select_single('Notification')
607+ self.assertThat(get_notification, Eventually(NotEquals(None)))
608+ self._assert_notification(
609+ get_notification(),
610+ summary,
611+ body,
612+ True,
613+ True,
614+ 1.0
615+ )
616+
617+ notification.clear_hints()
618+ summary = 'Updated layout'
619+ body = 'After the update we now have a bubble using the title-body ' \
620+ 'layout.'
621+ notification.update(summary, body, None)
622+ notification.show()
623+
624+ self.assertThat(get_notification, Eventually(NotEquals(None)))
625+ self._assert_notification(get_notification(), summary, body, False)
626+
627+ def test_append_hint(self):
628+ """Notification has to accumulate body-text using append-hint."""
629+ self.launch_unity()
630+ greeter = self.main_window.get_greeter()
631+ greeter.swipe()
632+
633+ notify_list = self._get_notifications_list()
634+
635+ summary = 'Cole Raby'
636+ body = 'Hey Bro Coly!'
637+ icon_path = self._get_icon_path('avatars/amanda.png')
638+ body_sum = body
639+ notification = self._create_ephemeral_notification(
640+ summary,
641+ body,
642+ icon_path,
643+ hints=[('x-canonical-append', 'true')]
644+ )
645+
646+ notification.show()
647+
648+ get_notification = lambda: notify_list.select_single('Notification')
649+ self.assertThat(get_notification, Eventually(NotEquals(None)))
650+ notification = get_notification()
651+ self._assert_notification(
652+ notification,
653+ summary,
654+ body_sum,
655+ True,
656+ False,
657+ 1.0
658+ )
659+
660+ bodies = [
661+ 'What\'s up dude?',
662+ 'Did you watch the air-race in Oshkosh last week?',
663+ 'Phil owned the place like no one before him!',
664+ 'Did really everything in the race work according to regulations?',
665+ 'Somehow I think to remember Burt Williams did cut corners and was not punished for this.',
666+ 'Hopefully the referees will watch the videos of the race.',
667+ 'Burt could get fined with US$ 50000 for that rule-violation :)'
668+ ]
669+
670+ for new_body in bodies:
671+ body = new_body
672+ body_sum += '\n' + body
673+ notification = self._create_ephemeral_notification(
674+ summary,
675+ body,
676+ icon_path,
677+ hints=[('x-canonical-append', 'true')]
678+ )
679+ notification.show()
680+ get_notification = lambda: notify_list.select_single(
681+ 'Notification'
682+ )
683+ self.assertThat(get_notification, Eventually(NotEquals(None)))
684+ notification = get_notification()
685+ self._assert_notification(
686+ notification,
687+ summary,
688+ body_sum,
689+ True,
690+ False,
691+ 1.0
692+ )
693+
694+ def _create_ephemeral_notification(
695+ self,
696+ summary="",
697+ body="",
698+ icon=None,
699+ hints=[],
700+ urgency="NORMAL"
701+ ):
702+ """Create an ephemeral (non-interactive) notification
703+
704+ :param summary: Summary text for the notification
705+ :param body: Body text to display in the notification
706+ :param icon: Path string to the icon to use
707+ :param hint_strings: List of tuples containing the 'name' and value
708+ for setting the hint strings for the notification
709+ :param urgency: Urgency string for the noticiation, either: 'LOW',
710+ 'NORMAL', 'CRITICAL'
711+
712+ """
713+ logger.info(
714+ "Creating ephemeral: summary(%s), body(%s), urgency(%r) "
715+ "and Icon(%s)",
716+ summary,
717+ body,
718+ urgency,
719+ icon
720+ )
721+
722+ n = Notify.Notification.new(summary, body, icon)
723+
724+ for hint in hints:
725+ key, value = hint
726+ n.set_hint_string(key, value)
727+ logger.info("Adding hint to notification: (%s, %s)", key, value)
728+ n.set_urgency(self._get_urgency(urgency))
729+
730+ return n
731+
732+ def _get_urgency(self, urgency):
733+ """Translates urgency string to enum."""
734+ _urgency_enums = {'LOW': Notify.Urgency.LOW,
735+ 'NORMAL': Notify.Urgency.NORMAL,
736+ 'CRITICAL': Notify.Urgency.CRITICAL}
737+ return _urgency_enums.get(urgency.upper())