phase: 159_edit_and_resend_message
All completion criteria verified. Final report:
**Phase 159 — Edit the last question and re-ask it: VERIFIED COMPLETE** (all 4 tasks already in `complete/`; this pass verified end-to-end)
**Verified (implementation landed + tests green):**
- `app.js`: `EDIT_ICON`, `lastUserWrap`, `appendEditButton`/`markLastEditable` (single-target discipline, in-flight guard), `openEditor` (labelled textarea, `maxlength=4000`, Escape/Ctrl+⌘+Enter, empty guard), save path: replace → splice tail → DOM removal → `lastBrainWrap=null` → `saveConversation`+`persistConversation` → `runTurn(text,{reask:true})` — matches README exactly
- `styles.css`: `.edit-btn`/`.edit-form`/`.edit-error`/`.is-editing` + mobile 44px; unit pins 40, E2E 7, integration `test_chats_api.py` extended
**Test/lint results (all green):**
- `uv run pytest` — 3,661 tests, exit 0
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` — all checks passed; 0 errors
- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` (DB up) — **7/7 passed**
- Regressions in isolation: `test_retry_answer.py` 4/4, `test_failed_turn_retry.py` 4/4, `test_chat_persistence.py` 4/4
**Completion criteria:** all 7 ✓ — Edit on last bubble + prefill + Escape/Cancel restore (E2E #3); replace+truncate+re-ask with no duplicated question (E2E #1); refresh survival (E2E #4); failed/“question too long” turn editable + trim re-asks (E2E #5); earlier bubbles un-editable + byte-identical Retry/Stop/persistence (E2E #2 + regressions + unit pins); suite/coverage/lint ✓; E2E + regressions isolated ✓. Commit + phase-dir move: harness's job (no git ops by me, per protocol).
**Documentation:** README.md — full review; phase 159 section cross-checked against `app.js` source (cap, keys, truncate, image survival) — consistent, no changes needed; all 30 `.env.example` vars documented. AGENTS.md — full review; all rules/quick-ref commands valid — verified consistent, no changes needed.
**Notable:** none — no defects found in prior work; no live-infrastructure changes required.
**Next pending phase:** `160_tool_budget_telemetry`
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
"""Phase 159 E2E (Playwright): edit the last question and re-ask it.
|
||||
|
||||
Source: ``TODO.md`` L3 — "Need to be able to edit and resend a message.
|
||||
Got 'Question too long — trim it and re-ask' but I can't trim it
|
||||
because I can't edit it." Run in isolation (DB must be up:
|
||||
``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the failure
|
||||
shape is the deterministic injection in ``tests/e2e/mock_llm.py``
|
||||
(the phase-67 ``always fail`` trigger). The phase-114 "Question too
|
||||
long — trim it and re-ask." frame takes the IDENTICAL settle path
|
||||
(this suite's driver case rides it through the mock's
|
||||
``ALWAYS_FAIL_TRIGGER`` 500s → retry-budget exhaustion → the terminal
|
||||
``error`` frame → ``finalizeFailedTurn``): the EXACT detail string is
|
||||
real-endpoint-only (it is the server's embed-input-too-large
|
||||
short-circuit — ``app/api/chat.py`` — which the mock's embeddings
|
||||
endpoint never raises), so the mock's 500 exhaustion (the phase-120
|
||||
zero-frame shape) is the driver case's stand-in.
|
||||
|
||||
The contract under test (phase 159, owner-locked 2026-10-05):
|
||||
|
||||
* EXACTLY ONE editable bubble — the most recent user message (the
|
||||
``markLastRetryable`` single-target discipline, one ``who`` over);
|
||||
earlier bubbles are never editable.
|
||||
* The Edit opens an INLINE editor in the bubble (the ``.tune-form``
|
||||
family — the textarea prefilled with the question,
|
||||
``maxlength`` = the 4000 cap, Save/Cancel, a ``role=alert`` error
|
||||
line); Cancel/``Escape`` restore the bubble and return focus to the
|
||||
toggle.
|
||||
* Save = REPLACE + TRUNCATE + RE-ASK (the phase-49 redo-in-place rule,
|
||||
one level up): the edited text replaces the record in place, every
|
||||
record after it is dropped (DOM + ``bor.chat.v1`` + the saved chat
|
||||
row), and a fresh answer streams from the edited text with
|
||||
``runTurn(text, { reask: true })`` — the question is never
|
||||
duplicated, the composer's unsent draft survives.
|
||||
* In-flight discipline: no Edit button while a turn runs; the button
|
||||
lands at the settle.
|
||||
|
||||
KB seed (the phase-120 ``test_failed_turn_retry.py`` direct-seed
|
||||
pattern, the task's ``docs/homelab/kubernetes.md``): ONE fixture
|
||||
document — the "How is my Kubernetes cluster set up?" questions
|
||||
FTS-hit it → HIGH → the grounded path (the re-asked turns stream the
|
||||
grounded ``MOCK_ANSWER_MARKER`` answer).
|
||||
|
||||
Test → contract mapping:
|
||||
|
||||
1. ``test_edit_last_question_reasks_in_place`` — send, grounded answer
|
||||
(ONE old brain bubble), the user bubble carries Edit; the editor
|
||||
prefills the EXACT question; append " in one line" + Save: exactly
|
||||
ONE user bubble (the edited text), the old answer bubble is gone,
|
||||
the new answer streams with the marker, ``bor.chat.v1`` holds
|
||||
``[user(edited), brain]`` with NO duplicate question, and the
|
||||
auto-saved ``saved_chats`` row matches the localStorage shape key-
|
||||
for-key (polled — the fire-and-forget PUTs land in order, the
|
||||
truncated shape is the final state; the DB stores the
|
||||
schema-normalized shape, the integration ``_expect`` convention).
|
||||
2. ``test_only_the_last_question_is_editable`` — two turns: exactly
|
||||
ONE ``.edit-btn`` exists, on the SECOND user bubble; the first has
|
||||
none (the single-target discipline, owner-locked).
|
||||
3. ``test_edit_cancel_is_a_no_op`` — open the editor, ``Escape``: the
|
||||
bubble text, the record, and the answer are unchanged (the
|
||||
``localStorage`` payload is byte-identical before/after), the
|
||||
``.edit-form`` is gone, and focus RETURNS to the Edit toggle
|
||||
(``document.activeElement`` is the ``.edit-btn``).
|
||||
4. ``test_edit_drops_a_stale_tail_and_survives_refresh`` — after an
|
||||
edit-save, ``page.reload()``: the edited question + the new answer
|
||||
restore, the DROPPED answer never reappears (one brain bubble, the
|
||||
fresh one — the old text is absent), and Edit is back on the
|
||||
question (the restore's mark pair).
|
||||
5. ``test_failed_turn_question_is_editable_and_trim_reasks`` — the
|
||||
driver case: the ``always fail`` trigger settles the turn with the
|
||||
terminal ``error`` frame + the failed bubble (the phase-120
|
||||
zero-frame shape — the phase-114 "question too long" frame takes
|
||||
this identical settle path, its exact detail string is
|
||||
real-endpoint-only, see the module docstring); Edit exists on the
|
||||
question BESIDE the failed bubble's Retry; trimming (the trigger
|
||||
stripped) + Save streams the grounded answer (the failed record is
|
||||
part of the dropped tail — ``failed: true`` never survives).
|
||||
6. ``test_edit_is_inert_while_the_turn_runs`` — the ``write a long
|
||||
answer`` trigger (~8 s): NO ``.edit-btn`` while the turn runs
|
||||
(the in-flight guard — the button never appears or moves
|
||||
mid-turn); at the ``done`` settle the button lands.
|
||||
7. ``test_composer_draft_survives_an_edit`` — negative: a typed,
|
||||
UNSSENT composer draft survives the edit-save (the save handler
|
||||
never touches ``input.value`` / ``autoGrow`` /
|
||||
``updateCharCount`` — the composer keeps its own draft).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document
|
||||
from e2e.auth_helpers import login
|
||||
from tests.e2e.mock_llm import (
|
||||
ALWAYS_FAIL_TRIGGER,
|
||||
LONG_ANSWER_TRIGGER,
|
||||
embed_text,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Seed + questions (see the module docstring for the gate notes)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SEED_SOURCE = "docs"
|
||||
SEED_PATH = "homelab/kubernetes.md"
|
||||
KUB_CONTENT = (REPO / "tests" / "fixtures" / "docs" / SEED_PATH).read_text()
|
||||
|
||||
#: The grounded question (the phase-120 suite's — FTS-hits the seed).
|
||||
Q = "How is my Kubernetes cluster set up?"
|
||||
#: Test 2's second turn (the seed's workloads section — FTS-hits too).
|
||||
Q2 = "Which workloads does my Kubernetes cluster run?"
|
||||
#: Test 4's edit — the original tokens plus one (grounding preserved —
|
||||
#: a superset of the seed's hit tokens), DISTINCT from the original so
|
||||
#: the mock's question echo makes the old vs the new answer
|
||||
#: distinguishable byte-for-byte.
|
||||
Q_EDITED = "How is my Kubernetes cluster set up, briefly?"
|
||||
#: Test 1's edit — append the task's " in one line" to the question.
|
||||
Q_TEST1_EDITED = "How is my Kubernetes cluster set up? in one line"
|
||||
#: Test 5's failure question (the phase-67 trigger — every streaming
|
||||
#: request 500s → the retry-budget exhaustion, the phase-120 shape).
|
||||
FAIL_Q = f"{Q} {ALWAYS_FAIL_TRIGGER}"
|
||||
#: Test 6's long answer (the mock's ~8 s pacing — the in-flight window).
|
||||
LONG_Q = f"{Q} {LONG_ANSWER_TRIGGER}"
|
||||
#: Test 7's unsent composer draft.
|
||||
DRAFT = "draft question about backups — do not send yet"
|
||||
|
||||
#: The app's terminal LLM-failure copy (app/api/chat.py's LLMError
|
||||
#: frame) — the error detail the note/banner/record all carry.
|
||||
ERROR_COPY = "The chat model dropped the connection — try again?"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
|
||||
|
||||
def _js_const(name: str) -> str:
|
||||
"""A frontend string constant, read from app.js — the E2E asserts
|
||||
against the SAME text the page renders (no JS/Python drift)."""
|
||||
js = (REPO / "frontend" / "assets" / "app.js").read_text(encoding="utf-8")
|
||||
m = re.search(rf'const {name}\s*=\s*\n?\s*"([^"]*)"', js)
|
||||
assert m, f"const {name} not found in app.js"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
FAILED_TURN_TEXT = _js_const("FAILED_TURN_TEXT")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DB seeding (TRUNCATE-then-seed, the phase-120 pattern)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed(db: Session) -> None:
|
||||
"""The single fixture document (see the module docstring)."""
|
||||
md = Document(
|
||||
source=SEED_SOURCE,
|
||||
path=SEED_PATH,
|
||||
full_path=f"/tmp/{SEED_PATH}",
|
||||
title="Kubernetes",
|
||||
content=KUB_CONTENT,
|
||||
content_hash=hashlib.sha256(KUB_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(md)
|
||||
db.flush()
|
||||
# One chunk carrying the mock's own embedding → genuine token overlap
|
||||
# for the grounded questions (the FTS path carries them to HIGH).
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=md.id,
|
||||
position=0,
|
||||
content=KUB_CONTENT,
|
||||
embedding=embed_text(KUB_CONTENT),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _reset_db() -> None:
|
||||
"""Truncate the KB (plus the prompt-shaping tables), then re-seed.
|
||||
|
||||
``steering_notes`` / ``kb_overview`` are truncated too, so the
|
||||
prompts are byte-stable regardless of leftovers from other suites.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
|
||||
)
|
||||
db.commit()
|
||||
_seed(db)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_chats() -> Iterator[None]:
|
||||
"""The send auto-saves a ``saved_chats`` row per turn (phase 55) —
|
||||
truncate around every test so the suite starts from (and leaves)
|
||||
an empty deployment (the phase-80/103 isolation pattern)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
yield
|
||||
# The auto-save is fire-and-forget (the browser never awaits the
|
||||
# PUT): a short margin lets the last turn's PUT land server-side
|
||||
# before the TRUNCATE, so the teardown never 500s an in-flight
|
||||
# upsert (the "Could not refresh instance" race). This runs AFTER
|
||||
# the page closes (LIFO finalization), so the PUT is either done
|
||||
# or aborted — never in flight.
|
||||
time.sleep(0.75)
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# localStorage reads + the auto-saved row poll + the turn flow
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stored_raw(page: Page) -> str:
|
||||
"""The raw ``bor.chat.v1`` payload (the byte-identity check's
|
||||
unit — a string, not a parsed dict)."""
|
||||
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
assert raw is not None, "the conversation key must exist in localStorage"
|
||||
return raw
|
||||
|
||||
|
||||
def _stored(page: Page) -> dict:
|
||||
"""The persisted ``bor.chat.v1`` conversation (localStorage — the
|
||||
phase-14 store the restore reads on refresh)."""
|
||||
return json.loads(_stored_raw(page))
|
||||
|
||||
|
||||
def _poll_saved_messages(
|
||||
title: str, want: Callable[[list[dict]], bool]
|
||||
) -> list[dict]:
|
||||
"""Wait for the auto-saved row's ``messages`` to match *want* (the
|
||||
phase-55 upserts are fire-and-forget — there is no status line to
|
||||
wait on; the ``#send-label`` settle only proves the LAST save
|
||||
point fired, not that its PUT landed). The row's title keeps the
|
||||
FIRST question on re-Save (title-keep), so the edit's row is the
|
||||
same row, polled by the original title."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: list[dict] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
with SessionLocal() as db:
|
||||
row = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE title = :t"),
|
||||
{"t": title},
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
last = row
|
||||
if want(row):
|
||||
return row
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(
|
||||
f"the saved_chats row for {title!r} never settled (last: {last!r})"
|
||||
)
|
||||
|
||||
|
||||
def _shape_matches(row: list[dict], stored: list[dict]) -> bool:
|
||||
"""The row's shape matches the localStorage shape key-for-key.
|
||||
|
||||
The DB stores the schema-NORMALIZED shape (``ChatMessage``
|
||||
re-validation — every optional key present as an explicit
|
||||
``None``, the integration suite's ``_expect`` convention), while
|
||||
localStorage keeps the RAW client records (a key subset). The
|
||||
match: every localStorage key equals the row's value, and the row
|
||||
carries no NON-NULL key the record lacks (no stale metadata,
|
||||
no missing content)."""
|
||||
if len(row) != len(stored):
|
||||
return False
|
||||
for r, s in zip(row, stored, strict=True):
|
||||
for k, v in s.items():
|
||||
if r.get(k) != v:
|
||||
return False
|
||||
for k, v in r.items():
|
||||
if k not in s and v is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _submit(page: Page, question: str) -> None:
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
# The user bubble lands synchronously with the submit handler.
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
|
||||
|
||||
def _assert_settled(page: Page) -> None:
|
||||
"""The turn has FULLY settled: the ``done`` frame is processed
|
||||
(save point 2 — the answer record persisted through
|
||||
``rememberBrainTurn`` — synchronously in the frame handler) BEFORE
|
||||
the finally's idle state flips the label to Send. The label is
|
||||
the settle signal, so a localStorage / row read after it is
|
||||
race-free for the record's shape (the marker can be visible a few
|
||||
frames before ``done`` — the last deltas carry it)."""
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
|
||||
|
||||
def _assert_grounded_settled(page: Page) -> str:
|
||||
"""The grounded answer streamed + the turn settled: returns the
|
||||
brain bubble's text (the phase-120 ``_assert_redo_succeeded``
|
||||
shape, minus the redo's failed-marker checks)."""
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
_assert_settled(page)
|
||||
return bubble.inner_text()
|
||||
|
||||
|
||||
def _open_editor(page: Page, expected_prefill: str) -> None:
|
||||
"""Click the Edit toggle (it must exist on the last user bubble)
|
||||
and pin the textarea's prefill (the record's text, exact)."""
|
||||
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
|
||||
page.click(".msg.user .edit-btn")
|
||||
ta = page.locator(".edit-form textarea")
|
||||
expect(ta).to_be_visible(timeout=10_000)
|
||||
expect(ta).to_have_value(expected_prefill)
|
||||
|
||||
|
||||
def _save_editor(page: Page, new_text: str) -> None:
|
||||
page.fill(".edit-form textarea", new_text)
|
||||
page.click(".edit-form .edit-save")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1 — the flow: send → Edit (prefilled) → trim + Save → the old answer
|
||||
# is gone, the new one streams, the record + the row are truncated
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_last_question_reasks_in_place(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
old_answer = _assert_grounded_settled(page)
|
||||
# The old answer is the ONLY brain bubble (the pre-edit shape).
|
||||
expect(page.locator(".msg.brain")).to_have_count(1)
|
||||
|
||||
_open_editor(page, Q)
|
||||
_save_editor(page, Q_TEST1_EDITED)
|
||||
|
||||
# The old answer bubble is GONE (the truncation removed it); the
|
||||
# question appears ONCE, edited; the fresh answer streams from the
|
||||
# edited text.
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(Q_TEST1_EDITED)
|
||||
new_answer = _assert_grounded_settled(page)
|
||||
expect(page.locator(".msg.brain")).to_have_count(1)
|
||||
assert new_answer != old_answer, "the old answer is gone — the new one streams"
|
||||
|
||||
# The record (localStorage): [user(edited), brain] — the edited
|
||||
# text replaces the record in place, the tail is dropped, and the
|
||||
# question is never DUPLICATED (no record carries the old text;
|
||||
# the new answer merely echoes the edited question).
|
||||
stored = _stored(page)
|
||||
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
|
||||
assert stored["messages"][0]["text"] == Q_TEST1_EDITED
|
||||
assert Q not in [m["text"] for m in stored["messages"]], (
|
||||
"no duplicate question — the old text is gone from the record"
|
||||
)
|
||||
assert MOCK_ANSWER_MARKER in stored["messages"][1]["text"]
|
||||
|
||||
# The auto-saved row (polled — the title keeps the FIRST question
|
||||
# on re-Save): the truncated shape [user(edited), brain], matching
|
||||
# the localStorage shape key-for-key (the DB is the durable boundary).
|
||||
row = _poll_saved_messages(
|
||||
Q, lambda msgs: len(msgs) == 2 and msgs[0].get("text") == Q_TEST1_EDITED
|
||||
)
|
||||
assert _shape_matches(row, stored["messages"]), (
|
||||
"the saved_chats row matches the localStorage shape — the "
|
||||
"truncated conversation persisted (no stale tail, no duplicate)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2 — the single-target discipline: exactly ONE editable bubble, the
|
||||
# NEWEST question (owner-locked 2026-10-05)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_only_the_last_question_is_editable(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
_assert_grounded_settled(page)
|
||||
_submit(page, Q2)
|
||||
_assert_grounded_settled(page)
|
||||
|
||||
# Exactly ONE .edit-btn in the whole DOM, on the SECOND user
|
||||
# bubble; the first (the older question) carries none — the
|
||||
# remove-then-append single target, one `who` over.
|
||||
expect(page.locator(".edit-btn")).to_have_count(1)
|
||||
expect(page.locator(".msg.user").nth(1).locator(".edit-btn")).to_have_count(1)
|
||||
expect(page.locator(".msg.user").nth(0).locator(".edit-btn")).to_have_count(0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3 — negative: Cancel (Escape) is a no-op — the bubble, the record,
|
||||
# and the answer are unchanged; focus returns to the toggle
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_cancel_is_a_no_op(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
answer = _assert_grounded_settled(page)
|
||||
question_text = page.locator(".msg.user .bubble").inner_text()
|
||||
before = _stored_raw(page)
|
||||
|
||||
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
|
||||
page.click(".msg.user .edit-btn")
|
||||
ta = page.locator(".edit-form textarea")
|
||||
expect(ta).to_be_visible(timeout=10_000)
|
||||
# A real edit attempt (so the no-op is meaningful): the textarea is
|
||||
# changed, then Escape cancels.
|
||||
page.fill(".edit-form textarea", "something entirely different")
|
||||
ta.press("Escape")
|
||||
|
||||
# The form is gone; the bubble + the answer are byte-identical; the
|
||||
# localStorage payload is byte-identical (nothing persisted —
|
||||
# cancel never touches the record).
|
||||
expect(page.locator(".edit-form")).to_have_count(0)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(question_text)
|
||||
assert page.locator(".msg.brain .bubble").last.inner_text() == answer
|
||||
assert _stored_raw(page) == before, (
|
||||
"cancel is a no-op — the localStorage payload is byte-identical"
|
||||
)
|
||||
# Focus RETURNED to the Edit toggle (the openTuneForm cancel rule).
|
||||
is_toggle = page.evaluate(
|
||||
"() => !!(document.activeElement && document.activeElement.classList"
|
||||
" && document.activeElement.classList.contains('edit-btn'))"
|
||||
)
|
||||
assert is_toggle, "focus must return to the Edit toggle on Escape"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4 — the truncated shape survives a refresh: the edited question + the
|
||||
# new answer restore; the dropped answer never reappears; Edit is
|
||||
# back on the question
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_drops_a_stale_tail_and_survives_refresh(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
old_answer = _assert_grounded_settled(page)
|
||||
|
||||
_open_editor(page, Q)
|
||||
_save_editor(page, Q_EDITED)
|
||||
new_answer = _assert_grounded_settled(page)
|
||||
assert new_answer != old_answer
|
||||
|
||||
# The refresh: the restore reads the truncated localStorage shape —
|
||||
# the edited question + the fresh answer.
|
||||
page.reload()
|
||||
expect(page.locator("#messages > .msg")).to_have_count(2)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(Q_EDITED)
|
||||
restored = page.locator(".msg.brain .bubble").last
|
||||
expect(restored).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
assert page.locator(".msg.brain .bubble").last.inner_text() != old_answer, (
|
||||
"the DROPPED answer never reappears — the restore shows the "
|
||||
"fresh one (no stale tail resurrected)"
|
||||
)
|
||||
# Edit is back on the restored question (the restore's mark pair —
|
||||
# the mirror of the live settle).
|
||||
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
|
||||
# The restored record is the truncated shape (the restore is
|
||||
# lossless: the edited text, the fresh answer, no duplicate).
|
||||
stored = _stored(page)
|
||||
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
|
||||
assert stored["messages"][0]["text"] == Q_EDITED
|
||||
assert Q not in [m["text"] for m in stored["messages"]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5 — the driver case (TODO.md L3): a failed turn's question is
|
||||
# editable; trimming + Save re-asks successfully (the phase-114
|
||||
# "Question too long" frame takes this identical settle path — its
|
||||
# exact detail string is real-endpoint-only, see the module
|
||||
# docstring)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_failed_turn_question_is_editable_and_trim_reasks(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, FAIL_Q)
|
||||
|
||||
# The ``always fail`` trigger: every streaming request 500s → the
|
||||
# retry-budget exhaustion → the terminal error frame — the
|
||||
# phase-120 zero-frame shape (banner + the failed bubble).
|
||||
banner = page.locator("#kb-banner")
|
||||
expect(banner).to_have_attribute("role", "alert", timeout=60_000)
|
||||
expect(banner).to_contain_text(ERROR_COPY)
|
||||
expect(page.locator("#banner-retry")).to_be_visible(timeout=10_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
brain = page.locator(".msg.brain")
|
||||
expect(brain).to_have_count(1)
|
||||
expect(brain.locator(".bubble")).to_have_text(FAILED_TURN_TEXT)
|
||||
note = brain.locator(".failed-note")
|
||||
expect(note).to_have_count(1)
|
||||
expect(note).to_contain_text(ERROR_COPY)
|
||||
expect(brain.locator(".retry-btn")).to_have_count(1)
|
||||
|
||||
# THE remedy: Edit exists on the question BESIDE the failed
|
||||
# bubble's Retry (the guard bails on thinking/streaming only — the
|
||||
# error state passes; the failed record is the stale tail the
|
||||
# edit-save truncates).
|
||||
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
|
||||
|
||||
_open_editor(page, FAIL_Q)
|
||||
_save_editor(page, Q) # the trigger stripped out
|
||||
|
||||
# The trimmed question re-asks successfully: the grounded answer
|
||||
# streams in place of the failed bubble (the failed record is
|
||||
# dropped — never kept, never rewritten).
|
||||
_assert_grounded_settled(page)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(Q)
|
||||
expect(page.locator(".msg.brain")).to_have_count(1)
|
||||
expect(page.locator(".failed-note")).to_have_count(0)
|
||||
stored = _stored(page)
|
||||
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
|
||||
assert stored["messages"][0]["text"] == Q
|
||||
assert stored["messages"][-1].get("failed") in (None, False), (
|
||||
"the failed record is part of the dropped tail — it never "
|
||||
"survives the edit"
|
||||
)
|
||||
assert FAILED_TURN_TEXT not in stored["messages"][-1]["text"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6 — in-flight discipline: no Edit button while a turn runs; the
|
||||
# button lands at the settle
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_is_inert_while_the_turn_runs(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# The ~8 s long answer — a comfortable in-flight window (the
|
||||
# mock's 12-char / 0.02 s pacing).
|
||||
_submit(page, LONG_Q)
|
||||
# The turn is in flight (the button IS the Stop control —
|
||||
# phase 48). While it runs the Edit button never appears (the
|
||||
# in-flight guard — the button never appears or moves mid-turn).
|
||||
expect(page.locator("#send-label")).to_have_text("Stop", timeout=30_000)
|
||||
expect(page.locator(".edit-btn")).to_have_count(0)
|
||||
# At the ``done`` settle the button lands (the idle-settle mark
|
||||
# pass — the at-rest landing).
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000)
|
||||
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 7 — negative: the composer's unsent draft survives an edit (the save
|
||||
# handler never touches the composer — task 02's work item)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_composer_draft_survives_an_edit(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
_assert_grounded_settled(page)
|
||||
|
||||
# The unsent draft (typed AFTER the settle — the composer keeps its
|
||||
# own draft, untouched by the edit below).
|
||||
page.fill("#message-input", DRAFT)
|
||||
|
||||
_open_editor(page, Q)
|
||||
_save_editor(page, Q_EDITED)
|
||||
_assert_grounded_settled(page)
|
||||
|
||||
# The draft is STILL in the composer (the save handler never writes
|
||||
# input.value / autoGrow / updateCharCount — the edit is the
|
||||
# in-bubble editor's job, the composer's is separate).
|
||||
assert page.locator("#message-input").input_value() == DRAFT
|
||||
@@ -1036,6 +1036,170 @@ def test_shared_serve_includes_the_brain_answer_image_ref(
|
||||
assert "answer_image" not in messages[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 159 (task 02): edit-and-resend — the SAVE shape. The client
|
||||
# edits the last question: the record is replaced in place, EVERY
|
||||
# record after it is dropped, and the edited question becomes the LAST
|
||||
# record. The auto-save then PUTs that shortened list. This pins the
|
||||
# claim "no schema change is needed": a shortened ``messages`` array
|
||||
# with the edited last user record is ALREADY legal on the existing
|
||||
# ``SavedChatUpdate`` path (no new field, no migration — A10 stays
|
||||
# stateless; the ≤4000 / ≤32 000 caps already cover the edited text).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The driver case's question (the phase-114 "Question too long" turn)
|
||||
#: — long, near the 4 000-char ``ChatRequest.message`` cap, so the
|
||||
#: edit trims it down to a short re-askable question.
|
||||
LONG_QUESTION = "How do I prune deleted docs " + "and rotate the certificates " * 90
|
||||
TRIMMED_QUESTION = "How do I prune deleted docs?"
|
||||
|
||||
|
||||
def test_put_edited_truncated_tail_round_trips_byte_identical(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The phase-159 client shape round-trips byte-identically through
|
||||
the EXISTING PUT path: the conversation was
|
||||
[user Q1, brain A1, user Q2-long, brain A2]; the owner trims Q2
|
||||
→ Q2', and the auto-save PUTs [user Q1, brain A1, user Q2'] —
|
||||
the tail (A2) is dropped, the last record is the EDITED user
|
||||
record, and the array is SHORTER. The PUT is a plain re-Save
|
||||
(no new field): 200 + ``message_count`` drops to 3, the stored
|
||||
JSONB + the admin GET + the 200 body all round-trip the edited
|
||||
record + the truncated list byte-identically (``_expect``'s full
|
||||
bor.chat.v1 shape), and the title keeps the FIRST question
|
||||
(title-keep on re-Save — the edited second question never
|
||||
re-titles the row)."""
|
||||
q1 = "What is my Traefik port?"
|
||||
a1 = {"who": "brain", "text": "8080."}
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
_user(q1),
|
||||
a1,
|
||||
_user(LONG_QUESTION),
|
||||
{"who": "brain", "text": "A long answer."},
|
||||
]
|
||||
},
|
||||
).json()
|
||||
assert created["message_count"] == 4
|
||||
|
||||
edited = [_user(q1), a1, _user(TRIMMED_QUESTION)]
|
||||
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
|
||||
assert r.status_code == 200, r.text # a shortened array is already legal
|
||||
body = r.json()
|
||||
assert body["message_count"] == 3, "the tail (A2) is dropped — 2 + the edit"
|
||||
assert body["title"] == q1 # title-keep: the edit re-titles nothing
|
||||
assert body["messages"] == _expect(edited)
|
||||
|
||||
# The RAW stored JSONB (the DB is the durable boundary — the
|
||||
# phase-123/124 precedent): the edited last user record + the
|
||||
# truncated list, byte-for-byte, explicit nulls included.
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": created["id"]},
|
||||
).scalar_one()
|
||||
assert raw == _expect(edited)
|
||||
|
||||
# The admin GET round-trips the same shape (the restore path reads
|
||||
# from this surface — a refresh shows the edited question and no
|
||||
# stale tail).
|
||||
got = admin_client.get(f"/api/chats/{created['id']}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["messages"] == _expect(edited)
|
||||
|
||||
|
||||
def test_put_edited_record_keeps_the_user_image_key(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The image survives the edit (the phase-49/123 A7 rule's record
|
||||
side): the record KEEPS its ``image`` key when the question is
|
||||
replaced in place + the tail dropped — the re-ask sends text
|
||||
only, but the saved shape (and the bubble's attachment) carry the
|
||||
attachment through the round-trip. The key is present-and-carrying
|
||||
(the phase-123 shape — ``_expect`` covers the base keys, the
|
||||
optional ``image`` key is asserted on top, the phase-123
|
||||
precedent)."""
|
||||
edited = [
|
||||
_user_with_image("What is in this image?"), # edited text, image kept
|
||||
# (the tail — a brain answer — is dropped by the edit)
|
||||
]
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
_user_with_image("What is in this picture?"),
|
||||
{"who": "brain", "text": "A cat, by the look of it."},
|
||||
]
|
||||
},
|
||||
).json()
|
||||
|
||||
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
|
||||
assert r.status_code == 200, r.text
|
||||
msgs = r.json()["messages"]
|
||||
assert len(msgs) == 1, "the tail is dropped — one record (the edit)"
|
||||
expected = _expect(edited)[0]
|
||||
expected["image"] = IMAGE_PATH
|
||||
assert msgs[0] == expected, (
|
||||
"the attachment key survives the in-place edit (text-only "
|
||||
"re-ask, attachment kept)"
|
||||
)
|
||||
|
||||
|
||||
def test_shared_serve_carries_the_edited_truncated_shape(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The phase-159 claim pinned on the PUBLIC surface: the
|
||||
edited/truncated conversation needs NO schema change on the shared
|
||||
serve either. The conversation is [user Q1, brain A1, user Q2,
|
||||
brain A2], shared BEFORE the edit (the token predates the shape
|
||||
change), then the owner trims Q2 → Q2' and the auto-save PUTs the
|
||||
post-edit shape [user Q1, brain A1, user Q2'] (the tail dropped,
|
||||
the array shorter, the last record the edited user record). The
|
||||
anonymous ``GET /api/shared/<token>`` still serves exactly the
|
||||
public shape (``title`` + ``messages`` — no id, no timestamps, no
|
||||
token) with the CURRENT row content: the edited record + the
|
||||
truncated list, byte-identical (the read is a point-in-time read
|
||||
of the live row, so the pre-share token rides the post-edit shape
|
||||
— the shared page renders the edited question, no stale tail, no
|
||||
new field)."""
|
||||
q1 = "What is my Traefik port?"
|
||||
a1 = {"who": "brain", "text": "8080."}
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
_user(q1),
|
||||
a1,
|
||||
_user(LONG_QUESTION),
|
||||
{"who": "brain", "text": "A long answer."},
|
||||
]
|
||||
},
|
||||
).json()
|
||||
share_url = _share(admin_client, created["id"])["share_url"]
|
||||
|
||||
# The edit + the auto-save's PUT (the post-edit shape — the same
|
||||
# shape as the admin-surface pin above, now served publicly).
|
||||
edited = [_user(q1), a1, _user(TRIMMED_QUESTION)]
|
||||
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The public read: the token (issued BEFORE the shape change) serves
|
||||
# the CURRENT row — the edited user record as the LAST message,
|
||||
# the tail gone, the array shorter, the byte-identical shape.
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
got = anon.get(f"/api{share_url}")
|
||||
assert got.status_code == 200, got.text
|
||||
body = got.json()
|
||||
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
|
||||
assert body["title"] == q1 # title-keep: the edit re-titles nothing
|
||||
assert len(body["messages"]) == 3, "the tail (A2) is dropped — 2 + the edit"
|
||||
assert body["messages"] == _expect(edited), (
|
||||
"the public surface serves the edited/truncated shape "
|
||||
"byte-identically — no schema change, no stale tail"
|
||||
)
|
||||
|
||||
|
||||
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
|
||||
@@ -66,10 +66,19 @@ def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> No
|
||||
)
|
||||
# No innerHTML on the typing bubble — the skeleton template
|
||||
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
|
||||
# write, and it must not gain a second one.
|
||||
assert js.count("bubble.innerHTML") == 0, (
|
||||
"the clock must never assign innerHTML to the typing bubble"
|
||||
)
|
||||
# write of the typing indicator, and the clock must not gain a
|
||||
# second one (the typing-clock context: addTyping / startThinking
|
||||
# Clock / stopThinkingClock / removeTyping — the phase-159
|
||||
# openEditor re-render of a USER bubble's .bubble is a different
|
||||
# context and does not count).
|
||||
for name in ("function addTyping", "function startThinkingClock",
|
||||
"function stopThinkingClock", "function removeTyping"):
|
||||
f = js.find(name)
|
||||
assert f != -1, f"{name} must exist"
|
||||
b = js[f : js.find("\n}\n", f)]
|
||||
assert "bubble.innerHTML" not in b, (
|
||||
f"{name} must never assign innerHTML to the typing bubble"
|
||||
)
|
||||
|
||||
|
||||
def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None:
|
||||
|
||||
@@ -365,10 +365,13 @@ def test_stop_finalize_appends_actions_last() -> None:
|
||||
|
||||
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
||||
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
|
||||
note is appended and the funnel ENDS with markLastRetryable — the
|
||||
note + Retry in the failed bubble's actions row (the partial
|
||||
note is appended and the funnel ENDS with markLastRetryable
|
||||
immediately followed by markLastEditable (phase 159 task 01 —
|
||||
the failed turn's question is editable too) — the note + Retry
|
||||
+ Edit in the failed bubble's actions row (the partial
|
||||
keeps its streamed text; the zero-frame shape gets a plain
|
||||
FAILED_TURN_TEXT bubble). Both shapes end retryable."""
|
||||
FAILED_TURN_TEXT bubble). Both shapes end retryable +
|
||||
editable."""
|
||||
js = _js()
|
||||
header = (
|
||||
"function finalizeFailedTurn(\n"
|
||||
@@ -382,10 +385,13 @@ def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
||||
)
|
||||
lines = [line for line in body.splitlines() if line.strip()]
|
||||
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
|
||||
"markLastEditable();"
|
||||
) and lines[-3].strip().startswith(
|
||||
"markLastRetryable();"
|
||||
), (
|
||||
"the funnel still ends with the markLastRetryable() call — the "
|
||||
"Retry button lands on the failed bubble's actions row"
|
||||
"the funnel ends with the markLastRetryable() + "
|
||||
"markLastEditable() pair — the Retry + Edit buttons land on "
|
||||
"the failed bubble's actions row"
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -175,14 +175,21 @@ def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
|
||||
|
||||
|
||||
def test_finalize_failed_turn_ends_retryable() -> None:
|
||||
"""The funnel ENDS with ``markLastRetryable()`` (the last statement
|
||||
— a trailing comment is fine) — the in-bubble Retry button lands
|
||||
on the failed bubble (the last brain wrap)."""
|
||||
"""The funnel ENDS with ``markLastRetryable()`` immediately
|
||||
followed by ``markLastEditable()`` (phase 159 task 01 — the
|
||||
failed turn's question is editable too: the trim-and-re-ask
|
||||
remedy) — the in-bubble Retry + Edit buttons land on the failed
|
||||
bubble (the last brain wrap / the last user wrap)."""
|
||||
js = _js()
|
||||
body = _fn_body(js, "finalizeFailedTurn").rstrip()
|
||||
last_line = body.splitlines()[-1].strip()
|
||||
assert last_line.startswith("markLastRetryable();"), (
|
||||
"finalizeFailedTurn must end with the markLastRetryable() call"
|
||||
lines = [line for line in body.splitlines() if line.strip()]
|
||||
assert lines[-1].strip().startswith("markLastEditable();"), (
|
||||
"finalizeFailedTurn must end with the markLastEditable() call "
|
||||
"(phase 159 — the failed turn's question stays editable)"
|
||||
)
|
||||
assert lines[-2].strip().startswith("markLastRetryable();"), (
|
||||
"the markLastEditable() call must sit immediately after "
|
||||
"markLastRetryable() (the paired settle)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -319,8 +319,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
||||
assert "stopped: true" in branch, "the owner-locked optional marker"
|
||||
assert "thinking: thinkingAcc || undefined" in branch
|
||||
assert "tools: toolAcc.length ? toolAcc : undefined" in branch
|
||||
# The confirmation rides the single settle in the finally.
|
||||
finally_block = js[finally_idx : finally_idx + 900]
|
||||
# The confirmation rides the single settle in the finally —
|
||||
# brace-matched from the finally's opening brace, so the
|
||||
# phase-159 settle additions (the markLastEditable pass + its
|
||||
# comment) cannot push the line out of a fixed window.
|
||||
finally_brace = js.index("{", finally_idx)
|
||||
depth = 0
|
||||
finally_end = finally_brace
|
||||
for k in range(finally_brace, len(js)):
|
||||
if js[k] == "{":
|
||||
depth += 1
|
||||
elif js[k] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
finally_end = k
|
||||
break
|
||||
finally_block = js[finally_idx : finally_end + 1]
|
||||
assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block
|
||||
|
||||
|
||||
|
||||
@@ -27,13 +27,25 @@ def test_leave_partial_index_is_a_module_turn_local() -> None:
|
||||
"""`leavePartialIndex` is a module-scope turn local (like
|
||||
persistedOnLeave): declared -1, reset at the top of runTurn, and
|
||||
written ONLY by the pagehide handler (the one `= conversation.length
|
||||
- 1` assignment). No other code may move the correlation."""
|
||||
- 1` assignment) — plus the phase-159 (task 02) truncation reset
|
||||
(the SANCTIONED third write: the edit's Save clears it to -1 WHEN
|
||||
IT POINTED PAST editIdx — a partial saved from the dropped tail
|
||||
must not be re-persisted into the new shape; the settle's identity
|
||||
guard would otherwise fall back to an append; the next runTurn
|
||||
also resets it turn-locally regardless). No other code may move
|
||||
the correlation."""
|
||||
js = _js()
|
||||
assert "let leavePartialIndex = -1" in js, "module-scope declaration"
|
||||
# The two -1 writes are the declaration and the runTurn reset.
|
||||
assert js.count("leavePartialIndex = -1;") == 2, (
|
||||
"only the declaration and the runTurn reset write -1"
|
||||
# The -1 writes: the declaration, the runTurn reset, and the
|
||||
# phase-159 truncation reset (the sanctioned third).
|
||||
assert js.count("leavePartialIndex = -1;") == 3, (
|
||||
"only the declaration, the runTurn reset, and the phase-159 "
|
||||
"truncation reset write -1"
|
||||
)
|
||||
# The phase-159 (task 02) truncation reset is GATED: it fires only
|
||||
# when the pointer pointed past the edited record (a partial on or
|
||||
# before the edited record is untouched).
|
||||
assert "if (leavePartialIndex > editIdx) leavePartialIndex = -1;" in js
|
||||
# The one and only index write is the pagehide handler's.
|
||||
assert js.count("leavePartialIndex = conversation.length - 1") == 1, (
|
||||
"only the pagehide handler records the partial's index"
|
||||
|
||||
Reference in New Issue
Block a user