basic.py 12.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10
from accerciser.i18n import _
from pyatspi import *
from pyatspi.constants import *
from validate import Validator
import random

__metadata__ = {
  'name': _('Basic'),
  'description': _('Tests fundamental GUI application accessibility')}

11 12 13
URL_BASE = 'http://live.gnome.org/Accerciser/Validate#'


14
class ActionIsInteractive(Validator):
Peter Parente's avatar
Peter Parente committed
15 16 17 18
  '''
  Any item that supports the action interface should also be focusable or 
  selectable so the user may interact with it via the keyboard.
  '''
19 20
  URL = URL_BASE + 'Actionable_ITEM_ROLE_is_not_focusable_or_selectable'

21 22 23 24 25 26 27 28 29 30 31
  def condition(self, acc):
    return acc.queryAction()

  def before(self, acc, state, view):
    s = acc.getState()
    if (not (s.contains(STATE_FOCUSABLE) or
             s.contains(STATE_SELECTABLE))):
      view.error(_('actionable %s is not focusable or selectable') %
                 acc.getLocalizedRoleName(), acc, self.URL)

class WidgetHasAction(Validator):
Peter Parente's avatar
Peter Parente committed
32 33 34 35
  '''
  Any widget with a role listed in condition should support the action 
  interface.
  '''
36
  URL = URL_BASE + 'Interactive_ITEM_ROLE_is_not_actionable'
37 38 39 40 41 42 43 44 45 46 47 48 49
  def condition(self, acc):
    return acc.getRole() in [ROLE_PUSH_BUTTON, ROLE_MENU, ROLE_MENU_ITEM,
                             ROLE_CHECK_MENU_ITEM, ROLE_RADIO_MENU_ITEM,
                             ROLE_TOGGLE_BUTTON, ROLE_RADIO_BUTTON]

  def before(self, acc, state, view):
    try:
      acc.queryAction()
    except NotImplementedError:
      view.error(_('interactive %s is not actionable') %
                 acc.getLocalizedRoleName(), acc, self.URL)

class OneFocus(Validator):  
Peter Parente's avatar
Peter Parente committed
50 51 52 53
  '''
  The application should have on and only one accessible with state focused
  at any one time.
  '''
54
  URL = URL_BASE + 'More_than_one_focused_widget'
55 56 57
  def before(self, acc, state, view):
    s = acc.getState()
    if s.contains(STATE_FOCUSED):
Joanmarie Diggs's avatar
Joanmarie Diggs committed
58
      if 'focus' not in state:
59 60
        state['focus'] = acc
      else:
61
        view.error(_('more than one focused widget'), acc, self.URL)
62 63

class WidgetHasText(Validator):
Peter Parente's avatar
Peter Parente committed
64 65 66 67
  '''
  Any widget with a role listed in condition should support the text interface
  since they all support stylized text.
  '''
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 73
                             ROLE_TABLE_ROW_HEADER,
                             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 79 80 81 82 83
                             ROLE_TERMINAL]

  def before(self, acc, state, view):
    try:
      acc.queryText()
    except NotImplementedError:
84
      view.error(_('%s has no text interface') % acc.getLocalizedRoleName(), acc, self.URL)
85 86

class ParentChildIndexMatch(Validator):
Peter Parente's avatar
Peter Parente committed
87 88 89 90
  '''
  The index returned by acc.getIndexInParent should return acc when provided
  to getChildAtIndex.
  '''
91
  URL = URL_BASE + 'ITEM_ROLE_index_in_parent_does_not_match_child_index'
92 93 94 95 96 97 98 99 100
  def condition(self, acc):
    # don't test applications
    acc.queryApplication()
    return False
  
  def before(self, acc, state, view):
    pi = acc.getIndexInParent()
    child = acc.parent.getChildAtIndex(pi)
    if acc != child:
101 102 103
      # Translators: The first variable is the role name of the object that has an
      # index mismatch.
      # 
