simframe 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/view.js CHANGED
@@ -247,8 +247,26 @@ export function actsInteractive(t) {
247
247
  export function rowsFor(entry, { screen, filter, interactive, all = false, limit = DEFAULT_LIMIT } = {}) {
248
248
  let kept = (entry?.targets ?? []).map((t) => ({ ...t })).filter((t) => {
249
249
  if (!isNum(t.x) || !isNum(t.y)) return false;
250
- // Off-screen elements are real in the tree and untappable in fact.
251
- if (regions.offViewport(t, screen)) return false;
250
+ // Off-screen elements are real in the tree and untappable in fact —
251
+ // *unless* enough of one is on screen to tap, which the centre test cannot
252
+ // see. A filter chip showing 29pt of itself at the right edge was dropped
253
+ // while OCR's reading of that same sliver, labelled "Flc", was kept and
254
+ // printed in its place. Item 121: say so or clamp. Both, here — the row is
255
+ // kept, its tap point is moved to the centre of the visible part, and it is
256
+ // marked so nobody reads a clamped coordinate as a whole element.
257
+ //
258
+ // Deliberately NOT also relaxing `locate`. A clipped element still answers
259
+ // "in the tree but not in view", so `scrollTo` keeps scrolling to it rather
260
+ // than declaring a sliver good enough — that conservatism was bought with a
261
+ // reported session lost to `"'Assigned to Me' is in view at 422,277
262
+ // already"`, and widening it here would buy the same bug back.
263
+ if (regions.offViewport(t, screen)) {
264
+ const clip = regions.clipping(t, screen);
265
+ if (!clip?.usable) return false;
266
+ t.clipped = true;
267
+ t.x = clip.point.x;
268
+ t.y = clip.point.y;
269
+ }
252
270
  if (!all && !regions.offerable(t.region)) return false;
253
271
  if (!all && isNoise(t)) return false;
254
272
  return true;
@@ -380,11 +398,42 @@ function valueNote(r) {
380
398
  return `= ${v}`;
381
399
  }
382
400
 
401
+ /**
402
+ * What to call a row.
403
+ *
404
+ * A control with no accessibility label is not necessarily anonymous, and the
405
+ * order here is by how much the name can be trusted. Its own label first. Then
406
+ * its `testID`, which is a name a developer chose and which `tap` has always
407
+ * matched on. Then OCR's reading of it, which is how an icon-only control gets
408
+ * a name at all and which cannot contradict a label it does not have. Only when
409
+ * all three are missing is it really unaddressable, and that is the case item
410
+ * 122 is about: three field reports in a row spent their time on controls that
411
+ * existed, were tappable, and could be reached by no selector at all.
412
+ */
413
+ export function displayName(r) {
414
+ return trim(r.label)
415
+ || trim(r.identifier)
416
+ // Only an alias with a word in it. OCR reads the three dots of an overflow
417
+ // menu as `...`, and on the first live run of this the button was named
418
+ // `#1 button 364,84 ...` — which looks like a name, cannot be typed into a
419
+ // selector, and made the count below say there was nothing unnamed here.
420
+ // `isNoise` already refuses such text as a row of its own; it must not get
421
+ // in through the alias door either.
422
+ || (r.aliases ?? []).find((a) => alnum(a))
423
+ || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)');
424
+ }
425
+
426
+ /** A row the map can print and no caller can name. */
427
+ export const unaddressable = (r) =>
428
+ matching.isAxTarget(r) && !trim(r.label) && !trim(r.identifier)
429
+ && !(r.aliases ?? []).some((a) => alnum(a));
430
+
383
431
  function renderRow(r) {
432
+ const shown = displayName(r);
384
433
  const name = [
385
- trim(r.label) || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)'),
434
+ shown,
386
435
  valueNote(r),
387
- aliasNote(r),
436
+ aliasNote(r, shown),
388
437
  ].filter(Boolean).join(' ');
389
438
  const state = [
390
439
  r.enabled === false ? 'disabled' : null,
@@ -395,7 +444,9 @@ function renderRow(r) {
395
444
  shortType(r.type).padEnd(9),
396
445
  `${r.x},${r.y}`.padEnd(9),
397
446
  state ? `${state} ` : '',
398
- name,
447
+ // After the name, and appended rather than added as a column: an extra
448
+ // element in this join puts an extra space on *every* row, clipped or not.
449
+ r.clipped ? `${name} (partly off-screen — the coordinate is the middle of the visible part)` : name,
399
450
  ].join(' ');
400
451
  }
401
452
 
@@ -406,11 +457,14 @@ function renderRow(r) {
406
457
  * for, which is useful when they disagree and pure cost when they agree —
407
458
  * "WELCOME ~ WELCOME" was a third of some rows.
408
459
  */
409
- function aliasNote(r) {
460
+ function aliasNote(r, shown = r.label) {
410
461
  const extra = (r.aliases ?? [])
411
462
  .filter((a) => {
412
463
  const t = alnum(a);
413
- return t && !alnum(r.label).includes(t);
464
+ // Compared against the name actually printed, not against the label. An
465
+ // unlabelled control is named by its own alias now, and comparing against
466
+ // an absent label printed every one of them twice: `Sort ~ Sort`.
467
+ return t && !alnum(shown).includes(t);
414
468
  })
415
469
  .slice(0, 2);
416
470
  return extra.length ? `~ ${trim(extra.join(' '))}` : null;
@@ -511,6 +565,25 @@ export async function screenMap(deviceQuery, {
511
565
  + ' you did not expect to see as belonging to the layer underneath'
512
566
  : null;
513
567
 
568
+ // Controls that are on the screen and answer to no name — item 122.
569
+ //
570
+ // Counted from the rows about to be printed rather than from the map, so it
571
+ // is a statement about what the caller can see. The wording is the ask,
572
+ // near-verbatim from the report that made it: *"a line like 'N on-screen
573
+ // tappable views have no accessibility label — they cannot be addressed by
574
+ // selector' would push people toward instrumenting, which is the outcome
575
+ // everyone wants"*. It is also the honest answer to the second half of that
576
+ // item, which we cannot fix from here: a view UIKit was never told is
577
+ // accessible is invisible to the tree, so a screen whose controls are all
578
+ // undeclared shows this count as 0 and still needs a screenshot.
579
+ const anonymous = rows.filter(unaddressable).length;
580
+ const unnamed = anonymous
581
+ ? `${anonymous} on-screen control(s) have no accessibility label — they are listed with their`
582
+ + ' coordinates and can be tapped by point or by #ref, but not by name. If what you are'
583
+ + ' looking for is not in the list either, the app has views that were never declared'
584
+ + ' accessible and only a screenshot will find those.'
585
+ : null;
586
+
514
587
  return {
515
588
  device,
516
589
  identity,
@@ -529,7 +602,8 @@ export async function screenMap(deviceQuery, {
529
602
  staleExits,
530
603
  cleared,
531
604
  overlay,
532
- text: render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, cleared, overlay }),
605
+ unnamed,
606
+ text: render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, cleared, overlay, unnamed }),
533
607
  };
534
608
  }
535
609
 
@@ -712,7 +786,7 @@ export function ambiguousLabels(rows) {
712
786
  return [...seen.values()].filter((n) => n > 1).length;
713
787
  }
714
788
 
715
- export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay }) {
789
+ export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay, unnamed }) {
716
790
  const head = [
717
791
  device?.name,
718
792
  screen?.width ? `${screen.width}x${screen.height}pt` : null,
@@ -747,6 +821,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
747
821
  // already believes, which is the one kind of news that must not be scrolled to.
748
822
  if (cleared) lines.push(cleared);
749
823
  if (overlay) lines.push(overlay);
824
+ if (unnamed) lines.push(unnamed);
750
825
  const worked = exitsLine(exitList, { stale: staleExits });
751
826
  if (worked) lines.push(worked);
752
827