Spaces:
Running on Zero
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 +6 -3
- avatar/transcript.js +27 -24
- tests/e2e/test_avatar_loop.py +199 -0
- tests/e2e/test_facade_parity.py +81 -0
|
@@ -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 |
-
|
| 22 |
-
|
| 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;
|
|
@@ -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
|
| 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
|
| 297 |
-
*
|
| 298 |
-
*
|
| 299 |
-
*
|
|
|
|
|
|
|
| 300 |
*/
|
| 301 |
function position(el, tokEl) {
|
| 302 |
el.style.left = '0px';
|
| 303 |
el.style.top = '0px';
|
| 304 |
-
|
| 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 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 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`;
|
|
@@ -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()
|
|
@@ -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
|