WolfDavid commited on
Commit
fd38c7c
·
1 Parent(s): 89a4bf6

test(02-08): popover at three layers, incl. the Pixel 7 column

Browse files

- test_facade_parity.py::test_lookup_popover_under_both_transports: a typed turn, then
taps on 田中さん and 住んでいます inside a request counter. Zero requests under BOTH
transports, which is the whole claim: the glosses ride inside the token the line holds,
so the iframe transport needed no new bridge call
- test_avatar_loop.py::test_lookup_popover: the card's senses ("study" among them), its
dictionary form 勉強する, its reading べんきょうしています and a badge compared against the
page's own lastTokens[2].jlpt; the learner's line is tappable too (D-03/D-18); the
plain spans carry no button role (D-11)
- test_avatar_loop.py::test_lookup_popover_mobile: the Pixel 7 descriptor with real
touchscreen taps, asserting insideTranscript on a 324 px column

Found by the rows above and fixed here:
- the card was clamped into view even when that put it on top of the word it was anchored
to, so a second tap hit the card instead of the next word. It now goes on whichever side
of the word has more room and never grows past that room (it scrolls internally)
- a 196 px column is not tall enough to hold both the conversation and the card: the card
covered the line above its anchor and made those words untappable. #transcript-text is
now a 40vh scrolling log (335 px on a Pixel 7) instead of a min/max-height block

avatar/transcript.css CHANGED
@@ -13,13 +13,16 @@
13
  * auto-height element - so a long conversation grew the page instead of scrolling the
14
  * transcript. The height is also what makes plan 02-08's `insideTranscript` a fact rather
15
  * than a coincidence: a card can only be proven to have stayed inside a box that has edges,
16
- * and on a phone that box is all the room there is. */
 
 
 
17
  #transcript-text {
18
  position: relative;
19
  overflow-x: clip;
20
  overflow-y: auto;
21
- min-height: 9em;
22
- max-height: 40vh;
23
  }
24
  #transcript-text .said {
25
  line-height: 2.1;
 
13
  * auto-height element - so a long conversation grew the page instead of scrolling the
14
  * transcript. The height is also what makes plan 02-08's `insideTranscript` a fact rather
15
  * than a coincidence: a card can only be proven to have stayed inside a box that has edges,
16
+ * and on a phone that box is all the room there is. It is a viewport fraction rather than a
17
+ * max-height because the lookup card needs room BESIDE the words as well as under them: on
18
+ * a 196 px column a card is wide enough to cover the line above the one it is anchored to,
19
+ * and a word under the card cannot be tapped. 40vh is 335 px on a Pixel 7. */
20
  #transcript-text {
21
  position: relative;
22
  overflow-x: clip;
23
  overflow-y: auto;
24
+ height: 40vh;
25
+ min-height: 14em;
26
  }
27
  #transcript-text .said {
28
  line-height: 2.1;
avatar/transcript.js CHANGED
@@ -51,8 +51,10 @@ const MAX_SENSES = 3;
51
  /** The gap between the tapped word and the card, and the inset from the column's edges. */
52
  const ANCHOR_GAP_PX = 6;
53
  const EDGE_INSET_PX = 8;
54
- /** The card never grows past this, nor past the column it must stay inside (see position). */
55
  const CARD_MAX_HEIGHT_PX = 240;
 
 
56
 
