tests/test_exec_bubble_overlap_browser.py
Rama master · réplica de lectura del repositorio original.
"""The exec bubble must not sit on top of the open panel (WebKit / playwright).
The bubble paints at --z-max and the panel at --z-bubble, so wherever the
bubble rests it is ABOVE the panel and swallows every tap inside its 50px
circle -- and the tap it swallows calls togglePanel(), which CLOSES the panel.
At phone width the panel is full-screen and the bubble's resting corner (right
14px, bottom --nav-h + 10) lands on the tail of the last choice row and on the
composer's right end (the mute and [x] buttons). So answering a nudge by
tapping a button near the right edge minimised the panel instead of sending
the answer -- reported 2026-09-18.
Measured on a 430x932 screen before the fix: bubble 366..416 x 807..857, the
choice row 12..418 x 808..841.
Marked `browser` so the fast smoke step skips it.
.venv/bin/pytest tests/test_exec_bubble_overlap_browser.py -q
"""
import json
import pytest
pytest.importorskip("playwright.sync_api")
pytestmark = pytest.mark.browser
# A realistic three-answer nudge: with the client-appended archive/exile the row
# runs to the right edge of a 430px screen, which is where the bubble rests.
_HISTORY = {
"messages": [
{
"role": "monitor",
"ts": "2026-09-18T14:00:11+00:00",
"card_id": "card-climbing",
"text": "Shoes and chalk in the bag. Packed yet?\n\n"
"[Sent it | Not yet | Doing it now]",
},
],
"monitorTotal": 0,
}
@pytest.fixture
def phone(browser, base_url, admin_headers):
"""iPhone-sized /rd with a nudge in the transcript, boundaries mocked."""
ctx = browser.new_context(extra_http_headers=admin_headers,
viewport={"width": 430, "height": 932},
has_touch=True, is_mobile=True)
pg = ctx.new_page()
pg.route("**/vendor/marked-*.min.js",
lambda r: r.fulfill(status=200,
content_type="application/javascript",
body="window.marked={use(){},parse:s=>s};"))
pg.route("**/hosaka-audio.js*",
lambda r: r.fulfill(status=200,
content_type="application/javascript",
body="window.HosakaAudio={createPlayer:()=>({"
"unlock(){},speak(){return Promise.resolve()},"
"flush(){},setVolume(){},elapsed:()=>0,"
"audioDuration:()=>0,isUnlocked:()=>true,"
"gestureUnlocked:()=>true})};"))
def _chat(route):
if route.request.method == "POST":
pg.evaluate("b => window.__sent.push(JSON.parse(b))",
route.request.post_data)
route.fulfill(status=200, content_type="text/event-stream",
body='data: {"type":"done"}\n\n')
else:
route.fulfill(status=200, content_type="application/json",
body=json.dumps(_HISTORY))
pg.route("**/api/cc/exec-history", _chat)
pg.route("**/api/cc/query", _chat)
pg.goto(f"{base_url}/rd", wait_until="domcontentloaded")
pg.evaluate("() => { window.__sent = []; }")
pg.wait_for_selector("#exec-bubble", timeout=5000)
yield pg
ctx.close()
def _bubble_box(pg):
return pg.eval_on_selector("#exec-bubble", """e => {
const r = e.getBoundingClientRect();
return {x: r.left, y: r.top, w: r.width, h: r.height};
}""")
def _open(pg):
"""Tap the bubble open and wait for the slide-in to settle."""
pg.tap("#exec-bubble")
pg.wait_for_selector("#exec-panel.open", timeout=4000)
pg.wait_for_selector(".exec-choice-row", timeout=4000)
pg.wait_for_function(
"() => document.getElementById('exec-panel').getBoundingClientRect().left === 0",
timeout=4000)
def test_bubble_is_not_over_the_open_panel(phone):
"""Nothing of the bubble intersects the panel while it is open."""
_open(phone)
assert phone.evaluate("""() => {
const b = document.getElementById('exec-bubble');
if (!b.offsetParent && getComputedStyle(b).display === 'none') return true;
const r = b.getBoundingClientRect();
const p = document.getElementById('exec-panel').getBoundingClientRect();
return r.right <= p.left || r.left >= p.right
|| r.bottom <= p.top || r.top >= p.bottom;
}""" ), "the bubble covers part of the open panel"
def test_tapping_the_last_answer_sends_it(phone):
"""The row's last button is where the bubble used to rest."""
_open(phone)
phone.locator("#exec-term .exec-choice-row .exec-choice").nth(2).tap()
phone.wait_for_function("() => (window.__sent || []).length >= 1", timeout=4000)
sent = phone.evaluate("() => window.__sent")[0]["prompt"]
assert sent.endswith("Doing it now")
assert phone.eval_on_selector("#exec-panel", "e => e.classList.contains('open')"), \
"tapping an answer closed the panel"
def test_every_choice_button_is_tappable(phone):
"""No answer or card action is hit-blocked by anything painted over it."""
_open(phone)
assert phone.evaluate("""() => {
const out = [];
document.querySelectorAll('#exec-term .exec-choice').forEach(el => {
const r = el.getBoundingClientRect();
for (const x of [r.left + 2, (r.left + r.right) / 2, r.right - 2]) {
const top = document.elementFromPoint(x, (r.top + r.bottom) / 2);
if (top !== el && !el.contains(top)) {
out.push(el.textContent.trim() + ' <- ' +
(top ? (top.id || top.className || top.tagName) : 'nothing'));
break;
}
}
});
return out;
}""") == []