104
      view.error(_('%s index in parent does not match child index') %
105
                 acc.getLocalizedRoleName(), acc, self.URL)
106 107

class ReciprocalRelations(Validator):
Peter Parente's avatar
Peter Parente committed
108 109 110 111
  '''
  Any relation in the map should point to an accessible having the reciprocal
  relation.
  '''
112
  URL = URL_BASE + 'Missing_reciprocal_for_RELATION_NAME_relation'
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
  REL_MAP = {RELATION_LABEL_FOR : RELATION_LABELLED_BY,
             RELATION_CONTROLLER_FOR : RELATION_CONTROLLED_BY,
             RELATION_MEMBER_OF : RELATION_MEMBER_OF,
             RELATION_FLOWS_TO : RELATION_FLOWS_FROM,
             RELATION_EMBEDS : RELATION_EMBEDDED_BY,
             RELATION_POPUP_FOR : RELATION_PARENT_WINDOW_OF,
             RELATION_DESCRIPTION_FOR : RELATION_DESCRIBED_BY}
  
  def condition(self, acc):
    s = acc.getRelationSet()
    return len(s) > 0

  def _getReciprocal(self, kind):
    return self.REL_MAP.get(kind)

  def _hasRelationTarget(self, s, kind, acc):
    if kind is None:
      return True
    
    for rel in s:
      rec = rel.getRelationType()
      if kind != rec:
        continue
Joanmarie Diggs's avatar
Joanmarie Diggs committed
136
      for i in range(rel.getNTargets()):
137 138 139 140 141 142 143 144
        if rel.getTarget(i) == acc:
          return True
    return False

  def before(self, acc, state, view):
    s = acc.getRelationSet()
    for rel in s:
      kind = rel.getRelationType()
Joanmarie Diggs's avatar
Joanmarie Diggs committed
145
      for i in range(rel.getNTargets()):
146 147 148 149 150
        target = rel.getTarget(i)
        ts = target.getRelationSet()
        rec = self._getReciprocal(kind)
        if not self._hasRelationTarget(ts, rec, acc):
          view.error(_('Missing reciprocal for %s relation') %
151
                     rel.getRelationTypeName(), acc, self.URL)
152 153
    
class HasLabelName(Validator):
Peter Parente's avatar
Peter Parente committed
154 155 156 157
  '''
  Any accessible with one of the roles listed below should have an accessible
  name, a labelled by relationship, or both.
  '''
158
  URL = URL_BASE + 'ITEM_ROLE_missing_name_or_label'
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191
  TEXT_CANNOT_LABEL = [ROLE_SPIN_BUTTON, ROLE_SLIDER, ROLE_PASSWORD_TEXT,
                       ROLE_TEXT, ROLE_ENTRY, ROLE_TERMINAL]
                   
  TEXT_CAN_LABEL = [ROLE_PUSH_BUTTON, ROLE_MENU, ROLE_MENU_ITEM,
                    ROLE_CHECK_MENU_ITEM, ROLE_RADIO_MENU_ITEM,
                    ROLE_TOGGLE_BUTTON, ROLE_TABLE_COLUMN_HEADER,
                    ROLE_TABLE_ROW_HEADER, ROLE_ROW_HEADER,
                    ROLE_COLUMN_HEADER, ROLE_RADIO_BUTTON, ROLE_PAGE_TAB,
                    ROLE_LIST_ITEM, ROLE_LINK, ROLE_LABEL, ROLE_HEADING,
                    ROLE_HEADER, ROLE_FOOTER, ROLE_CHECK_BOX, ROLE_CAPTION,
                    ]

  def condition(self, acc):
    return acc.getRole() in (self.TEXT_CANNOT_LABEL + self.TEXT_CAN_LABEL)

  def _checkForReadable(self, acc):
    if acc.name and acc.name.strip():
      return True
    if acc in self.TEXT_CAN_LABEL:
      try:
        t = acc.queryText()
      except NotImplementedError:
        return False
      if t.getText(0, -1).strip():
        return True
    return False

  def before(self, acc, state, view):
    if self._checkForReadable(acc):
      return
    for rel in acc.getRelationSet():
      if rel.getRelationType() != RELATION_LABELLED_BY:
        continue
