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