1from accerciser.i18n import _
2from pyatspi import *
3from pyatspi.constants import *
4from validate import Validator
5import random
6
7__metadata__ = {
8  'name': _('Basic'),
9  'description': _('Tests fundamental GUI application accessibility')}
10
11URL_BASE = 'http://live.gnome.org/Accerciser/Validate#'
12
13
14class ActionIsInteractive(Validator):
15  '''
16  Any item that supports the action interface should also be focusable or
17  selectable so the user may interact with it via the keyboard.
18  '''
19  URL = URL_BASE + 'Actionable_ITEM_ROLE_is_not_focusable_or_selectable'
20
21  def condition(self, acc):
22    return acc.queryAction()
23
24  def before(self, acc, state, view):
25    s = acc.getState()
26    if (not (s.contains(STATE_FOCUSABLE) or
27             s.contains(STATE_SELECTABLE))):
28      view.error(_('actionable %s is not focusable or selectable') %
29                 acc.getLocalizedRoleName(), acc, self.URL)
30
31class WidgetHasAction(Validator):
32  '''
33  Any widget with a role listed in condition should support the action
34  interface.
35  '''
36  URL = URL_BASE + 'Interactive_ITEM_ROLE_is_not_actionable'
37  def condition(self, acc):
38    return acc.getRole() in [ROLE_PUSH_BUTTON, ROLE_MENU, ROLE_MENU_ITEM,
39                             ROLE_CHECK_MENU_ITEM, ROLE_RADIO_MENU_ITEM,
40                             ROLE_TOGGLE_BUTTON, ROLE_RADIO_BUTTON]
41
42  def before(self, acc, state, view):
43    try:
44      acc.queryAction()
45    except NotImplementedError:
46      view.error(_('interactive %s is not actionable') %
47                 acc.getLocalizedRoleName(), acc, self.URL)
48
49class OneFocus(Validator):
50  '''
51  The application should have on and only one accessible with state focused
52  at any one time.
53  '''
54  URL = URL_BASE + 'More_than_one_focused_widget'
55  def before(self, acc, state, view):
56    s = acc.getState()
57    if s.contains(STATE_FOCUSED):
58      if 'focus' not in state:
59        state['focus'] = acc
60      else:
61        view.error(_('more than one focused widget'), acc, self.URL)
62
63class WidgetHasText(Validator):
64  '''
65  Any widget with a role listed in condition should support the text interface
66  since they all support stylized text.
67  '''
68  URL = URL_BASE + 'ITEM_ROLE_has_no_text_interface'
69  def condition(self, acc):
70    return acc.getRole() in [
71                             ROLE_TABLE_COLUMN_HEADER,
72                             ROLE_TABLE_ROW_HEADER,
73                             ROLE_PASSWORD_TEXT,
74                             ROLE_TEXT, ROLE_ENTRY, ROLE_PARAGRAPH,
75                             ROLE_LIST_ITEM,
76                             ROLE_HEADING, ROLE_HEADER,
77                             ROLE_FOOTER, ROLE_CAPTION,
78                             ROLE_TERMINAL]
79
80  def before(self, acc, state, view):
81    try:
82      acc.queryText()
83    except NotImplementedError:
84      view.error(_('%s has no text interface') % acc.getLocalizedRoleName(), acc, self.URL)
85
86class ParentChildIndexMatch(Validator):
87  '''
88  The index returned by acc.getIndexInParent should return acc when provided
89  to getChildAtIndex.
90  '''
91  URL = URL_BASE + 'ITEM_ROLE_index_in_parent_does_not_match_child_index'
92  def condition(self, acc):
93    # don't test applications
94    acc.queryApplication()
95    return False
96
97  def before(self, acc, state, view):
98    pi = acc.getIndexInParent()
99    child = acc.parent.getChildAtIndex(pi)
100    if acc != child:
101      # Translators: The first variable is the role name of the object that has an
102      # index mismatch.
103      #
104      view.error(_('%s index in parent does not match child index') %
105                 acc.getLocalizedRoleName(), acc, self.URL)
106
107class ReciprocalRelations(Validator):
108  '''
109  Any relation in the map should point to an accessible having the reciprocal
110  relation.
111  '''
112  URL = URL_BASE + 'Missing_reciprocal_for_RELATION_NAME_relation'
113  REL_MAP = {RELATION_LABEL_FOR : RELATION_LABELLED_BY,
114             RELATION_CONTROLLER_FOR : RELATION_CONTROLLED_BY,
115             RELATION_MEMBER_OF : RELATION_MEMBER_OF,
116             RELATION_FLOWS_TO : RELATION_FLOWS_FROM,
117             RELATION_EMBEDS : RELATION_EMBEDDED_BY,
118             RELATION_POPUP_FOR : RELATION_PARENT_WINDOW_OF,
119             RELATION_DESCRIPTION_FOR : RELATION_DESCRIBED_BY}
120
121  def condition(self, acc):
122    s = acc.getRelationSet()
123    return len(s) > 0
124
125  def _getReciprocal(self, kind):
126    return self.REL_MAP.get(kind)
127
128  def _hasRelationTarget(self, s, kind, acc):
129    if kind is None:
130      return True
131
132    for rel in s:
133      rec = rel.getRelationType()
134      if kind != rec:
135        continue
136      for i in range(rel.getNTargets()):
137        if rel.getTarget(i) == acc:
138          return True
139    return False
140
141  def before(self, acc, state, view):
142    s = acc.getRelationSet()
143    for rel in s:
144      kind = rel.getRelationType()
145      for i in range(rel.getNTargets()):
146        target = rel.getTarget(i)
147        ts = target.getRelationSet()
148        rec = self._getReciprocal(kind)
149        if not self._hasRelationTarget(ts, rec, acc):
150          view.error(_('Missing reciprocal for %s relation') %
151                     rel.getRelationTypeName(), acc, self.URL)
152
153class HasLabelName(Validator):
154  '''
155  Any accessible with one of the roles listed below should have an accessible
156  name, a labelled by relationship, or both.
157  '''
158  URL = URL_BASE + 'ITEM_ROLE_missing_name_or_label'
159  TEXT_CANNOT_LABEL = [ROLE_SPIN_BUTTON, ROLE_SLIDER, ROLE_PASSWORD_TEXT,
160                       ROLE_TEXT, ROLE_ENTRY, ROLE_TERMINAL]
161
162  TEXT_CAN_LABEL = [ROLE_PUSH_BUTTON, ROLE_MENU, ROLE_MENU_ITEM,
163                    ROLE_CHECK_MENU_ITEM, ROLE_RADIO_MENU_ITEM,
164                    ROLE_TOGGLE_BUTTON, ROLE_TABLE_COLUMN_HEADER,
165                    ROLE_TABLE_ROW_HEADER, ROLE_ROW_HEADER,
166                    ROLE_COLUMN_HEADER, ROLE_RADIO_BUTTON, ROLE_PAGE_TAB,
167                    ROLE_LIST_ITEM, ROLE_LINK, ROLE_LABEL, ROLE_HEADING,
168                    ROLE_HEADER, ROLE_FOOTER, ROLE_CHECK_BOX, ROLE_CAPTION,
169                    ]
170
171  def condition(self, acc):
172    return acc.getRole() in (self.TEXT_CANNOT_LABEL + self.TEXT_CAN_LABEL)
173
174  def _checkForReadable(self, acc):
175    if acc.name and acc.name.strip():
176      return True
177    if acc in self.TEXT_CAN_LABEL:
178      try:
179        t = acc.queryText()
180      except NotImplementedError:
181        return False
182      if t.getText(0, -1).strip():
183        return True
184    return False
185
186  def before(self, acc, state, view):
187    if self._checkForReadable(acc):
188      return
189    for rel in acc.getRelationSet():
190      if rel.getRelationType() != RELATION_LABELLED_BY:
191        continue
192      for i in range(rel.getNTargets()):
193        target = rel.getTarget(i)
194        if self._checkForReadable(target):
195          return
196    # Translators: The first variable is the role name of the object that is missing
197    # the name or label.
198    #
199    view.error(_('%s missing name or label') % acc.getLocalizedRoleName(), acc,
200               self.URL)
201
202class TableHasSelection(Validator):
203  '''
204  A focusable accessible with a table interface should also support the
205  selection interface.
206  '''
207  URL = URL_BASE + \
208    'Focusable_ITEM_ROLE_has_a_table_interface.2C_but_not_a_selection_interface'
209  def condition(self, acc):
210    acc.queryTable()
211    return acc.getState().contains(STATE_FOCUSABLE)
212
213  def before(self, acc, state, view):
214    try:
215      acc.querySelection()
216    except NotImplementedError:
217      view.error(_('focusable %s has a table interface, but not a selection interface') %
218                 acc.getLocalizedRoleName(), acc, self.URL)
219
220class StateWithAbility(Validator):
221  '''
222  Any accessible with one of the ephemeral states in state map should have the
223  corresponding -able state.
224  '''
225  URL = URL_BASE + \
226    'ITEM_ROLE_has_ITEM_EPHEMERAL_STATE_state_without_ITEM_ABLE_STATE_state'
227  STATE_MAP = {STATE_EXPANDED : STATE_EXPANDABLE,
228               STATE_COLLAPSED : STATE_EXPANDABLE,
229               STATE_FOCUSED : STATE_FOCUSABLE,
230               STATE_SELECTED: STATE_SELECTABLE}
231  def condition(self, acc):
232    ss = acc.getState()
233    for s in self.STATE_MAP:
234      if ss.contains(s):
235        self.test_state = s
236        return True
237
238  def before(self, acc, state, view):
239    ss = acc.getState()
240    able_state = self.STATE_MAP[self.test_state]
241    if not ss.contains(able_state):
242      # Translators: First variable is an accessible role name, the next two
243      # variables are accessible state names.
244      # For example: "button has focused state without focusable state".
245      #
246      view.error(_('%s has %s state without %s state') % (
247        acc.getLocalizedRoleName(),
248        stateToString(self.test_state),
249        stateToString(able_state)), acc, self.URL)
250
251class RadioInSet(Validator):
252  '''
253  An accessible with a radio button role should be a member of a set as
254  indicated by a relation or appropriate object property.
255  '''
256  URL = URL_BASE + 'ITEM_ROLE_does_not_belong_to_a_set'
257  def condition(self, acc):
258    return self.getRole() in [ROLE_RADIO_BUTTON, ROLE_RADIO_MENU_ITEM]
259
260  def before(self, acc, state, view):
261    attrs = acc.getAttributes()
262    m = dict([attr.split(':', 1) for attr in attrs])
263    if 'posinset' in m:
264      return
265    rels = acc.getRelationSet()
266    for rel in rels:
267      if rel.getRelationType() == RELATION_MEMBER_OF:
268        return
269    # Translators: The radio button does not belong to a set, thus it is useless.
270    # The first variable is the object's role name.
271    #
272    view.error(_('%s does not belong to a set') % acc.getLocalizedRoleName(),
273               acc, self.URL)
274
275def _randomRowCol(table):
276  rows, cols = table.nRows, table.nColumns
277  r = random.randint(0, rows-1)
278  c = random.randint(0, cols-1)
279  return r, c
280
281class TableRowColIndex(Validator):
282  '''
283  The index returned by getIndexAt(row, col) should result in getRowAtIndex
284  and getColumnAtIndex returning the original row and col.
285  '''
286  URL = URL_BASE + 'ITEM_ROLEs_index_X_does_not_match_row_and_column'
287  MAX_SAMPLES = 100
288  def condition(self, acc):
289    t = acc.queryTable()
290    # must not be empty to test
291    return (t.nRows and t.nColumns)
292
293  def before(self, acc, state, view):
294    t = acc.queryTable()
295    samples = max(t.nRows * t.nColumns, self.MAX_SAMPLES)
296    for i in range(samples):
297      r, c = _randomRowCol(t)
298      i = t.getIndexAt(r, c)
299      ir = t.getRowAtIndex(i)
300      ic = t.getColumnAtIndex(i)
301      if r != ir or c != ic:
302        # Translators: The row or column number retrieved from a table child's
303        # object at a certain index is wrong.
304        # The first variable is the role name of the object, the second is the
305        # given index.
306        #
307        view.error(_('%(rolename)s index %(num)d does not match row and column') %
308                   {'rolename':acc.getLocalizedRoleName(), 'num':i}, acc, self.URL)
309        return
310
311class TableRowColParentIndex(Validator):
312  '''
313  The accessible returned by table.getAccessibleAt should return
314  acc.getIndexInParent matching acc.getIndexAt.
315  '''
316  URL = URL_BASE + \
317    'ITEM_ROLEs_parent_index_X_does_not_match_row_and_column_index_Y'
318  MAX_SAMPLES = 100
319  def condition(self, acc):
320    t = acc.queryTable()
321    # must not be empty to test
322    return (t.nRows and t.nColumns)
323
324  def before(self, acc, state, view):
325    t = acc.queryTable()
326    samples = max(t.nRows * t.nColumns, self.MAX_SAMPLES)
327    for i in range(samples):
328      r, c = _randomRowCol(t)
329      child = t.getAccessibleAt(r, c)
330      ip = child.getIndexInParent()
331      i = t.getIndexAt(r, c)
332      if i != ip:
333        # Translators: The "parent index" is the order of the child in the parent.
334        # the "row and column index" should be the same value retrieved by the
335        # object's location in the table.
336        # The first variable is the object's role name, the second and third variables
337        # are index numbers.
338        #
339        view.error(_('%(rolename)s parent index %(num1)d does not match row and column index %(num2)d') %
340                   {'rolename':acc.getLocalizedRoleName(), 'num1':ip, 'num2':i}, acc, self.URL)
341        return
342
343class ImageHasName(Validator):
344  '''
345  Any accessible with an image role or image interface should have either a
346  name, description, or image description.
347  '''
348  URL = URL_BASE + 'ITEM_ROLE_has_no_name_or_description'
349  def condition(self, acc):
350    if acc.getRole() in [ROLE_DESKTOP_ICON, ROLE_ICON, ROLE_ANIMATION,
351                         ROLE_IMAGE]:
352      return True
353    acc.queryImage()
354    return True
355
356  def before(self, acc, state, view):
357    if ((acc.name and acc.name.strip()) or
358        (acc.description and acc.description.strip())):
359      return
360    ni = False
361    try:
362      im = acc.queryImage()
363    except NotImplementedError:
364      ni = True
365    if ni or im.imageDescription is None or not im.imageDescription.strip():
366      view.error(_('%s has no name or description') %
367                 acc.getLocalizedRoleName(), acc, self.URL)
368