phase: 132_image_upload_feedback
All verification complete. Final report: **Phase 132 (image upload feedback) — final verification pass: 100% green** - **Verified** all 3 tasks' implementations in the working tree: `index.html` status part (hidden-by-default, arc + "Uploading image…", pinned order), `app.js` `enterUploadState`/`exitUploadState` (entered after `attachUpload` guard pre-await, exited on BOTH resolution paths, `setUiState` untouched, idle "Send" restore byte-identical), `styles.css` `.is-uploading` rules (house `spin` keyframes, reduced-motion block, existing `:disabled` Send language), unit pins, E2E suite with route-delayed upload window. - **Completion criteria** — (1) mid-flight spinner + "Uploading image…", remove + Send disabled with "Uploading…": ✅ E2E-asserted; (2) second Send press = no second upload (request count exactly 1): ✅; (3) success clears + turn proceeds (bubble carries image); failure clears + banner + text/attachment remain: ✅; (4) `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`: ✅ 3 passed (18.5 s, in isolation); (5) no behavior change in completed phases: ✅ (full suite green, A5 path-never-bytes re-pinned in E2E). - **Full suite**: `uv run pytest --cov=app --cov-report=term-missing` → **3108 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check .` → clean; `uv run pyright` → 0 errors. - **Documentation**: README.md fully reviewed (setup, pages, all `.env.example` vars + every default vs `app/config.py`, tools incl. phase-131 `read(offset,limit)`, image/PDF/sync/deploy sections) and AGENTS.md fully reviewed (rules, quick-reference commands, gitignore/gpg claims) — **verified consistent; no changes needed**. Phase-132 README bullet already in the working tree; CSS `:root` contrast-note corrections (15.3:1 / 8.2:1) re-verified mathematically correct (comments only). - **Decisions**: none — all task work was already implemented and correct; no fixes required. - **Next pending phase**: `133_pdf_screenshot_fallback`.
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
"""Phase 132 task 03 E2E (Playwright, mock-only): the chat image upload
|
||||
shows visible "uploading" feedback (TODO L8).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the settled-turn
|
||||
pins key on the mock's byte-stable grounded answer
|
||||
(``Deterministic mock answer for E2E``), which the live model will not
|
||||
produce. The in-flight mechanics themselves are LLM-independent.
|
||||
|
||||
THE UPLOAD WINDOW (the route-delay rationale): in production
|
||||
``POST /api/chat-images`` resolves in ~50 ms — the in-flight window is
|
||||
unsamplable. ``page.route`` intercepts the upload and the handler
|
||||
DELAYS (``asyncio.sleep``) before ``route.continue_()``: the server's
|
||||
REAL handler runs, the response is just late (the upload is genuine —
|
||||
the file lands, the path is returned, the turn proceeds). The window is
|
||||
therefore ≥ ``HOLD_SECONDS`` (3.0 s) and the in-flight state — set by
|
||||
``enterUploadState`` synchronously with the fetch, cleared by
|
||||
``exitUploadState`` on resolution — is directly observable.
|
||||
|
||||
WHY THE ASYNC API (not the house sync pattern): a sync-API route
|
||||
handler runs in a greenlet on the TEST thread — its ``time.sleep``
|
||||
blocks the thread for the whole hold, so no ``expect()`` can poll the
|
||||
DOM during the window (only the post-release residual state is visible
|
||||
— a race against the server's response time). The async API runs the
|
||||
handler as an independent event-loop task: the sleep suspends ONLY the
|
||||
handler, the test coroutine keeps polling through the driver while the
|
||||
upload is genuinely in flight. The in-flight assertions are therefore
|
||||
waits on a directly-set DOM state, not a race.
|
||||
|
||||
Context: SIGNED IN via the house form login (admin — the same context
|
||||
the phase-123 image suite drives; the async twin of
|
||||
``e2e.auth_helpers.login``, no cookie surgery). The paperclip's reveal
|
||||
gate is the ``/api/config`` ``images: true`` flag, not identity — but
|
||||
the shell's anonymous boot shows the phase-79 token gate (locked +
|
||||
inert), so a signed-in context is the house chat context. The
|
||||
suite's module app boots with ``BOR_IMAGES=true`` pinned EXPLICITLY
|
||||
(process env ranks above an operator's local gitignored ``.env`` — the
|
||||
leak-guard convention) and a suite-private ``BOR_CHAT_IMAGE_DIR``.
|
||||
|
||||
KB fixture: ONE local source (``tmp_path_factory`` dir, registered
|
||||
through the authenticated API + the real in-process ``POST /api/sync``
|
||||
— the ``test_chat_answer_clusters.py`` pattern) holding ONE text doc
|
||||
whose body shares the question's tokens (token-overlap cosine ~0.46–0.67
|
||||
vs the mock-calibrated 0.30 gate): the turn after a successful upload
|
||||
completes GROUNDED, exactly as pre-phase. The image fixture is the
|
||||
house 1×1 transparent PNG (inline constant — the pipeline is
|
||||
content-agnostic; the well-formed bytes keep the upload + render pins
|
||||
honest).
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_upload_window_shows_uploading_and_blocks_resend`` — attach →
|
||||
type → send, the upload route-delayed: MID-FLIGHT (asserted by
|
||||
waiting on the class while the ≥3 s window is open) the strip has
|
||||
``is-uploading`` + the visible ``.attach-preview-status`` reading
|
||||
"Uploading image…" (the thumbnail stays visible — the user watches
|
||||
THEIR image moving), ``#attach-remove`` is disabled, ``#send-btn``
|
||||
is disabled with the "Uploading…" label; the owner's exact spam —
|
||||
two more presses on the disabled button (the browser suppresses the
|
||||
DOM event on a disabled control: genuinely dead presses) plus an
|
||||
Enter-to-submit (the form's ``sendBtn.disabled`` guard) — then, past
|
||||
the window: EXACTLY ONE upload request reached the interceptor
|
||||
(no double upload) and EXACTLY ONE user + ONE brain bubble (no
|
||||
second turn), the state cleared, the turn settled.
|
||||
2. ``test_upload_success_clears_and_turn_proceeds`` — the same flow,
|
||||
past the window: the state is cleared (no ``is-uploading``, the
|
||||
status hidden, the remove re-enabled, the label back to "Send"), the
|
||||
strip HIDEs with the send (the phase-123 contract), the user bubble
|
||||
carries the attached image (the live data URL, alt = the filename),
|
||||
the brain bubble is the mock's GROUNDED answer (not a deflection),
|
||||
and the localStorage record carries the stored PATH — never base64
|
||||
(A5).
|
||||
3. ``test_upload_failure_clears_state_and_keeps_the_draft`` — a route
|
||||
that DELAYS then ABORTS the upload (the network-drop path —
|
||||
``fetch`` rejects, ``uploadAttachedImage``'s catch): mid-flight the
|
||||
same uploading state; after resolution the state is CLEARED (Send
|
||||
re-enabled with "Send"), the OUT-OF-TURN banner shows the
|
||||
network-drop copy ("Couldn't attach the image — try again."), the
|
||||
input text is INTACT, the strip is back in its NORMAL preview
|
||||
(the attachment remains for the retry — visible, no
|
||||
``is-uploading``), and NO turn ran (no bubbles at all).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.async_api import Page, async_playwright, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Per-module app port (the conftest session app owns its port in a
|
||||
# combined run — the module app binds its own; env-overridable).
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_UPLOADFB", "8180"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
#: The upload hold (the task's ≥2 s window; 3.0 s keeps the in-flight
|
||||
#: assertions + the spam comfortably inside the window even under slow
|
||||
#: driver polling — the hold is the ONLY thing between the press and
|
||||
#: the resolution, so the window is hold + intercept latency).
|
||||
HOLD_SECONDS = 3.0
|
||||
SETTLE_TIMEOUT_MS = 30_000
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The fixtures' constants
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The house 1×1 transparent PNG (the inline constant of the image E2E
|
||||
#: suites — no new binary committed).
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
PNG_NAME = "diagram.png"
|
||||
|
||||
SOURCE_NAME = "e2e-uploadfb" # the directory basename = the source name
|
||||
NOTE_PATH = "note.md"
|
||||
|
||||
#: The one text doc — its body carries the question's tokens, so the
|
||||
#: mock's token-overlap cosine against the (single) chunk clears the
|
||||
#: mock-calibrated 0.30 gate deterministically (~0.46–0.67 measured):
|
||||
#: the post-upload turn GROUNDS (HIGH), the mock's default grounded
|
||||
#: answer streams, and the turn completes exactly as pre-phase.
|
||||
NOTE_CONTENT = (
|
||||
"# Homelab Router Note\n"
|
||||
"\n"
|
||||
"The homelab router note for the e2e upload-feedback suite: the core\n"
|
||||
"router runs the firewall rules and the VLAN interfaces, and the\n"
|
||||
"uplink carries the whole homelab network.\n"
|
||||
)
|
||||
|
||||
QUESTION = "What does my homelab note say about the core router?"
|
||||
#: The phase-50 auto-title (first question, whitespace-collapsed, 120-
|
||||
#: char cap) of the two settled turns — the shared e2e Postgres keeps
|
||||
#: ``saved_chats`` across runs, so the module boundary owns this
|
||||
#: suite's rows by title (the test_chat_answer_clusters cleanup).
|
||||
SUITE_TITLE = " ".join(QUESTION.split())[:120]
|
||||
MOCK_MARKER = "Deterministic mock answer for E2E"
|
||||
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||
IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
|
||||
#: The module counter the task's double-send assertion keys on — every
|
||||
#: intercepted upload request (method + path checked in the handler).
|
||||
UPLOAD_REQUESTS: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module app: images ON, suite-private image store, the one-doc local
|
||||
# source as the sole registered source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(mock_llm: int, scratch: Path) -> dict[str, str]:
|
||||
"""The conftest app env (leak guards included) with the phase-123
|
||||
image knob pinned EXPLICITLY (the ``/api/config`` ``images: true``
|
||||
gate reveals ``#attach-btn``) and the question-image store in the
|
||||
suite's scratch dir (the app under test never writes into the
|
||||
owner's real ``~/bor-sources``)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated gate (conftest pattern) — the one-doc KB grounds
|
||||
# the question on these values (see NOTE_CONTENT).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
|
||||
# reads it from cwd) — override it with an EMPTY value: the registry
|
||||
# must hold EXACTLY this suite's local directory.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Leak guards (conftest pattern): docs-push inert; UI strings at the
|
||||
# code defaults.
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
# The story's knob + the suite-private stores.
|
||||
env["BOR_IMAGES"] = "true"
|
||||
env["BOR_ANSWER_IMAGES"] = "false" # the mock never show_images anyway
|
||||
env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
|
||||
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_IMAGE_DIR"] = str(scratch / "images")
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kb_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The one-source fixture tree (see the module docstring): the app
|
||||
runs on the same host, so the path is visible to it."""
|
||||
scratch = tmp_path_factory.mktemp("bor_uploadfb")
|
||||
src = scratch / SOURCE_NAME
|
||||
src.mkdir()
|
||||
(src / NOTE_PATH).write_text(NOTE_CONTENT, encoding="utf-8")
|
||||
return src
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, kb_dir: Path) -> Iterator[str]:
|
||||
"""The real app under test — per-module app (the conftest pattern;
|
||||
the session app is never started in this isolated run, so no port
|
||||
clash)."""
|
||||
scratch = kb_dir.parent
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB + admin-HTTP helpers (the house patterns)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB (the E2E isolation pattern): the suites share
|
||||
one Postgres, and a leftover row would corrupt the one-doc premise.
|
||||
``saved_chats`` is never truncated (the house pattern) — the two
|
||||
settled turns auto-save rows under this suite's title, and
|
||||
:func:`_cleanup_saved_rows` owns them."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, sources_meta, folder_summaries"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _cleanup_saved_rows() -> None:
|
||||
"""Delete THIS suite's auto-saved rows by title (best-effort: a
|
||||
failed delete — DB unreachable, the skip case — never masks the
|
||||
skip; re-runnable on the shared DB)."""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("DELETE FROM saved_chats WHERE title = :title"),
|
||||
{"title": SUITE_TITLE},
|
||||
)
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _admin_client(app_url: str) -> Iterator[httpx.Client]:
|
||||
"""An httpx client holding a fresh admin session (the form login's
|
||||
API twin — the house httpx pattern of test_chat_answer_clusters)."""
|
||||
client = httpx.Client(base_url=app_url, timeout=30.0)
|
||||
try:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
|
||||
"""Poll the (cookie-authenticated) status endpoint until the run
|
||||
reaches a terminal state."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
return body
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def seeded_kb(app_url: str, kb_dir: Path, db_ready: None) -> Iterator[None]:
|
||||
"""The story's precondition per test: a FRESH registry holding ONLY
|
||||
this suite's local source, synced by the REAL in-process
|
||||
``POST /api/sync`` pipeline (walk → chunk → embed → overview →
|
||||
version bump) under the deterministic mock — exactly ONE document.
|
||||
(Re-seeded per test — the shared e2e Postgres makes leftovers
|
||||
possible, and the one-doc premise is load-bearing for the gate.)"""
|
||||
_cleanup_saved_rows() # a previous run's rows would be stale
|
||||
_truncate_all()
|
||||
with _admin_client(app_url) as client:
|
||||
r = client.post("/api/git-sources", json={"kind": "local", "path": str(kb_dir)})
|
||||
assert r.status_code == 201, r.text
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 202, r.text
|
||||
body = _wait_sync_done_http(client)
|
||||
assert body["state"] == "success", body
|
||||
assert body["detail"]["added"] == 1, body
|
||||
yield
|
||||
_truncate_all()
|
||||
_cleanup_saved_rows()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser helpers (async API — see the header's WHY THE ASYNC API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _png_file(tmp_path: Path) -> Path:
|
||||
png = tmp_path / PNG_NAME
|
||||
png.write_bytes(PNG_1X1)
|
||||
return png
|
||||
|
||||
|
||||
async def _admin_login(page: Page, app_url: str) -> None:
|
||||
"""The house form login (the async twin of ``e2e.auth_helpers.
|
||||
login``): the REAL form on /login.html — fill → submit → redirect.
|
||||
The phase-79 gate locks the anonymous shell (inert + the token
|
||||
gate is the only interactive surface), so the suite drives the same
|
||||
signed-in context the other chat suites do."""
|
||||
await page.goto(f"{app_url}/login.html?next=/")
|
||||
await expect(page.locator("#login-password")).to_be_visible()
|
||||
await page.fill("#login-password", ADMIN_PASSWORD)
|
||||
await page.click("#login-form button[type=submit]")
|
||||
await expect(page).to_have_url(app_url + "/", timeout=SETTLE_TIMEOUT_MS)
|
||||
|
||||
|
||||
def _upload_route_handler(fail: bool) -> Any:
|
||||
"""The route-delayed upload (or the delayed ABORT — the failure
|
||||
test's network drop). Only the POST upload is held; anything else
|
||||
(the bytes route's GETs) passes straight through. The request is
|
||||
counted the moment it is intercepted — the double-send pin."""
|
||||
|
||||
async def handle(route: Any) -> None:
|
||||
if (
|
||||
route.request.method == "POST"
|
||||
and urlsplit(route.request.url).path == "/api/chat-images"
|
||||
):
|
||||
UPLOAD_REQUESTS.append(route.request.url)
|
||||
await asyncio.sleep(HOLD_SECONDS)
|
||||
if fail:
|
||||
await route.abort("failed")
|
||||
return
|
||||
await route.continue_()
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
async def _attach_via_button(page: Page, png: Path) -> None:
|
||||
"""The real user path: click the paperclip (revealed by the
|
||||
``images: true`` config gate) → the file chooser (the button's
|
||||
programmatic input click) → ``set_files``. The preview strip must
|
||||
reveal with the data-URL thumbnail + the filename (phase 123)."""
|
||||
btn = page.locator("#attach-btn")
|
||||
await expect(btn).to_be_visible()
|
||||
await expect(btn).to_have_attribute("aria-label", "Attach an image")
|
||||
async with page.expect_file_chooser() as fc_info:
|
||||
await btn.click()
|
||||
await (await fc_info.value).set_files(str(png))
|
||||
await expect(page.locator("#attach-preview")).to_be_visible()
|
||||
await expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||
src = await page.locator("#attach-preview img").get_attribute("src")
|
||||
assert (src or "").startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
|
||||
|
||||
|
||||
async def _assert_uploading(page: Page) -> None:
|
||||
"""The in-flight state (phase 132 task 01) — every assertion is a
|
||||
WAIT on the directly-set DOM state (the UI is no-poll: the state is
|
||||
set synchronously with the fetch), so while the window is open each
|
||||
wait settles on the first tick inside it."""
|
||||
await expect(page.locator("#attach-preview")).to_have_class(
|
||||
re.compile(r"\bis-uploading\b")
|
||||
)
|
||||
# The thumbnail stays visible — the user watches THEIR image moving.
|
||||
await expect(page.locator("#attach-preview img")).to_be_visible()
|
||||
await expect(page.locator(".attach-preview-status")).to_be_visible()
|
||||
await expect(page.locator(".attach-preview-status-text")).to_have_text("Uploading image…")
|
||||
await expect(page.locator("#attach-remove")).to_be_disabled()
|
||||
await expect(page.locator("#send-btn")).to_be_disabled()
|
||||
await expect(page.locator("#send-label")).to_have_text("Uploading…")
|
||||
|
||||
|
||||
async def _send_with_held_upload(
|
||||
page: Page, app_url: str, png: Path, *, fail: bool = False, spam: bool = False
|
||||
) -> None:
|
||||
"""Chat view (house form login) → attach → type → (hold the upload
|
||||
route) → send → assert the in-flight state → optionally the owner's
|
||||
exact spam (mid-flight: two presses on the disabled button + an
|
||||
Enter-to-submit)."""
|
||||
UPLOAD_REQUESTS.clear()
|
||||
await _admin_login(page, app_url)
|
||||
await _attach_via_button(page, png)
|
||||
await page.fill("#message-input", QUESTION)
|
||||
await page.route("**/api/chat-images", _upload_route_handler(fail))
|
||||
await page.click("#send-btn")
|
||||
await _assert_uploading(page)
|
||||
if spam:
|
||||
# The owner's exact spam while the window is open: two more
|
||||
# presses on the disabled button (force=True dispatches the
|
||||
# mouse events; the BROWSER suppresses the DOM event on a
|
||||
# disabled control — genuinely dead presses, like a user's)
|
||||
# and an Enter-to-submit (the form's sendBtn.disabled guard).
|
||||
await page.click("#send-btn", force=True)
|
||||
await page.click("#send-btn", force=True)
|
||||
await page.locator("#message-input").press("Enter")
|
||||
|
||||
|
||||
async def _wait_grounded_settle(page: Page) -> None:
|
||||
"""Past the window: the upload resolved, the state CLEARED, and the
|
||||
turn settled GROUNDED on the one-doc KB (the mock's byte-stable
|
||||
answer — not a deflection), the button back to the idle "Send"."""
|
||||
brain = page.locator(".msg.brain .bubble").first
|
||||
await expect(brain).to_contain_text(MOCK_MARKER, timeout=SETTLE_TIMEOUT_MS)
|
||||
await expect(brain).not_to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
|
||||
await expect(page.locator("#send-btn")).to_be_enabled()
|
||||
await expect(page.locator("#send-label")).to_have_text("Send")
|
||||
# The uploading state is gone at every writer's exit point…
|
||||
strip = page.locator("#attach-preview")
|
||||
assert "is-uploading" not in (await strip.get_attribute("class") or "")
|
||||
await expect(page.locator(".attach-preview-status")).to_be_hidden()
|
||||
await expect(page.locator("#attach-remove")).to_be_enabled()
|
||||
# …and the strip must not linger into the turn (the phase-123
|
||||
# contract — the preview clears when the question goes out).
|
||||
await expect(strip).to_be_hidden()
|
||||
# A successful turn shows no out-of-turn banner.
|
||||
await expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The window: uploading state visible, the spam is visibly AND
|
||||
# functionally dead (exactly ONE upload, ONE turn)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _scenario_window_blocks_resend(app_url: str, tmp_path: Path) -> None:
|
||||
png = _png_file(tmp_path)
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.set_default_timeout(30_000)
|
||||
await _send_with_held_upload(page, app_url, png, spam=True)
|
||||
await _wait_grounded_settle(page)
|
||||
# The spam is FUNCTIONALLY dead too: exactly ONE upload
|
||||
# request reached the interceptor (the second press never
|
||||
# fired an upload; the Enter never fired a send).
|
||||
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
|
||||
# …and no second turn: exactly one question + one answer.
|
||||
await expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
await expect(page.locator(".msg.brain .bubble")).to_have_count(1)
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_upload_window_shows_uploading_and_blocks_resend(
|
||||
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
asyncio.run(_scenario_window_blocks_resend(app_url, tmp_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Success: the indicator clears, the strip hides, the bubble carries
|
||||
# the image, the record carries the PATH, the turn completes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _scenario_success_clears_and_proceeds(app_url: str, tmp_path: Path) -> None:
|
||||
png = _png_file(tmp_path)
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.set_default_timeout(30_000)
|
||||
await _send_with_held_upload(page, app_url, png)
|
||||
await _wait_grounded_settle(page)
|
||||
# The user bubble renders the attached image (the phase-123
|
||||
# attachBubbleImage — the live data URL, alt = the filename).
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
await expect(img).to_be_visible()
|
||||
src = await img.get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL"
|
||||
assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1
|
||||
assert await img.get_attribute("alt") == PNG_NAME
|
||||
# A5 at the storage boundary: the record carries the stored
|
||||
# PATH, and NOTHING base64 crossed into the payload.
|
||||
raw = await page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
stored = json.loads(raw) if raw else None
|
||||
assert stored is not None, "the conversation must be persisted (save point 1)"
|
||||
path = stored["messages"][0]["image"]
|
||||
assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}"
|
||||
assert "base64" not in json.dumps(stored)
|
||||
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_upload_success_clears_and_turn_proceeds(
|
||||
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
asyncio.run(_scenario_success_clears_and_proceeds(app_url, tmp_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Failure (the network drop): the state clears, the banner shows,
|
||||
# the draft + the attachment remain for the retry, NO turn ran
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _scenario_failure_clears_and_keeps_draft(app_url: str, tmp_path: Path) -> None:
|
||||
png = _png_file(tmp_path)
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.set_default_timeout(30_000)
|
||||
# The route DELAYS (the in-flight state is assertable, same
|
||||
# as the other tests) then ABORTS — the fetch rejects and
|
||||
# uploadAttachedImage's catch settles the network-drop
|
||||
# banner.
|
||||
await _send_with_held_upload(page, app_url, png, fail=True)
|
||||
# The OUT-OF-TURN banner (the network-drop copy).
|
||||
banner = page.locator("#kb-banner")
|
||||
await expect(banner).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
|
||||
await expect(banner).to_have_class(re.compile(r"\bis-error\b"))
|
||||
await expect(page.locator("#kb-banner-text")).to_contain_text(
|
||||
"Couldn't attach the image — try again."
|
||||
)
|
||||
# The uploading state is CLEARED on the failure path too.
|
||||
strip = page.locator("#attach-preview")
|
||||
assert "is-uploading" not in (await strip.get_attribute("class") or "")
|
||||
await expect(page.locator(".attach-preview-status")).to_be_hidden()
|
||||
await expect(page.locator("#attach-remove")).to_be_enabled()
|
||||
await expect(page.locator("#send-btn")).to_be_enabled()
|
||||
await expect(page.locator("#send-label")).to_have_text("Send")
|
||||
# The strip is back in its NORMAL preview — the attachment
|
||||
# remains for the retry (the thumbnail + the filename).
|
||||
await expect(strip).to_be_visible()
|
||||
await expect(
|
||||
page.locator("#attach-preview .attach-preview-name")
|
||||
).to_have_text(PNG_NAME)
|
||||
src = await page.locator("#attach-preview img").get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the preview keeps the data URL"
|
||||
# The typed question is INTACT (the send was blocked — A8).
|
||||
assert await page.locator("#message-input").input_value() == QUESTION
|
||||
# NO turn ran (the question never went out without the image).
|
||||
await expect(page.locator(".msg.user")).to_have_count(0)
|
||||
await expect(page.locator(".msg.brain")).to_have_count(0)
|
||||
# The request was intercepted exactly once (it never reached
|
||||
# the server — the abort dropped it).
|
||||
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_upload_failure_clears_state_and_keeps_the_draft(
|
||||
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
asyncio.run(_scenario_failure_clears_and_keeps_draft(app_url, tmp_path))
|
||||
@@ -34,6 +34,19 @@ wiring (a recording fake LLM, a fake retriever, a fake DB session,
|
||||
patched settings) so the whole validate → build contract runs without
|
||||
a stack; the upload/serve tests point ``chat_image_dir`` at a tmp
|
||||
directory (the raw-string ``expanduser`` convention).
|
||||
|
||||
Phase 132 (task 01, TODO L8) pins the PRE-turn uploading state in the
|
||||
same chat-frontend surface: the preview strip ships a HIDDEN status
|
||||
part (the house loading arc — decorative, aria-hidden — + the pinned
|
||||
"Uploading image…" text), and ``handleSend``'s attachedImage branch
|
||||
enters it exactly once per upload (after the ``attachUpload`` guard,
|
||||
before the ``await uploadAttachedImage``) and exits it on BOTH
|
||||
resolution paths (the helper never throws) — strip class
|
||||
``is-uploading`` + status unhidden + remove ✕ disabled + the Send
|
||||
button disabled with "Uploading…" on entry; the mirror image on exit
|
||||
with the idle label restored byte-for-byte. ``setUiState`` / ``uiState``
|
||||
/ the turn loader stay untouched (the upload is pre-turn; ``uiState``
|
||||
stays ``idle``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -41,6 +54,8 @@ import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
@@ -1168,3 +1183,425 @@ def test_the_bad_pick_and_the_reader_failure_keep_the_banner_copy() -> None:
|
||||
m = re.search(r'reader\.onerror = \(\) => \{(.*?)\n \};', js, re.S)
|
||||
assert m, "the FileReader's onerror branch must exist"
|
||||
assert 'showErrorBanner("Couldn\'t attach the image — try again.");' in m.group(1)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Phase 132 (task 01, TODO L8) — the PRE-turn uploading state
|
||||
# =====================================================================
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of the first `function <name>` in js (brace balanced
|
||||
— the house pin pattern from tests/unit/test_sync_button.py)."""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined"
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def test_the_strip_ships_the_hidden_uploading_status_part() -> None:
|
||||
"""The preview strip's markup gains ONE status part INSIDE the
|
||||
strip — after the filename span, before the remove button —
|
||||
shipping HIDDEN (the house "ships hidden" rule): the house
|
||||
loading arc (the svg is decorative, aria-hidden — the arc is not
|
||||
the meaning) + the pinned "Uploading image…" text (it carries the
|
||||
meaning). The strip's existing parts (thumbnail, name, remove ✕
|
||||
+ its aria-label) are untouched, and the thumbnail + filename stay
|
||||
visible throughout the upload (the user watches THEIR image
|
||||
uploading — only the status part's `hidden` moves)."""
|
||||
html = _index_html()
|
||||
start = html.find('<div class="attach-preview" id="attach-preview" hidden>')
|
||||
assert start != -1
|
||||
strip = html[start : html.find("</div>", start)]
|
||||
# the status part: the exact shipped markup (hidden by default).
|
||||
assert '<span class="attach-preview-status" hidden>' in strip
|
||||
assert (
|
||||
'<svg class="attach-preview-status-icon" aria-hidden="true"'
|
||||
' viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
||||
' stroke-width="2" stroke-linecap="round">'
|
||||
'<path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>' in strip
|
||||
), "the house loading arc (decorative, aria-hidden) must ship"
|
||||
assert '<span class="attach-preview-status-text">Uploading image…</span>' in strip
|
||||
# exactly ONE status part, in the pinned order: img → name →
|
||||
# status → remove (inside the strip, before its closing tag).
|
||||
assert strip.count('class="attach-preview-status"') == 1
|
||||
i_img = strip.find('class="attach-preview-img"')
|
||||
i_name = strip.find('class="attach-preview-name"')
|
||||
i_status = strip.find('class="attach-preview-status" hidden')
|
||||
i_remove = strip.find('id="attach-remove"')
|
||||
assert -1 < i_img < i_name < i_status < i_remove, (
|
||||
"the status part sits after the name, before the remove button"
|
||||
)
|
||||
# the pre-phase parts are byte-untouched (the phase-123 contract).
|
||||
assert '<img class="attach-preview-img" alt="">' in strip
|
||||
assert 'aria-label="Remove the attached image"' in strip
|
||||
|
||||
|
||||
def test_enter_exit_upload_state_are_the_exact_affordance_writers() -> None:
|
||||
"""The two tiny helpers next to showAttachPreview / clearAttachedImage
|
||||
(A5, owner-confirmed 2026-09-27 — the double-spam block is the
|
||||
BOTH-halves affordance). enterUploadState: the strip gains the
|
||||
.is-uploading class (task 02's CSS hooks it), the status part
|
||||
unhides, the remove ✕ disables (the attachment is mid-flight), and
|
||||
the Send button disables with the "Uploading…" label. exitUploadState
|
||||
is the mirror image — and its idle restore is BYTE-IDENTICAL to
|
||||
setUiState's idle label ("Send"), so the success path hands the
|
||||
button to runTurn's setUiState("thinking") as usual and the
|
||||
failure path leaves a fully editable composer. The attach family
|
||||
is null-guarded (the house pattern); the send refs are
|
||||
unconditional (the composer is required). setUiState / uiState /
|
||||
the turn loader are NEVER touched — the upload is PRE-turn.
|
||||
The module-scope ref is the strip's status part (the text is
|
||||
static — no attachStatusText)."""
|
||||
js = _app_js()
|
||||
enter = _fn(js, "enterUploadState")
|
||||
exit_ = _fn(js, "exitUploadState")
|
||||
# entry — the full affordance, in the pinned order.
|
||||
i_preview = enter.find('attachPreview.classList.add("is-uploading")')
|
||||
i_status = enter.find("attachStatus.hidden = false")
|
||||
i_remove = enter.find("attachRemove.disabled = true")
|
||||
i_send = enter.find("sendBtn.disabled = true")
|
||||
i_label = enter.find('sendLabel.textContent = "Uploading…"')
|
||||
assert -1 < i_preview < i_status < i_remove < i_send < i_label
|
||||
# exit — the mirror image, ending on the exact idle label.
|
||||
i_preview = exit_.find('attachPreview.classList.remove("is-uploading")')
|
||||
i_status = exit_.find("attachStatus.hidden = true")
|
||||
i_remove = exit_.find("attachRemove.disabled = false")
|
||||
i_send = exit_.find("sendBtn.disabled = false")
|
||||
i_label = exit_.find('sendLabel.textContent = "Send"')
|
||||
assert -1 < i_preview < i_status < i_remove < i_send < i_label
|
||||
# the idle restore is byte-identical to setUiState's idle label —
|
||||
# the same literal "Send", so no stale "Uploading…" can survive.
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send";' in js
|
||||
# the house null-guard pattern on the attach family only (a New
|
||||
# chat mid-upload hides the strip; the class on a hidden element
|
||||
# is harmless and still cleared at resolution); the send refs are
|
||||
# unconditional (the chat view cannot exist without the composer).
|
||||
for fn_src in (enter, exit_):
|
||||
for guarded in ("attachPreview", "attachStatus", "attachRemove"):
|
||||
assert f"if ({guarded})" in fn_src, f"{guarded} must be null-guarded"
|
||||
assert "if (sendBtn)" not in fn_src and "if (sendLabel)" not in fn_src
|
||||
# the PRE-turn contract: neither helper may touch the turn's
|
||||
# state owners (the turn loader spins only when the turn starts).
|
||||
for fn_src in (enter, exit_):
|
||||
assert "setUiState" not in fn_src
|
||||
assert "uiState" not in fn_src
|
||||
assert "turnLoader" not in fn_src
|
||||
assert "innerHTML" not in fn_src, "the house rule: textContent only"
|
||||
# the module-scope ref: the status part (no text ref — static).
|
||||
assert 'const attachStatus = document.querySelector(".attach-preview-status");' in js
|
||||
assert "attachStatusText" not in js
|
||||
|
||||
|
||||
def test_handle_send_wraps_the_await_with_the_upload_state() -> None:
|
||||
"""In ``handleSend``'s attachedImage branch: ``enterUploadState()``
|
||||
lands EXACTLY ONCE — after the ``attachUpload = true;`` guard line
|
||||
and BEFORE ``await uploadAttachedImage`` (the moment the upload
|
||||
starts — never before, never twice); ``exitUploadState()`` lands
|
||||
EXACTLY ONCE — after the ``attachUpload = false;`` line (the
|
||||
helper never throws, so this is reached on BOTH success and
|
||||
failure) and BEFORE the ``if (path === null) return;`` block
|
||||
(the failure path exits the state, then returns with the banner +
|
||||
the editable composer + the attachment for the retry). The
|
||||
``attachUpload`` guard is byte-untouched, the upload helper is
|
||||
still called from exactly one site (the def + the call), and
|
||||
``handleSend`` itself never calls ``setUiState`` (the pre-turn
|
||||
contract — the turn's own state takes over inside runTurn)."""
|
||||
js = _app_js()
|
||||
# the EXACT handleSend body (brace matched — the file-order
|
||||
# neighbors of handleSend carry their own setUiState references in
|
||||
# their docstrings; the pin is on this function alone).
|
||||
body = _fn(js, "handleSend")
|
||||
i_guard = body.index("if (attachUpload) return;")
|
||||
i_arm = body.index("attachUpload = true;")
|
||||
i_enter = body.index("enterUploadState();")
|
||||
i_await = body.index("await uploadAttachedImage(attachedImage.file)")
|
||||
i_release = body.index("attachUpload = false;")
|
||||
i_exit = body.index("exitUploadState();")
|
||||
i_block = body.index("if (path === null) return;")
|
||||
assert -1 < i_guard < i_arm < i_enter < i_await < i_release < i_exit < i_block, (
|
||||
"guard → arm → ENTER → await → release → EXIT → block, in order"
|
||||
)
|
||||
assert body.count("enterUploadState();") == 1, "entered exactly once per upload"
|
||||
assert body.count("exitUploadState();") == 1, (
|
||||
"exited exactly once — the helper never throws, one line covers both paths"
|
||||
)
|
||||
# the pre-phase contract is byte-untouched: the guard, the single
|
||||
# call site of the upload helper (def + call = 2 occurrences),
|
||||
# and the A8 block line.
|
||||
assert (
|
||||
"if (attachUpload) return; // a second submit mid-upload: "
|
||||
"the first owns the send (never two uploads, never two turns)" in js
|
||||
)
|
||||
assert js.count("uploadAttachedImage") == 2
|
||||
# handleSend itself never drives the turn's UI state — the upload
|
||||
# is PRE-turn (uiState stays idle across the whole flight); the
|
||||
# call is absent (the word survives only in the phase-48 comment).
|
||||
assert "setUiState(" not in body
|
||||
|
||||
|
||||
#: The node harness (the test-document-viewer pattern): the REAL
|
||||
#: extracted enterUploadState / exitUploadState run against stub
|
||||
#: element refs; every transition is snapshotted as a JSON row —
|
||||
#: [tag, isUploading, statusHidden, removeDisabled, sendDisabled,
|
||||
#: label]. Plain strings only (the script is concatenation-assembled).
|
||||
_UPLOAD_NODE_HEAD = """
|
||||
const _classes = new Set();
|
||||
const attachPreview = {
|
||||
classList: {
|
||||
add: (c) => _classes.add(c),
|
||||
remove: (c) => _classes.delete(c),
|
||||
contains: (c) => _classes.has(c),
|
||||
},
|
||||
};
|
||||
const attachStatus = { hidden: true };
|
||||
const attachRemove = { disabled: false };
|
||||
const sendBtn = { disabled: false };
|
||||
const sendLabel = { textContent: "Send" };
|
||||
const show = (tag) => {
|
||||
console.log(JSON.stringify([
|
||||
tag,
|
||||
_classes.has("is-uploading"),
|
||||
attachStatus.hidden,
|
||||
attachRemove.disabled,
|
||||
sendBtn.disabled,
|
||||
sendLabel.textContent,
|
||||
]));
|
||||
};
|
||||
"""
|
||||
|
||||
_UPLOAD_NODE_TAIL = """
|
||||
show("initial");
|
||||
enterUploadState();
|
||||
show("enter");
|
||||
exitUploadState();
|
||||
show("exit-success");
|
||||
enterUploadState();
|
||||
show("re-enter");
|
||||
exitUploadState();
|
||||
show("exit-failure");
|
||||
"""
|
||||
|
||||
#: node availability (the test-document-viewer pattern).
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_upload_state_transitions_under_node() -> None:
|
||||
"""Behavioral check (node) of the REAL enterUploadState /
|
||||
exitUploadState against stub element refs (the test-document-
|
||||
viewer pattern), across the pinned transition set:
|
||||
|
||||
* entry: the strip gains .is-uploading, the status part unhides,
|
||||
the remove ✕ disables, the Send disables with "Uploading…";
|
||||
* exit (success path): the mirror image — class removed, status
|
||||
re-hidden, remove re-enabled, Send re-enabled with "Send";
|
||||
* exit (failure path — the same call, banner the failure's
|
||||
signal, not the strip's): byte-identical to the success exit —
|
||||
the state is fully cleared either way (the user can retry or
|
||||
remove).
|
||||
"""
|
||||
js = _app_js()
|
||||
fns = "\n".join(_fn(js, name) for name in ("enterUploadState", "exitUploadState"))
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in _run_node(_UPLOAD_NODE_HEAD + fns + _UPLOAD_NODE_TAIL).splitlines()
|
||||
if line
|
||||
]
|
||||
by_tag = {row[0]: row for row in rows}
|
||||
# row = [tag, isUploading, statusHidden, removeDisabled, sendDisabled, label]
|
||||
assert by_tag["initial"] == ["initial", False, True, False, False, "Send"]
|
||||
assert by_tag["enter"] == ["enter", True, False, True, True, "Uploading…"], (
|
||||
"entry is the full affordance — strip + both halves of the button"
|
||||
)
|
||||
# success and failure exits are byte-identical: fully cleared, the
|
||||
# idle label restored exactly.
|
||||
for tag in ("exit-success", "exit-failure"):
|
||||
assert by_tag[tag] == [tag, False, True, False, False, "Send"], tag
|
||||
# a re-entry (the retry after a failed upload) re-arms the full
|
||||
# affordance — the helpers are idempotent in both directions.
|
||||
assert by_tag["re-enter"] == ["re-enter", True, False, True, True, "Uploading…"]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Phase 132 (task 02, TODO L8) — the strip's uploading CSS (the visual
|
||||
# half of the affordance: the dimmed thumbnail, the hidden name slot,
|
||||
# the spinning --ink-soft status, the dimmed remove ✕, the reduced-
|
||||
# motion still, and the verified house :disabled language on the Send)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
def _phase132_css() -> str:
|
||||
"""The Phase 132 CSS block in styles.css — from its comment marker
|
||||
to the next top-level rule (the ``.composer`` that follows the
|
||||
``.attach-preview*`` family) — with the comments stripped, so the
|
||||
raw-color audit sees only declarations."""
|
||||
css = _css()
|
||||
start = css.find("/* Phase 132 (task 02, TODO L8):")
|
||||
assert start != -1, "the Phase 132 CSS block must exist in styles.css"
|
||||
end = css.find("\n.composer {", start)
|
||||
assert end != -1, "the Phase 132 block must sit before the .composer rule"
|
||||
return re.sub(r"/\*.*?\*/", "", css[start:end], flags=re.S)
|
||||
|
||||
|
||||
def test_the_uploading_strip_dims_the_thumbnail_and_keeps_the_name_slot() -> None:
|
||||
"""The in-flight strip: the thumbnail DIMS (opacity 0.5 — it is
|
||||
decorative, no contrast duty), and the filename HIDES with
|
||||
visibility (NOT display/width — the flex: 1 slot keeps its measured
|
||||
space, so the status takes the row's right hand without reflowing
|
||||
the pinned 44px remove button; the strip height is stable)."""
|
||||
css = _css()
|
||||
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-img \{(.*?)\}", css, re.S)
|
||||
assert m, "the uploading thumbnail must dim"
|
||||
assert "opacity: 0.5;" in m.group(1)
|
||||
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-name \{(.*?)\}", css, re.S)
|
||||
assert m, "the uploading name must hide"
|
||||
body = m.group(1)
|
||||
assert "visibility: hidden;" in body
|
||||
assert "display: none" not in body and "width: 0" not in body, (
|
||||
"the name slot must keep its space (visibility, not removal)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_uploading_status_row_is_the_ink_soft_small_text() -> None:
|
||||
"""The status part's row: the house inline-flex language (the icon
|
||||
+ the text as one unit), --ink-soft at the strip's small text size
|
||||
(the name's 0.85rem), and min-width 0 (the row's clamp — at 320px
|
||||
the text wraps rather than clipping; verified in the task's browser
|
||||
pass: one line at 320px, no clip)."""
|
||||
m = re.search(r"\.attach-preview-status \{(.*?)\}", _css(), re.S)
|
||||
assert m, ".attach-preview-status must be styled"
|
||||
body = m.group(1)
|
||||
for needle in (
|
||||
"display: inline-flex;",
|
||||
"align-items: center;",
|
||||
"gap: 0.35rem;",
|
||||
"color: var(--ink-soft);",
|
||||
"font-size: 0.85rem;",
|
||||
"min-width: 0;",
|
||||
):
|
||||
assert needle in body, f".attach-preview-status must keep: {needle}"
|
||||
|
||||
|
||||
def test_the_status_icon_is_the_14px_house_spin_at_the_sync_pace() -> None:
|
||||
"""The arc: 14px (the strip's small-text scale), flex 0 0 auto (the
|
||||
.sync-icon house pattern), animated with the HOUSE `spin`
|
||||
keyframes at 1s linear — .sync-icon.is-spinning's in-flight pace
|
||||
(the .spinner's 0.8s is the busy-turn arc's). The keyframes must
|
||||
EXIST — a dangling animation-name would spin nothing and the
|
||||
"in motion" state would be invisible."""
|
||||
m = re.search(r"\.attach-preview-status-icon \{(.*?)\}", _css(), re.S)
|
||||
assert m, ".attach-preview-status-icon must be styled"
|
||||
body = m.group(1)
|
||||
for needle in ("width: 14px;", "height: 14px;", "flex: 0 0 auto;"):
|
||||
assert needle in body, f".attach-preview-status-icon must keep: {needle}"
|
||||
assert "animation: spin 1s linear infinite;" in body
|
||||
assert re.search(r"@keyframes spin \{\s*to \{ transform: rotate\(360deg\); \}\s*\}", _css()), (
|
||||
"the house `spin` keyframes must exist (the arc's animation-name)"
|
||||
)
|
||||
|
||||
|
||||
def test_reduced_motion_stills_the_uploading_arc() -> None:
|
||||
"""The house reduced-motion block: with prefers-reduced-motion the
|
||||
arc STILLS (animation none) — the "Uploading image…" text + the
|
||||
dimmed thumbnail then carry the state without motion (B5: never
|
||||
motion alone; the state is text + dimming, not the spin)."""
|
||||
m = re.search(
|
||||
r"@media \(prefers-reduced-motion: reduce\) \{\s*"
|
||||
r"\.attach-preview-status-icon \{ animation: none; \}\s*\}",
|
||||
_css(),
|
||||
)
|
||||
assert m, "the house reduced-motion block must still the uploading arc"
|
||||
|
||||
|
||||
def test_the_disabled_remove_x_dims_with_the_disabled_affordance() -> None:
|
||||
"""The remove ✕ (task 01 disables it mid-flight via the attribute)
|
||||
dims to 0.5 while uploading — the attribute is the state, the
|
||||
opacity is its visual half (the same 0.5 as the dimmed thumbnail,
|
||||
the house dim language)."""
|
||||
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-remove \{(.*?)\}", _css(), re.S)
|
||||
assert m, "the uploading remove button must dim"
|
||||
assert "opacity: 0.5;" in m.group(1)
|
||||
|
||||
|
||||
def test_the_disabled_send_rule_is_the_existing_house_rule_not_a_new_visual() -> None:
|
||||
"""Task 02's verification step: the Send is disabled via the
|
||||
`disabled` attribute (task 01) and the EXISTING house rule
|
||||
``.send-btn:disabled`` already reads as disabled — the
|
||||
--brand-busy background (the brand brightened 40% toward white,
|
||||
visibly lighter than the enabled --brand fill, and a derived
|
||||
variable that follows every saved theme) + the not-allowed
|
||||
cursor. The pin says the rule is INTACT: the upload state must
|
||||
not invent a second disabled look (no new color, no new weight).
|
||||
(Verified in the task's browser pass: the rule applies to
|
||||
#send-btn, which rides .send-btn.)"""
|
||||
m = re.search(r"\.send-btn:disabled \{(.*?)\}", _css(), re.S)
|
||||
assert m, "the house .send-btn:disabled rule must exist"
|
||||
body = m.group(1)
|
||||
assert "background: var(--brand-busy);" in body
|
||||
assert "cursor: not-allowed;" in body
|
||||
|
||||
|
||||
def test_the_phase132_css_ships_no_raw_color_and_holds_the_aa_pair() -> None:
|
||||
"""The theme audit (the B4 byte-identical contract): every value
|
||||
in the Phase 132 block is a theme variable or a neutral number —
|
||||
NO raw hex/rgb/hsl (a custom theme repaints everything through the
|
||||
variables). And the status text's --ink-soft on the strip's
|
||||
--surface holds the AA floor: computed here against WCAG 2.1 from
|
||||
the built-in :root literals (the theme phases' method) — 8.2:1,
|
||||
≥ 4.5:1."""
|
||||
block = _phase132_css()
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no raw hex in the Phase 132 block"
|
||||
assert not re.search(r"\b(?:rgb|rgba|hsl|hsla)\(|\bsrgb\b", block), (
|
||||
"no raw rgb/hsl in the Phase 132 block"
|
||||
)
|
||||
ratio = _wcag_contrast(_root_color("--ink-soft"), _root_color("--surface"))
|
||||
assert ratio >= 4.5, f"--ink-soft on --surface = {ratio:.2f}:1 (< 4.5:1 AA floor)"
|
||||
|
||||
|
||||
def _root_color(name: str) -> str:
|
||||
"""A built-in :root literal color (the theme phases audit the
|
||||
built-in palette this way; a saved theme repaints through the
|
||||
variables and is checked by the theme E2E)."""
|
||||
m = re.search(rf"(?m)^\s*{name}:\s*(#[0-9a-fA-F]{{6}})", _css())
|
||||
assert m, f"{name} must ship a plain built-in color in :root"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _hex_rgb(value: str) -> tuple[int, int, int]:
|
||||
h = value.lstrip("#")
|
||||
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||
|
||||
|
||||
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
|
||||
"""WCAG 2.1 relative luminance (the house helper's math — the
|
||||
test_responsive_polish / test_dark_tech_theme contrast helper)."""
|
||||
|
||||
def chan(c: int) -> float:
|
||||
s = c / 255
|
||||
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = (chan(c) for c in rgb)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def _wcag_contrast(fg: str, bg: str) -> float:
|
||||
l1, l2 = _rel_luminance(_hex_rgb(fg)), _rel_luminance(_hex_rgb(bg))
|
||||
if l1 < l2:
|
||||
l1, l2 = l2, l1
|
||||
return (l1 + 0.05) / (l2 + 0.05)
|
||||
|
||||
Reference in New Issue
Block a user