Joanmarie Diggs's avatar
Joanmarie Diggs committed
192
      for i in range(rel.getNTargets()):
193 194 195
        target = rel.getTarget(i)
        if self._checkForReadable(target):
          return
196 197 198
    # Translators: The first variable is the role name of the object that is missing
    # the name or label.
    # 
199 200 201 202
    view.error(_('%s missing name or label') % acc.getLocalizedRoleName(), acc,
               self.URL)
    
class TableHasSelection(Validator):
Peter Parente's avatar
Peter Parente committed
203 204 205 206
  '''
  A focusable accessible with a table interface should also support the 
  selection interface.
  '''
207 208
  URL = URL_BASE + \
    'Focusable_ITEM_ROLE_has_a_table_interface.2C_but_not_a_selection_interface'
209 210 211 212 213 214 215 216
  def condition(self, acc):
    acc.queryTable()
    return acc.getState().contains(STATE_FOCUSABLE)

  def before(self, acc, state, view):
    try:
      acc.querySelection()
    except NotImplementedError:
217 218
      view.error(_('focusable %s has a table interface, but not a selection interface') %
                 acc.getLocalizedRoleName(), acc, self.URL)
219 220
                 
class StateWithAbility(Validator):
Peter Parente's avatar
Peter Parente committed
221 222 223 224
  '''
  Any accessible with one of the ephemeral states in state map should have the
  corresponding -able state.
  '''
225 226
  URL = URL_BASE + \
    'ITEM_ROLE_has_ITEM_EPHEMERAL_STATE_state_without_ITEM_ABLE_STATE_state'
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241
  STATE_MAP = {STATE_EXPANDED : STATE_EXPANDABLE,
               STATE_COLLAPSED : STATE_EXPANDABLE,
               STATE_FOCUSED : STATE_FOCUSABLE,
               STATE_SELECTED: STATE_SELECTABLE}
  def condition(self, acc):
    ss = acc.getState()
    for s in self.STATE_MAP:
      if ss.contains(s):
        self.test_state = s
        return True

  def before(self, acc, state, view):
    ss = acc.getState()
    able_state = self.STATE_MAP[self.test_state]
    if not ss.contains(able_state):
242 243 244 245
      # Translators: First variable is an accessible role name, the next two
      # variables are accessible state names.
      # For example: "button has focused state without focusable state".
      #
246 247 248
      view.error(_('%s has %s state without %s state') % (
        acc.getLocalizedRoleName(),
        stateToString(self.test_state),
249
        stateToString(able_state)), acc, self.URL)
250 251

class RadioInSet(Validator):
Peter Parente's avatar
Peter Parente committed
252 253 254 255
  '''
  An accessible with a radio button role should be a member of a set as 
  indicated by a relation or appropriate object property.
  '''
256
  URL = URL_BASE + 'ITEM_ROLE_does_not_belong_to_a_set'
257 258 259 260 261 262
  def condition(self, acc):
    return self.getRole() in [ROLE_RADIO_BUTTON, ROLE_RADIO_MENU_ITEM]

  def before(self, acc, state, view):
    attrs = acc.getAttributes()
    m = dict([attr.split(':', 1) for attr in attrs])
Joanmarie Diggs's avatar
Joanmarie Diggs committed
263
    if 'posinset' in m:
264 265 266 267 268
      return
    rels = acc.getRelationSet()
    for rel in rels:
      if rel.getRelationType() == RELATION_MEMBER_OF:
        return
269 270 271
    # Translators: The radio button does not belong to a set, thus it is useless.
    # The first variable is the object's role name.
    # 
272
    view.error(_('%s does not belong to a set') % acc.getLocalizedRoleName(),
273
               acc, self.URL)
