feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
@@ -447,3 +447,227 @@ def test_share_button_revealed_only_for_admin() -> None:
|
||||
assert boot_start < save_reveal < reveal, (
|
||||
"the Share reveal joins the same admin-reveal block as Save"
|
||||
)
|
||||
|
||||
|
||||
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
|
||||
|
||||
|
||||
def test_stale_banner_html_after_kb_banner() -> None:
|
||||
"""index.html: the #stale-banner section sits DIRECTLY AFTER
|
||||
#kb-banner (the chat-shell top-of-column position — kb-banner keeps
|
||||
the top slot when both are visible), role="status", shipped hidden,
|
||||
with the exact text and the #stale-regenerate button (type=button,
|
||||
visible label "Regenerate", the redo glyph — the SAME SVG paths as
|
||||
RETRY_ICON in app.js, the phase-49 Retry asset). No other page
|
||||
carries it (chat-page only)."""
|
||||
html = _index()
|
||||
kb_idx = html.find('id="kb-banner"')
|
||||
banner = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
|
||||
assert banner, "index.html must contain the #stale-banner section"
|
||||
tag = banner.group(0)
|
||||
assert 'role="status"' in tag
|
||||
assert "hidden" in tag, "the banner ships hidden (the reveal is app.js's job)"
|
||||
assert -1 < kb_idx < banner.start(), "the banner sits directly after #kb-banner"
|
||||
# Nothing between the kb-banner close and the stale banner except
|
||||
# whitespace + the phase-53 comment: the top-of-column pair is kept.
|
||||
between = html[html.find("</div>", kb_idx) : banner.start()]
|
||||
assert "id=" not in between, "no other element lands between the two banners"
|
||||
block = html[banner.start() : html.find("</section>", banner.start())]
|
||||
assert "The sources have been updated since this chat was saved." in block
|
||||
btn = re.search(r'<button[^>]*id="stale-regenerate"[^>]*>', block)
|
||||
assert btn, "the banner carries the #stale-regenerate button"
|
||||
assert 'type="button"' in btn.group(0)
|
||||
btn_block = block[btn.start() : block.find("</button>", btn.start())]
|
||||
assert ">Regenerate</span>" in btn_block, "the visible label is Regenerate"
|
||||
# The redo glyph: the SAME paths as RETRY_ICON (the phase-49 asset).
|
||||
assert 'd="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"' in btn_block
|
||||
assert 'd="M21 3v5h-5"' in btn_block
|
||||
# The banner's own leading mark is the redo glyph too (distinct from
|
||||
# the kb-banner warning triangle) — aria-hidden decoration.
|
||||
lead = block[: btn.start()]
|
||||
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the stale banner is chat-page only"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_load_reveals_stale_banner_on_payload_stale() -> None:
|
||||
"""restoreSavedChatFromUrl: on a 200 payload with stale: true, the
|
||||
#stale-banner is revealed (hidden removed) — the flag is
|
||||
server-computed (task 03), the client never does staleness math.
|
||||
The reveal rides the boot SUCCESS path (after the link + the
|
||||
localStorage mirror), so a non-stale payload leaves the banner
|
||||
hidden."""
|
||||
js = _js()
|
||||
body = _fn(js, "restoreSavedChatFromUrl")
|
||||
assert "data.stale === true" in body, "the reveal branches on the payload's stale flag"
|
||||
link_i = body.find("currentChatId = chatId")
|
||||
reveal_i = body.find("staleBanner.hidden = false")
|
||||
assert -1 < link_i < reveal_i, "the reveal runs on the success path, after the link"
|
||||
assert "staleBanner.hidden = false" in body, "reveal = remove `hidden`"
|
||||
|
||||
|
||||
def test_boot_load_stale_reveal_no_brain_record_guard() -> None:
|
||||
"""The no-brain-record guard: a stale conversation with NO brain
|
||||
record (user-only) is revealed TEXT-ONLY — the #stale-regenerate
|
||||
button is removed BEFORE the reveal (retryLastTurn is never called
|
||||
in that state)."""
|
||||
js = _js()
|
||||
body = _fn(js, "restoreSavedChatFromUrl")
|
||||
guard_i = body.find('!conversation.some((m) => m.who === "brain")')
|
||||
remove_i = body.find("staleRegenBtn.remove()")
|
||||
reveal_i = body.find("staleBanner.hidden = false")
|
||||
assert -1 < guard_i < remove_i < reveal_i, (
|
||||
"the no-brain check removes the button before the banner is revealed"
|
||||
)
|
||||
|
||||
|
||||
def test_retry_last_turn_returns_the_turn_promise() -> None:
|
||||
"""retryLastTurn RETURNS the runTurn promise (phase 53 task 05):
|
||||
the Regenerate path awaits the turn's completion to know when to
|
||||
persist. The phase-49 Retry click handler ignores the return value
|
||||
— behavior-neutral for it (the redo-order pins in
|
||||
test_frontend_feedback.py keep holding unchanged)."""
|
||||
js = _js()
|
||||
body = _fn(js, "retryLastTurn")
|
||||
assert "return runTurn(text, { reask: true })" in body, (
|
||||
"the redo promise is returned for the Regenerate await"
|
||||
)
|
||||
assert "void runTurn" not in body, "the fire-and-forget void is gone"
|
||||
# The existing Retry click handler still ignores the return value.
|
||||
append = _fn(js, "appendRetryButton")
|
||||
assert "retryLastTurn(wrap)" in append, "the Retry click is unchanged (no await)"
|
||||
|
||||
|
||||
def test_stale_regenerate_drives_retry_last_turn_and_awaits() -> None:
|
||||
"""regenerateStaleChat: drives retryLastTurn on the LAST brain
|
||||
bubble's rendered wrap (phase-49 targeting — retryLastTurn's own
|
||||
`wrap !== lastBrainWrap` guard makes a stale click a no-op that
|
||||
resolves nothing), AWAITs the returned turn promise, and persists
|
||||
only when the turn completed WITHOUT the error banner (a
|
||||
mid-stream error leaves the linked row untouched — stale stays
|
||||
true). The double-click guard releases in the finally — never
|
||||
stale (PLAN §7.4)."""
|
||||
js = _js()
|
||||
body = _fn(js, "regenerateStaleChat")
|
||||
assert "staleRegenBtn.disabled = true" in body, "one regenerate at a time"
|
||||
call_i = body.find("retryLastTurn(lastBrainWrap)")
|
||||
await_i = body.find("await turn")
|
||||
assert -1 < call_i < await_i, "call the redo on the last brain wrap, then await it"
|
||||
err_i = body.find('banner.classList.contains("is-error")')
|
||||
put_i = body.find('`/api/chats/${currentChatId}`')
|
||||
assert -1 < await_i < err_i < put_i, (
|
||||
"the error-banner check sits between the await and the persist"
|
||||
)
|
||||
finally_idx = body.rfind("finally")
|
||||
assert finally_idx != -1 and "staleRegenBtn.disabled = false" in body[finally_idx:], (
|
||||
"the button is re-enabled in the finally — never stale"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_regenerate_persists_the_linked_row() -> None:
|
||||
"""The post-regenerate persist (the existing upsert path): linked →
|
||||
PUT /api/chats/<id> (the server re-stamps sources_version → the row
|
||||
is fresh); a 404 (the row was deleted from History meanwhile)
|
||||
follows saveCurrentChat's stale-link rule — unlink + recreate
|
||||
(POST), and the recreate links the new id. Success hides the banner
|
||||
AND announces the outcome in the #send-status live region; 403/5xx
|
||||
→ the actionable error banner (the row stays as the turn left it);
|
||||
network → the reachable? banner."""
|
||||
js = _js()
|
||||
body = _fn(js, "regenerateStaleChat")
|
||||
assert "if (currentChatId)" in body
|
||||
assert 'method: "PUT"' in body
|
||||
assert 'fetch("/api/chats"' in body and 'method: "POST"' in body
|
||||
put_idx = body.find('method: "PUT"')
|
||||
post_idx = body.find('method: "POST"')
|
||||
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
|
||||
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
|
||||
# The 404→recreate fallback: unlink, then POST again.
|
||||
notfound_idx = body.find("res.status === 404")
|
||||
assert notfound_idx != -1, "the PUT 404 must be handled"
|
||||
fallback = body[notfound_idx:post_idx]
|
||||
assert "currentChatId = null" in fallback, "the stale link is dropped"
|
||||
# The recreate links the new row.
|
||||
assert "res.status === 201" in body
|
||||
assert "currentChatId = String(created.id)" in body
|
||||
# Success: hide the banner, then announce in the live region.
|
||||
hide_i = body.find("staleBanner.hidden = true")
|
||||
ann_i = body.find('sendStatus.textContent = "Regenerated')
|
||||
assert -1 < hide_i < ann_i, "hide the banner, then announce the outcome"
|
||||
assert "the answer now reflects the current sources." in body, ("the live-region line")
|
||||
# Failures raise an actionable banner (non-ok HTTP + network).
|
||||
assert (
|
||||
'showErrorBanner("Couldn\'t save the regenerated answer — is the app reachable?")' in body
|
||||
)
|
||||
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
|
||||
|
||||
|
||||
def test_stale_regenerate_binding_and_element_queries() -> None:
|
||||
"""The wiring: app.js queries #stale-banner + #stale-regenerate at
|
||||
module scope and binds the click to regenerateStaleChat. The banner
|
||||
only ever shows on the /?chat=<id> boot path (admin), so the
|
||||
binding is inert otherwise."""
|
||||
js = _js()
|
||||
assert 'document.querySelector("#stale-banner")' in js
|
||||
assert 'document.querySelector("#stale-regenerate")' in js
|
||||
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
|
||||
|
||||
|
||||
def test_stale_banner_cleared_on_new_chat_and_resave() -> None:
|
||||
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the
|
||||
banner described (and unlinks it) — the banner hides; a successful
|
||||
manual re-Save re-stamps the row to the current generation (task
|
||||
03) — the banner is done the moment the save succeeds."""
|
||||
js = _js()
|
||||
new_body = _fn(js, "startNewChat")
|
||||
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
|
||||
save_body = _fn(js, "saveCurrentChat")
|
||||
saved_line = 'sendStatus.textContent = "Conversation saved."'
|
||||
after = save_body[save_body.find(saved_line):]
|
||||
assert "staleBanner.hidden = true" in after, (
|
||||
"a successful re-save re-stamps the row — the banner is done"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_banner_css_is_the_kb_banner_family() -> None:
|
||||
"""styles.css: the banner rides the .kb-banner family (the section
|
||||
carries BOTH classes — the flex row + accent tokens come from
|
||||
.kb-banner; .stale-banner adds the wrap so the pill can drop below
|
||||
the text when the row must wrap), and the .stale-regenerate pill is
|
||||
the EXACT brand-pill family of Save/Share (solid --brand, --bg text
|
||||
5.2:1 AA, borderless, 999px, ≥44px, hover lightens the fill, 16px
|
||||
redo glyph). The ≤640px block makes the pill a full-width row."""
|
||||
html = _index()
|
||||
tag = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
|
||||
assert tag and "kb-banner" in tag.group(0) and "stale-banner" in tag.group(0), (
|
||||
"the section carries both classes — the family comes from .kb-banner"
|
||||
)
|
||||
css = _css()
|
||||
block = re.search(r"\.stale-regenerate \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .stale-regenerate"
|
||||
body = block.group(1)
|
||||
for prop in (
|
||||
"display: inline-flex",
|
||||
"min-height: 44px",
|
||||
"margin-left: auto",
|
||||
"border-radius: 999px",
|
||||
"border: 0",
|
||||
"background: var(--brand)",
|
||||
"color: var(--bg)",
|
||||
"font-weight: 700",
|
||||
"cursor: pointer",
|
||||
):
|
||||
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
|
||||
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
|
||||
"the redo glyph rides the 16px pill size"
|
||||
)
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "mobile media query missing"
|
||||
assert ".stale-regenerate { margin-left: 0; width: 100%; }" in mobile.group(1), (
|
||||
"at phone width the pill takes a full-width row"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user