Ctrl K
ComunidadGuías…

tests/test_exec_bubble_overlap_browser.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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;
    }""") == []