Ctrl K
ComunidadGuías…

tests/test_exec_choices_browser.py

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

Archivo original ↗Preguntar a KiBot
"""Exec nudge choice-row behaviour (WebKit / playwright).

EVERY open question stays tappable — answer buttons and card actions both —
and a tapped answer carries a deterministic reference to the question it
answers, so the model never has to guess which one a bare "Not yet" belongs to.

This is the regression. `clear()` used to wipe every row but the newest, so a
day that fired two nudges ended with exactly one tappable row — under the NEWER
card. On 2026-09-16 the tap meant for the 14:00 climbing nudge PATCHed
`craft-lyre-poster` to archives instead, and climbing had to be archived by
hand from /hq 22 seconds later.

Boundaries mocked, no LLM and no writes to the real board: /api/cc/exec-history serves a
canned two-nudge history, PATCH /api/rd is captured rather than applied, and
POST /api/cc/query is captured so a tapped answer's outgoing text can be read.

Marked `browser` so the fast smoke step skips it.

    .venv/bin/pytest tests/test_exec_choices_browser.py -q
"""
import json

import pytest

pytest.importorskip("playwright.sync_api")

pytestmark = pytest.mark.browser


# Two nudges, oldest first — exactly the shape history replay walks. Each ends
# on an answer row and carries its own card id.
_HISTORY = {
    "messages": [
        {
            "role": "monitor",
            "ts": "2026-09-16T14:00:11+00:00",
            "card_id": "card-climbing",
            "text": "Shoes and chalk in the bag. Packed yet?\n\n[Packed | Not yet]",
        },
        {
            "role": "monitor",
            "ts": "2026-09-16T17:57:12+00:00",
            "card_id": "card-poster",
            "text": "Collect the Lyre poster. Retrieved?\n\n[Got it | Not yet]",
        },
    ],
    "monitorTotal": 0,
}


# An ordinary chat reply — no nudge, no card_id in any payload. The card it is
# about is named in the row itself (`card=`), which is the only thing that can
# tell the panel which card `archive` would archive.
_CHAT_HISTORY = {
    "messages": [
        {"role": "user", "ts": "2026-09-19T10:00:00+00:00", "text": "poster?"},
        {
            "role": "assistant",
            "ts": "2026-09-19T10:00:04+00:00",
            "text": "It has been sitting there since Tuesday. Picked up?"
                       "\n\n[card=card-poster | Got it | Not yet]",
        },
    ],
    "monitorTotal": 0,
}


@pytest.fixture
def open_panel(browser, base_url, admin_headers):
    """Open /rd with the chat history canned and PATCH /api/rd captured."""
    contexts = []

    def _open(history=_HISTORY):
        ctx = browser.new_context(extra_http_headers=admin_headers)
        contexts.append(ctx)
        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})};"))
        # GET replays the canned history; POST (a sent answer) is captured and
        # answered with an empty SSE stream so the panel settles.
        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)

        # Capture the card-action write instead of moving a real card.
        def _patch_rd(route):
            if route.request.method == "PATCH":
                pg.evaluate("b => window.__patched.push(JSON.parse(b))",
                            route.request.post_data)
                route.fulfill(status=200, content_type="application/json", body="{}")
            else:
                route.continue_()

        pg.goto(f"{base_url}/rd", wait_until="domcontentloaded")
        pg.evaluate("() => { window.__patched = []; window.__sent = []; }")
        pg.route("**/api/rd?source=Exec", _patch_rd)
        pg.wait_for_selector("#exec-bubble", timeout=5000)
        pg.click("#exec-bubble")
        pg.wait_for_selector("#exec-panel.open", timeout=4000)
        pg.wait_for_selector(".exec-choice-row", timeout=4000)
        return pg

    yield _open
    for c in contexts:
        try:
            c.close()
        except Exception:
            pass


def _rows(pg):
    """[[button label, ...], ...] per choice row, oldest row first."""
    return pg.evaluate("""() => Array.from(
        document.querySelectorAll('#exec-term .exec-choice-row'),
        r => Array.from(r.querySelectorAll('.exec-choice'), b => b.textContent))""")


def test_every_open_question_stays_tappable(open_panel):
    """Both nudges keep their own answers AND their own card actions."""
    rows = _rows(open_panel())
    assert rows == [
        ["Packed", "Not yet", "exile", "archive"],
        ["Got it", "Not yet", "exile", "archive"],
    ]


def test_answer_carries_a_reference_to_its_question(open_panel):
    """A tapped answer names the question and the card it belongs to.

    Both nudges offer a "Not yet" — the whole point of the reference is that
    the two are distinguishable.
    """
    pg = open_panel()
    pg.locator("#exec-term .exec-choice-row").nth(0).get_by_text("Not yet").click()
    pg.wait_for_function("() => (window.__sent || []).length >= 1", timeout=4000)
    text = pg.evaluate("() => window.__sent")[0]["prompt"]
    assert '[answering: "Packed yet?" card=card-climbing]' in text
    assert text.endswith("Not yet")
    # Answering retires that question's answers; its card actions and the OTHER
    # question both stay live.
    assert _rows(pg) == [["exile", "archive"], ["Got it", "Not yet", "exile", "archive"]]


def test_older_done_patches_its_own_card(open_panel):
    """Tapping the OLDER row's `archive` archives THAT card, not the newer one."""
    pg = open_panel()
    # .nth(0), not :first-of-type — the rows are <div> siblings of the message
    # <div>s, so "first of type" is the first message, not the first row.
    pg.locator("#exec-term .exec-choice-row .exec-act-archive").nth(0).click()
    pg.wait_for_function("() => (window.__patched || []).length >= 1", timeout=4000)
    assert pg.evaluate("() => window.__patched")[0] == {
        "cards": [{"id": "card-climbing", "column": "archives"}]
    }
    # The tapped row is gone; the newest nudge is untouched.
    assert _rows(pg) == [["Got it", "Not yet", "exile", "archive"]]


def test_chat_reply_naming_a_card_gets_card_actions(open_panel):
    """An ordinary reply carries no card_id — the `card=` cell supplies it.

    The cell itself is never a button and never prose: the whole row is stripped
    before the message renders, so the id stays out of Wai's sight.
    """
    pg = open_panel(_CHAT_HISTORY)
    assert _rows(pg) == [["Got it", "Not yet", "exile", "archive"]]
    body = pg.text_content("#exec-term .msg.assistant .msg-body")
    assert "card-poster" not in body and "[" not in body

    pg.locator("#exec-term .exec-choice-row .exec-act-archive").click()
    pg.wait_for_function("() => (window.__patched || []).length >= 1", timeout=4000)
    assert pg.evaluate("() => window.__patched")[0] == {
        "cards": [{"id": "card-poster", "column": "archives"}]
    }
    assert _rows(pg) == []