57
  function clamp(value, low, high) {
58
  return Math.min(Math.max(value, low), high);
@@ -293,23 +295,31 @@ export function createTranscript(container, { doc = document } = {}) {
293
  * Offsets are in the container's PADDING-BOX coordinates - what `position: absolute`
294
  * inside `#transcript-text` resolves against - so `clientLeft` / `clientTop` (the border
295
  * widths) come off the viewport rects, and `scrollTop` converts the visible position into
296
- * the scrolled content's. The card is clamped horizontally into the column, flipped above
297
- * the word when it would fall out of the bottom, and, when the column is tall enough to
298
- * hold it at all, clamped into view vertically as a last resort - which is what keeps
299
- * insideTranscript true on a 300 px phone column.
 
 
300
  */
301
  function position(el, tokEl) {
302
  el.style.left = '0px';
303
  el.style.top = '0px';
304
- // A three-sense entry can be taller than a phone's transcript column. Capping the card
305
- // to the column (it scrolls internally past that) is what turns "it fitted this time"
306
- // into "it always fits": every branch below can then place it inside.
307
- el.style.maxHeight = `${Math.min(
308
- CARD_MAX_HEIGHT_PX,
309
- Math.max(60, container.clientHeight - 2 * EDGE_INSET_PX)
310
- )}px`;
311
  const contRect = container.getBoundingClientRect();
312
  const tokRect = tokEl.getBoundingClientRect();
 
 
 
 
 
 
 
 
 
 
 
 
313
  const width = el.offsetWidth;
314
  const height = el.offsetHeight;
315
 
@@ -319,18 +329,11 @@ export function createTranscript(container, { doc = document } = {}) {
319
  EDGE_INSET_PX,
320
  maxLeft
321
  );
322
-
323
- const viewTop = container.scrollTop;
324
- const viewBottom = viewTop + container.clientHeight;
325
- const base = tokRect.top - contRect.top - container.clientTop + viewTop;
326
- let top = base + tokRect.height + ANCHOR_GAP_PX;
327
- if (top + height > viewBottom) {
328
- const above = base - height - ANCHOR_GAP_PX;
329
- if (above >= viewTop) top = above;
330
- }
331
- if (height <= container.clientHeight) {
332
- top = clamp(top, viewTop + 2, viewBottom - height - 2);
333
- }
334
 
335
  el.style.left = `${Math.round(left)}px`;
336
  el.style.top = `${Math.round(top)}px`;
 
51
  /** The gap between the tapped word and the card, and the inset from the column's edges. */
52
  const ANCHOR_GAP_PX = 6;
53
  const EDGE_INSET_PX = 8;
54
+ /** The card never grows past this, nor past the room beside the word (see position). */
55
  const CARD_MAX_HEIGHT_PX = 240;
56
+ /** Below this a card is not worth showing at all, so it is allowed to overflow instead. */
57
+ const MIN_CARD_HEIGHT_PX = 48;
58
 
59
  function clamp(value, low, high) {
60
  return Math.min(Math.max(value, low), high);
 
295
  * Offsets are in the container's PADDING-BOX coordinates - what `position: absolute`
296
  * inside `#transcript-text` resolves against - so `clientLeft` / `clientTop` (the border
297
  * widths) come off the viewport rects, and `scrollTop` converts the visible position into
298
+ * the scrolled content's. The card is clamped horizontally into the column and placed on
299
+ * whichever side of the word has more room, never growing past that room - it scrolls
300
+ * internally instead. Those two rules together are what make the published numbers true
301
+ * by construction rather than by luck: the card cannot leave the column
302
+ * (insideTranscript) and cannot cover the word that was tapped, which is what would
303
+ * otherwise make "tap the next word to re-anchor" impossible on a short phone column.
304
  */
305
  function position(el, tokEl) {
306
  el.style.left = '0px';
307
  el.style.top = '0px';
308
+ el.style.maxHeight = `${CARD_MAX_HEIGHT_PX}px`;
 
 
 
 
 
 
309
  const contRect = container.getBoundingClientRect();
310
  const tokRect = tokEl.getBoundingClientRect();
311
+
312
+ const viewTop = container.scrollTop;
313
+ const viewBottom = viewTop + container.clientHeight;
314
+ const tokTop = tokRect.top - contRect.top - container.clientTop + viewTop;
315
+ const tokBottom = tokTop + tokRect.height;
316
+
317
+ const roomBelow = viewBottom - (tokBottom + ANCHOR_GAP_PX) - 2;
318
+ const roomAbove = tokTop - ANCHOR_GAP_PX - viewTop - 2;
319
+ const below = roomBelow >= roomAbove;
320
+ const room = Math.max(MIN_CARD_HEIGHT_PX, below ? roomBelow : roomAbove);
321
+ el.style.maxHeight = `${Math.round(Math.min(CARD_MAX_HEIGHT_PX, room))}px`;
322
+
323
  const width = el.offsetWidth;
324
  const height = el.offsetHeight;
325
 
 
329
  EDGE_INSET_PX,
330
  maxLeft
331
  );
332
+ const top = clamp(
333
+ below ? tokBottom + ANCHOR_GAP_PX : tokTop - ANCHOR_GAP_PX - height,
334
+ viewTop,
335
+ Math.max(viewTop, viewBottom - height)
336
+ );
 
 
 
 
 
 
 
337
 
338
  el.style.left = `${Math.round(left)}px`;
339
  el.style.top = `${Math.round(top)}px`;
tests/e2e/test_avatar_loop.py CHANGED
@@ -1476,3 +1476,202 @@ def test_furigana_modes(page, space_url, warm_space, speech_events, wait_for_ava
1476
  _set_reading(page, "furigana-mode", "always")
1477
  _set_reading(page, "level-select", "N5")
1478
  assert _furigana(page)["mode"] == "always"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1476
  _set_reading(page, "furigana-mode", "always")
1477
  _set_reading(page, "level-select", "N5")
1478
  assert _furigana(page)["mode"] == "always"
1479
+
1480
+
1481
+ # ================================================================ word lookup (plan 02-08)
1482
+ #
1483
+ # JPN-03 on the Space: tap any word, see its reading, its meaning and its level - instantly,
1484
+ # because the glosses ride inside the token the line already holds (research § Q3). Two rows
1485
+ # for one feature, because the interesting failures differ: on the desktop page the question
1486
+ # is whether the CARD is right (the senses, the dictionary form, the badge, and zero
1487
+ # requests); on a phone it is whether the card is REACHABLE - anchored inside a 412 px
1488
+ # column, under real touch input rather than Playwright's mouse (research Pitfall 5).
1489
+
1490
+ # 勉強しています in 日本語を勉強しています。 - lemma 勉強する, N5, senses including "study".
1491
+ LOOKUP_STUDY_TOKEN = 2
1492
+ LOOKUP_LEARNER_TOKEN = 0 # 日本語 - the learner's own line is tappable too (D-03 / D-18)
1493
+ STUDY_VERB_READING = "べんきょうしています"
1494
+ STUDY_VERB_LEMMA = "勉強する"
1495
+
1496
+ # The long fixture, so the avatar's line wraps on a phone column and its last word sits low
1497
+ # enough that an unflipped card would hang out of the bottom.
1498
+ LONG_TEXT = "今日はいい天気ですから、公園を散歩してから、買い物に行きました。"
1499
+
1500
+ POPOVER_BOUND = "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.popover)"
1501
+ POPOVER = """
1502
+ async () => {
1503
+ const d = await window.Avatar.getDebug();
1504
+ return d.popover ? JSON.parse(JSON.stringify(d.popover)) : null;
1505
+ }
1506
+ """
1507
+ AVATAR_TOKENS = "() => document.querySelectorAll('#transcript-text .turn-avatar .tok').length >= 3"
1508
+ CONTAINER_RECT = """
1509
+ () => { const c = document.querySelector('#transcript-text');
1510
+ const r = c.getBoundingClientRect();
1511
+ return { left: r.left, top: r.top, width: r.width, height: r.height,
1512
+ clientWidth: c.clientWidth, clientHeight: c.clientHeight,
1513
+ scrollHeight: c.scrollHeight, scrollTop: c.scrollTop }; }
1514
+ """
1515
+ CARD_RECT = """
1516
+ () => { const el = document.querySelector('#lookup-popover');
1517
+ if (!el) return null;
1518
+ const r = el.getBoundingClientRect();
1519
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom,
1520
+ width: r.width, height: r.height }; }
1521
+ """
1522
+
1523
+
1524
+ def _popover(page) -> dict:
1525
+ return page.evaluate(POPOVER)
1526
+
1527
+
1528
+ def _box(locator) -> dict:
1529
+ """The element's box, scrolled into view first: on a phone the transcript is below the
1530
+ fold, and raw input at a coordinate outside the viewport lands on nothing."""
1531
+ locator.scroll_into_view_if_needed()
1532
+ box = locator.bounding_box()
1533
+ assert box and box["width"] > 0 and box["height"] > 0, f"nothing to tap: {box}"
1534
+ return box
1535
+
1536
+
1537
+ def _tap_mouse(page, locator) -> None:
1538
+ """Raw pointer input at the element's centre. NOT ``page.click``: the card opens on
1539
+ ``pointerup`` and dismisses on a capture-phase ``pointerdown``, so the real order of the
1540
+ two pointer events is part of what is under test."""
1541
+ box = _box(locator)
1542
+ page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
1543
+
1544
+
1545
+ def _tap_touch(page, locator) -> dict:
1546
+ """A real touch sequence on a touch-enabled context - what a learner's thumb does."""
1547
+ box = _box(locator)
1548
+ page.touchscreen.tap(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
1549
+ return box
1550
+
1551
+
1552
+ @pytest.mark.deployed
1553
+ def test_lookup_popover(
1554
+ page, space_url, warm_space, speech_events, request_counter, wait_for_avatar_ready
1555
+ ):
1556
+ """JPN-03 / D-05 / D-06 / D-07 / D-08: tapping a word opens a card carrying that word's
1557
+ reading, its JMdict senses, its dictionary form and its JLPT badge - with zero network
1558
+ requests, because the lookup is a pure client action on data the line already holds.
1559
+
1560
+ The badge is compared against the level in the page's OWN token record, never a literal
1561
+ here, for the same reason the furigana oracle exists: a wrong record and a wrong badge
1562
+ must not be able to agree.
1563
+ """
1564
+ speech_events.install(page)
1565
+ wait_for_avatar_ready(page, space_url)
1566
+ page.wait_for_function(POPOVER_BOUND, timeout=READY_TIMEOUT_MS)
1567
+ seeded = _popover(page)
1568
+ assert seeded["open"] is False and seeded["openCount"] == 0
1569
+
1570
+ turn = _study_turn(page, speech_events)
1571
+ tokens = page.evaluate(LAST_TOKENS)
1572
+ avatar_line = page.locator("#transcript-text .turn-avatar").last
1573
+ learner_line = page.locator("#transcript-text .turn-you").last
1574
+ card = page.locator("#lookup-popover")
1575
+
1576
+ with request_counter(page) as seen:
1577
+ _tap_mouse(page, avatar_line.locator(f".tok[data-token='{LOOKUP_STUDY_TOKEN}']"))
1578
+ opened = _popover(page)
1579
+ during = list(seen)
1580
+
1581
+ senses = card.locator(".lk-gloss li").all_text_contents()
1582
+ print(
1583
+ f"\n[deployed] {STUDY_TEXT} in {turn['turn_ms']} ms; tap 勉強しています -> {opened}\n"
1584
+ f"[deployed] card: reading {card.locator('.lk-reading').text_content()!r}, "
1585
+ f"badge {card.locator('.lk-level').text_content()!r}, "
1586
+ f"lemma {card.locator('.lk-lemma').text_content()!r}, senses {senses}\n"
1587
+ f"[deployed] requests during the tap: {during}; card rect {page.evaluate(CARD_RECT)}"
1588
+ )
1589
+ assert opened["open"] is True and opened["tokenIndex"] == LOOKUP_STUDY_TOKEN
1590
+ assert opened["lineId"] == avatar_line.get_attribute("data-line")
1591
+ assert during == [], (
1592
+ "the lookup issued network requests; the glosses are supposed to ride inside the "
1593
+ f"token the line already carries: {during}"
1594
+ )
1595
+ assert card.is_visible()
1596
+ assert len(senses) >= 1 and opened["glossCount"] == len(senses)
1597
+ assert "study" in " ".join(senses).lower(), senses
1598
+ assert card.locator(".lk-level").text_content() == tokens[LOOKUP_STUDY_TOKEN]["jlpt"]
1599
+ assert STUDY_VERB_LEMMA in card.locator(".lk-lemma").text_content()
1600
+ assert card.locator(".lk-reading").text_content() == STUDY_VERB_READING
1601
+
1602
+ # D-11: the particle and the full stop are plain text - not tappable, no button role.
1603
+ plains = page.locator("#transcript-text .turn-avatar .plain")
1604
+ assert plains.count() >= 2
1605
+ roles = [plains.nth(i).get_attribute("role") for i in range(plains.count())]
1606
+ assert roles == [None] * len(roles), f"a plain span claims to be a control: {roles}"
1607
+
1608
+ # D-03 / D-18 parity: the learner's own line is tappable too.
1609
+ _tap_mouse(page, learner_line.locator(f".tok[data-token='{LOOKUP_LEARNER_TOKEN}']"))
1610
+ mine = _popover(page)
1611
+ print(f"[deployed] tap the learner's own 日本語 -> {mine}")
1612
+ assert mine["open"] is True
1613
+ assert mine["lineId"] == learner_line.get_attribute("data-line")
1614
+ assert mine["tokenIndex"] == LOOKUP_LEARNER_TOKEN
1615
+ assert mine["openCount"] == 2, "a tap on another word re-anchors rather than re-opening"
1616
+
1617
+ page.keyboard.press("Escape")
1618
+ closed = _popover(page)
1619
+ assert closed["open"] is False and closed["closeCount"] >= 1
1620
+ assert card.is_visible() is False
1621
+
1622
+
1623
+ @pytest.mark.deployed
1624
+ def test_lookup_popover_mobile(
1625
+ browser, playwright, space_url, warm_space, speech_events, wait_for_avatar_ready
1626
+ ):
1627
+ """JPN-03 / D-06 on a phone: the same card, on a 412 px column, opened with real touch.
1628
+
1629
+ ``page.touchscreen.tap`` on the Pixel 7 descriptor, not ``page.click``: research
1630
+ Pitfall 5 is that Playwright's mouse helpers make a card look reachable where a real
1631
+ touch sequence would not dismiss or re-anchor it. ``insideTranscript`` is the phone
1632
+ assertion - a card that renders outside the column is invisible on a phone and passes
1633
+ every count-based check there is.
1634
+ """
1635
+ context = browser.new_context(**playwright.devices["Pixel 7"])
1636
+ page = context.new_page()
1637
+ try:
1638
+ speech_events.install(page)
1639
+ timings = wait_for_avatar_ready(page, space_url)
1640
+ page.wait_for_function(POPOVER_BOUND, timeout=READY_TIMEOUT_MS)
1641
+
1642
+ submitted = time.monotonic()
1643
+ _submit_text(page, LONG_TEXT)
1644
+ wait_for_ruby(
1645
+ page, AVATAR_TOKENS, LEARNER_RUBY_TIMEOUT_MS, "the avatar's line has no tappable words"
1646
+ )
1647
+ line_ms = round((time.monotonic() - submitted) * 1000)
1648
+ cont = page.evaluate(CONTAINER_RECT)
1649
+ print(
1650
+ f"\n[deployed/Pixel 7] ready {timings['ready_seconds']:.1f}s; the avatar's line "
1651
+ f"{line_ms} ms after Enter; transcript column {cont}"
1652
+ )
1653
+
1654
+ avatar_line = page.locator("#transcript-text .turn-avatar").last
1655
+ last_box = _tap_touch(page, avatar_line.locator(".tok").last)
1656
+ low = _popover(page)
1657
+ low_rect = page.evaluate(CARD_RECT)
1658
+ print(f"[deployed/Pixel 7] tap the LAST word -> {low}; card {low_rect}")
1659
+ assert low["open"] is True, f"a touch on {last_box} opened nothing"
1660
+ assert low["insideTranscript"] is True, (low, low_rect, page.evaluate(CONTAINER_RECT))
1661
+ assert low["width"] <= cont["clientWidth"], (low, cont)
1662
+
1663
+ _tap_touch(page, avatar_line.locator(".tok").first)
1664
+ high = _popover(page)
1665
+ high_rect = page.evaluate(CARD_RECT)
1666
+ print(f"[deployed/Pixel 7] tap the FIRST word -> {high}; card {high_rect}")
1667
+ assert high["open"] is True and high["openCount"] == 2
1668
+ assert high["insideTranscript"] is True, (high, high_rect, page.evaluate(CONTAINER_RECT))
1669
+ assert high["width"] <= cont["clientWidth"]
1670
+
1671
+ _tap_touch(page, page.locator("#intro"))
1672
+ closed = _popover(page)
1673
+ print(f"[deployed/Pixel 7] tap outside -> {closed}")
1674
+ assert closed["open"] is False
1675
+ assert page.locator("#lookup-popover").is_visible() is False
1676
+ finally:
1677
+ context.close()
tests/e2e/test_facade_parity.py CHANGED
@@ -789,3 +789,84 @@ def test_ruby_under_both_transports(transport, page, gradio_apps, speech_events)
789
  f"Gradio page (always {back['lastLineHeightPx']} px vs never "
790
  f"{never['lastLineHeightPx']} px)"
791
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
789
  f"Gradio page (always {back['lastLineHeightPx']} px vs never "
790
  f"{never['lastLineHeightPx']} px)"
791
  )
792
+
793
+
794
+ # ------------------------------------------------------- the lookup popover (plan 02-08)
795
+ #
796
+ # The standalone layer proved the card in a browser with no server. This row proves the
797
+ # HOST under both transports, and proves the claim the whole feature rests on: the lookup
798
+ # is a PURE CLIENT ACTION. The glosses ride inside the token the line already holds
799
+ # (research § Q3), so a request counter around the taps must record nothing at all - under
800
+ # the iframe transport too, where a naive design would have needed a new bridge call.
801
+
802
+ TANAKA_TEXT = "田中さんは東京に住んでいます。"
803
+ NAME_TOKEN = 0 # 田中さん - a name (D-12), and a word JMdict has no entry for
804
+ VERB_TOKEN = 4 # 住んでいます - one tap unit for the whole conjugation (D-05), lemma 住む
805
+
806
+ POPOVER_BOUND = "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.popover)"
807
+ POPOVER = """
808
+ async () => {
809
+ const d = await window.Avatar.getDebug();
810
+ return d.popover ? JSON.parse(JSON.stringify(d.popover)) : null;
811
+ }
812
+ """
813
+ AVATAR_TOKENS = "() => document.querySelectorAll('#transcript-text .turn-avatar .tok').length >= 3"
814
+
815
+
816
+ def _tap(page, locator) -> None:
817
+ """Raw pointer input at the element's centre. Not ``page.click``: the card opens on
818
+ ``pointerup`` and dismisses on a capture-phase ``pointerdown``, so the real order of
819
+ the two pointer events is part of what is being proven (research Pitfall 5)."""
820
+ box = locator.bounding_box()
821
+ assert box and box["width"] > 0 and box["height"] > 0, f"nothing to tap: {box}"
822
+ page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
823
+
824
+
825
+ @pytest.mark.parametrize("transport", TRANSPORTS)
826
+ def test_lookup_popover_under_both_transports(transport, page, gradio_apps, request_counter):
827
+ """JPN-03 / D-05 / D-06 / D-07 / D-12: tap a word, get its card - identically inline and
828
+ in an iframe, and without a single network request either way."""
829
+ url = gradio_apps(transport)
830
+ page.goto(url)
831
+ page.wait_for_function(AVATAR_READY, timeout=BOOT_TIMEOUT_MS)
832
+ page.wait_for_function(FIRST_FRAME, timeout=FIRST_FRAME_TIMEOUT_MS)
833
+ page.wait_for_function(POPOVER_BOUND, timeout=BOOT_TIMEOUT_MS)
834
+ seeded = page.evaluate(POPOVER)
835
+ assert seeded["open"] is False and seeded["openCount"] == 0
836
+
837
+ page.fill("#text-input input", TANAKA_TEXT)
838
+ page.press("#text-input input", "Enter")
839
+ wait_for_ruby(page, AVATAR_TOKENS, TURN_TIMEOUT_MS, "the avatar's line has no tappable words")
840
+ _wait_settled(page)
841
+
842
+ line = page.locator("#transcript-text .turn-avatar").last
843
+ with request_counter(page) as seen:
844
+ _tap(page, line.locator(f".tok[data-token='{NAME_TOKEN}']"))
845
+ name = page.evaluate(POPOVER)
846
+ _tap(page, line.locator(f".tok[data-token='{VERB_TOKEN}']"))
847
+ verb = page.evaluate(POPOVER)
848
+ during = list(seen)
849
+
850
+ card = page.locator("#lookup-popover")
851
+ print(
852
+ f"[{transport}] tap 田中さん -> {name}\n[{transport}] tap 住んでいます -> {verb}\n"
853
+ f"[{transport}] requests during both taps: {during}"
854
+ )
855
+ assert name["open"] is True and name["tokenIndex"] == NAME_TOKEN
856
+ assert name["surface"] == "田中さん" and name["level"] == "name"
857
+ assert name["glossCount"] == 0
858
+ assert verb["open"] is True and verb["tokenIndex"] == VERB_TOKEN
859
+ assert verb["level"] == "N5"
860
+ assert verb["glossCount"] >= 1
861
+ assert verb["openCount"] == 2, "a tap on another word re-anchors rather than re-opening"
862
+ assert verb["closeCount"] == 0, "re-anchoring must not pass through a closed state"
863
+ assert "住む" in card.locator(".lk-lemma").text_content()
864
+ assert during == [], (
865
+ f"{transport}: the lookup issued {len(during)} request(s) - the glosses are supposed "
866
+ f"to ride inside the token the line already holds: {during}"
867
+ )
868
+
869
+ _tap(page, page.locator("#intro"))
870
+ closed = page.evaluate(POPOVER)
871
+ assert closed["open"] is False and closed["closeCount"] == 1
872
+ assert card.is_visible() is False