274 275 276 277 278 279 280 281

def _randomRowCol(table):
  rows, cols = table.nRows, table.nColumns
  r = random.randint(0, rows-1)
  c = random.randint(0, cols-1)
  return r, c
    
class TableRowColIndex(Validator):
Peter Parente's avatar
Peter Parente committed
282 283 284 285
  '''
  The index returned by getIndexAt(row, col) should result in getRowAtIndex
  and getColumnAtIndex returning the original row and col.
  '''
286
  URL = URL_BASE + 'ITEM_ROLEs_index_X_does_not_match_row_and_column'
287 288 289 290 291 292 293 294 295
  MAX_SAMPLES = 100
  def condition(self, acc):
    t = acc.queryTable()
    # must not be empty to test
    return (t.nRows and t.nColumns)

  def before(self, acc, state, view):
    t = acc.queryTable()
    samples = max(t.nRows * t.nColumns, self.MAX_SAMPLES)
Joanmarie Diggs's avatar
Joanmarie Diggs committed
296
    for i in range(samples):
297 298 299 300 301
      r, c = _randomRowCol(t)
      i = t.getIndexAt(r, c)
      ir = t.getRowAtIndex(i)
      ic = t.getColumnAtIndex(i)
      if r != ir or c != ic:
302 303
        # Translators: The row or column number retrieved from a table child's
        # object at a certain index is wrong.
304 305 306
        # The first variable is the role name of the object, the second is the
        # given index.
        # 
307
        view.error(_('%(rolename)s index %(num)d does not match row and column') %
308
                   {'rolename':acc.getLocalizedRoleName(), 'num':i}, acc, self.URL)
309 310 311
        return

class TableRowColParentIndex(Validator):
Peter Parente's avatar
Peter Parente committed
312 313 314 315
  '''
  The accessible returned by table.getAccessibleAt should return 
  acc.getIndexInParent matching acc.getIndexAt.
  '''
316 317
  URL = URL_BASE + \
    'ITEM_ROLEs_parent_index_X_does_not_match_row_and_column_index_Y'
318 319 320 321 322 323 324 325 326
  MAX_SAMPLES = 100
  def condition(self, acc):
    t = acc.queryTable()
    # must not be empty to test
    return (t.nRows and t.nColumns)

  def before(self, acc, state, view):
    t = acc.queryTable()
    samples = max(t.nRows * t.nColumns, self.MAX_SAMPLES)
Joanmarie Diggs's avatar
Joanmarie Diggs committed
327
    for i in range(samples):
328 329 330 331 332
      r, c = _randomRowCol(t)
      child = t.getAccessibleAt(r, c)
      ip = child.getIndexInParent()
      i = t.getIndexAt(r, c)
      if i != ip:
333 334 335 336 337 338
        # Translators: The "parent index" is the order of the child in the parent.
        # the "row and column index" should be the same value retrieved by the
        # object's location in the table.
        # The first variable is the object's role name, the second and third variables
        # are index numbers.
        #
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 342 343
        return

class ImageHasName(Validator):
Peter Parente's avatar
Peter Parente committed
344 345 346 347
  '''
  Any accessible with an image role or image interface should have either a
  name, description, or image description.
  '''
348
  URL = URL_BASE + 'ITEM_ROLE_has_no_name_or_description'
349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366
  def condition(self, acc):
    if acc.getRole() in [ROLE_DESKTOP_ICON, ROLE_ICON, ROLE_ANIMATION,
                         ROLE_IMAGE]:
      return True
    acc.queryImage()
    return True

  def before(self, acc, state, view):
    if ((acc.name and acc.name.strip()) or
        (acc.description and acc.description.strip())):
      return
    ni = False
    try:
      im = acc.queryImage()
    except NotImplementedError:
      ni = True
    if ni or im.imageDescription is None or not im.imageDescription.strip():
      view.error(_('%s has no name or description') % 
367
                 acc.getLocalizedRoleName(), acc, self.URL)