feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row
This commit is contained in:
@@ -6,7 +6,10 @@ styles.css so a silent regression (key rename, dropped try/catch, missing
|
||||
restore, New chat control lost) is caught without a browser.
|
||||
|
||||
Pinned design (PLAN §7.4 note / phase 14):
|
||||
* versioned key ``bor.chat.v1`` → ``{v: 1, messages: [...]}``, raw text only;
|
||||
* versioned key ``bor.chat.v1`` → ``{v: 1, chatId: string | null,
|
||||
messages: [...]}`` (phase 55 A2: the shape extends IN PLACE with the
|
||||
saved_chats row link — a pre-55 record without it reads as null),
|
||||
raw text only;
|
||||
* save points: user message on send, brain message on ``done``;
|
||||
* every ``localStorage`` access wrapped in try/catch (failure-safe);
|
||||
* size budget ~700k chars, oldest dropped first;
|
||||
@@ -45,13 +48,28 @@ def _index() -> str:
|
||||
|
||||
def test_versioned_storage_key_and_v1_payload() -> None:
|
||||
"""`bor.chat.v1` (versioned — a format bump is a clean start) with the
|
||||
{v, messages} payload shape (A11: raw localStorage JSON, no library)."""
|
||||
{v, chatId, messages} payload shape (A11: raw localStorage JSON, no
|
||||
library). Phase 55 (A2): the shape extends IN PLACE with `chatId` —
|
||||
the saved_chats row link (null when unlinked), so a reload restores
|
||||
the conversation AND its link; the trimToBudget size probe measures
|
||||
the same shape."""
|
||||
js = _js()
|
||||
assert 'const STORAGE_KEY = "bor.chat.v1"' in js
|
||||
assert "export const STORAGE_VERSION = 1" in js
|
||||
# The payload written to the key is always {v: STORAGE_VERSION, messages}
|
||||
# (two write paths: saveConversation and the trimToBudget size probe).
|
||||
assert js.count("v: STORAGE_VERSION, messages") >= 2
|
||||
# The write carries the version, the row link, and the trimmed
|
||||
# messages (the single write path — saveConversation).
|
||||
save_start = js.find("function saveConversation")
|
||||
save_body = js[save_start : js.find("\n}\n", save_start)]
|
||||
assert "v: STORAGE_VERSION" in save_body
|
||||
assert "chatId: currentChatId" in save_body, (
|
||||
"phase 55: the link is written with the record (null when unlinked)"
|
||||
)
|
||||
assert "trimToBudget(conversation)" in save_body
|
||||
# The size probe measures the same shape (the link is a fixed-length
|
||||
# field — null stands in for the size estimate).
|
||||
probe_start = js.find("function trimToBudget")
|
||||
probe = js[probe_start : js.find("\n}\n", probe_start)]
|
||||
assert "v: STORAGE_VERSION" in probe and "chatId: null" in probe
|
||||
# Restore validates the version before trusting anything.
|
||||
assert "data.v !== STORAGE_VERSION" in js
|
||||
|
||||
|
||||
+480
-158
@@ -1,18 +1,60 @@
|
||||
"""Unit: the phase-50 task-03 save-chat contract on the chat page.
|
||||
"""Unit: the save/share contract on the chat page (phase 50 → phase 55).
|
||||
|
||||
The browser behavior itself is E2E-gated by the story suite (task 05);
|
||||
like the other frontend-adjacent unit files, this module pins the
|
||||
JS/CSS/HTML markers the save/load contract depends on, so a silent
|
||||
regression is caught without a browser:
|
||||
The browser behavior itself is E2E-gated by the story suites (phase 50
|
||||
+ phase 55 task 06); like the other frontend-adjacent unit files, this
|
||||
module pins the JS/CSS/HTML markers the save/load contract depends on,
|
||||
so a silent regression is caught without a browser:
|
||||
|
||||
* the ``currentChatId`` lifecycle (set on create/open, cleared by New
|
||||
chat and by the 404-PUT fallback);
|
||||
* the upsert branch (PUT when linked, POST when not, the 404→recreate
|
||||
fallback, the live-region feedback strings);
|
||||
* phase 55 (A2): the Save pill is GONE — no ``#save-chat-btn`` in
|
||||
index.html, no ``.save-chat-btn`` in styles.css, no ``saveBtn`` /
|
||||
``saveCurrentChat`` symbol in app.js; the headless
|
||||
``persistConversation()`` upsert (PUT when linked, POST when not, the
|
||||
404→recreate fallback) is wired to the save points (the user send in
|
||||
runTurn's ``!reask`` block, ``rememberBrainTurn`` — the pagehide
|
||||
partial rides it, no direct call there) with the A2 quiet contract
|
||||
(one-line status note on failure, NO error banner, silent success)
|
||||
and the module-level ``persisting`` double-fire guard;
|
||||
* the ``bor.chat.v1`` record carries ``chatId`` (the row link survives
|
||||
reloads; a pre-55 record without the field reads as null — never
|
||||
throws);
|
||||
* the ``currentChatId`` lifecycle (set on create/open, hydrated from the
|
||||
record on the local restore, cleared by New chat and by the 404-PUT
|
||||
fallback);
|
||||
* the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the
|
||||
local restore and mirrors it to localStorage; anonymous / invalid /
|
||||
404 / network → the local restore);
|
||||
* the ship-hidden / reveal-for-admin gate on ``#save-chat-btn``.
|
||||
* the phase-55 task-03 Share contract on ``#share-chat-btn``: static,
|
||||
ALWAYS-VISIBLE markup — no ``hidden`` attribute, NO reveal step
|
||||
(no ``shareBtn.hidden`` assignment anywhere in app.js), and NEUTRAL
|
||||
error copy on a failed share (the write surface is public — no
|
||||
sign-in wording);
|
||||
* phase 55 task 04 (the share-success toast, owner-locked A4):
|
||||
``showToast`` — the SINGLE aria-hidden ``.toast`` node (lazy-created
|
||||
once, reused — no stacking; text via ``textContent``, never
|
||||
``innerHTML``; the pending dismiss cleared + reflow forced so a
|
||||
second toast re-runs the entry; ~4s auto-dismiss) is called from
|
||||
BOTH ``shareCurrentChat`` success branches with their own texts and
|
||||
NEVER from a failure branch (the error banner is the failure UI);
|
||||
styles.css ``.toast`` — fixed top-right just under the sticky header
|
||||
(z-index 1000, brand fill — --bg on --brand 5.2:1 AA, a small
|
||||
max-width, hidden by default with ``pointer-events: none``), a ~200ms
|
||||
slide-down + fade entry via ``.toast.is-visible``, and the
|
||||
reduced-motion override (transform dropped for BOTH states, the
|
||||
opacity fade kept);
|
||||
* phase 55 task 05 (the action row, owner-locked A5): a single
|
||||
``<div class="chat-actions">`` in index.html wraps BOTH pills as its
|
||||
element children (DOM order New chat → Share), replacing the two
|
||||
pills as direct children of ``.chat-shell`` — the row sits inside the
|
||||
shell, above ``#messages``, with the kb-banner / stale-banner /
|
||||
steering / announcer structure around it untouched; styles.css
|
||||
``.chat-actions`` — base ``display: flex; flex-direction: row;
|
||||
align-items: center; gap: 0.6rem`` (the row's cross-axis override of
|
||||
the column's stretch: the pills keep their intrinsic widths, side by
|
||||
side, left-aligned) and the ≤640px override
|
||||
``flex-direction: column; align-items: stretch; gap: 0.5rem`` (full-
|
||||
width stack, New chat above Share) with the existing ≤640px pill
|
||||
rules (padding, icon/label handling, the ``.chat-shell`` label
|
||||
overrides) left intact for the stacked pills.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -52,62 +94,29 @@ def _fn(js: str, name: str) -> str:
|
||||
# ---------- the Save button on the chat page ----------
|
||||
|
||||
|
||||
def test_save_button_ships_hidden_beside_new_chat() -> None:
|
||||
"""#save-chat-btn: a real type=button with the accessible name
|
||||
"Save chat", SHIPPED HIDDEN (app.js reveals it for admin only),
|
||||
beside #new-chat-btn in .chat-shell inside <main>, above
|
||||
#messages — the two chat-shell actions read as a pair. No other
|
||||
page carries it (chat-page only, like New chat)."""
|
||||
def test_save_pill_is_gone_from_the_chat_page() -> None:
|
||||
"""Phase 55 (A2): the Save control is RETIRED — there is no
|
||||
#save-chat-btn anywhere in index.html (at no width), no
|
||||
.save-chat-btn rule anywhere in styles.css (base, ≤900px squeeze,
|
||||
≤640px overrides), and no saveBtn / saveCurrentChat symbol left in
|
||||
app.js (the headless persistConversation() replaced the handler)."""
|
||||
html = _index()
|
||||
btn = re.search(r'<button[^>]*id="save-chat-btn"[^>]*>', html)
|
||||
assert btn, "index.html must contain #save-chat-btn"
|
||||
tag = btn.group(0)
|
||||
assert 'type="button"' in tag
|
||||
assert 'aria-label="Save chat"' in tag
|
||||
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
|
||||
# Beside New chat: after it, still inside .chat-shell, above #messages.
|
||||
main_idx = html.find('main id="main"')
|
||||
shell_idx = html.find('class="container chat-shell"')
|
||||
new_idx = html.find('id="new-chat-btn"')
|
||||
messages_idx = html.find('id="messages"')
|
||||
assert -1 < main_idx < shell_idx < new_idx < btn.start() < messages_idx, (
|
||||
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
|
||||
)
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
assert 'id="save-chat-btn"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the Save button is chat-page only"
|
||||
)
|
||||
|
||||
|
||||
def test_save_button_css_is_the_exact_new_chat_family() -> None:
|
||||
"""styles.css: .save-chat-btn carries the EXACT visual family of
|
||||
.new-chat-btn — solid brand pill (--bg on --brand = 5.2:1, AA),
|
||||
borderless, 999px radius, ≥44px target, hover lightens the brand
|
||||
fill; the ≤640px block mirrors the New chat overrides (label stays
|
||||
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
|
||||
assert 'id="save-chat-btn"' not in html, "index.html must not carry #save-chat-btn"
|
||||
assert "save-chat-label" not in html, "no Save label left in index.html"
|
||||
css = _css()
|
||||
block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .save-chat-btn"
|
||||
body = block.group(1)
|
||||
assert "min-height: 44px" in body
|
||||
assert "border-radius: 999px" in body
|
||||
assert "border: 0" in body
|
||||
assert "background: var(--brand)" in body, "same solid brand fill as New chat"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
|
||||
hover = re.search(r"\.save-chat-btn:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
svg = re.search(r"\.save-chat-btn svg \{([\s\S]*?)\n\}", css)
|
||||
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like New chat)"
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "mobile media query missing"
|
||||
mbody = mobile.group(1)
|
||||
assert ".save-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, "squeezes with New chat"
|
||||
assert ".save-chat-label { display: none; }" in mbody
|
||||
assert ".save-chat-btn svg { display: block; }" in mbody
|
||||
assert ".chat-shell .save-chat-label { display: inline; }" in mbody, (
|
||||
"in .chat-shell the label stays visible, as for New chat"
|
||||
assert "save-chat-btn" not in css, "styles.css must not style .save-chat-btn"
|
||||
assert "save-chat-label" not in css, "styles.css must not style .save-chat-label"
|
||||
# The ≤900px combined squeeze rule drops the Save pill (New chat +
|
||||
# auth only).
|
||||
tablet = re.search(r"@media \(max-width: 900px\) \{([\s\S]*?)\n\}", css)
|
||||
assert tablet, "tablet media query missing"
|
||||
assert ".new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }" in tablet.group(1), (
|
||||
"the tablet squeeze rule is New chat + auth only"
|
||||
)
|
||||
assert ".chat-shell .save-chat-btn svg { display: none; }" in mbody
|
||||
js = _js()
|
||||
assert "saveBtn" not in js, "no saveBtn symbol left in app.js"
|
||||
assert "saveCurrentChat" not in js, "no saveCurrentChat symbol left in app.js"
|
||||
assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
|
||||
|
||||
|
||||
# ---------- currentChatId lifecycle ----------
|
||||
@@ -115,20 +124,27 @@ def test_save_button_css_is_the_exact_new_chat_family() -> None:
|
||||
|
||||
def test_current_chat_id_module_scope_and_lifecycle() -> None:
|
||||
"""currentChatId: module scope, string | null — set to the created
|
||||
row's id on a fresh Save (201), set to the opened id on a
|
||||
successful boot load, cleared by "New chat" AND by the 404-PUT
|
||||
fallback (a stale link must never leave the conversation unsaved)."""
|
||||
row's id on a fresh auto-save (201), set to the opened id on a
|
||||
successful boot load, hydrated from the record on the local restore
|
||||
(phase 55 — the link survives reloads), cleared by "New chat" AND by
|
||||
the 404-PUT fallback (a stale link must never wedge the
|
||||
conversation)."""
|
||||
js = _js()
|
||||
assert "let currentChatId = null" in js, "module-scope link, null = unlinked"
|
||||
# Set on create: the 201 branch links to the created row's id.
|
||||
save_body = _fn(js, "saveCurrentChat")
|
||||
save_body = _fn(js, "persistConversation")
|
||||
assert "res.status === 201" in save_body
|
||||
assert "currentChatId = String(created.id)" in save_body, (
|
||||
"a fresh Save links to the created row's id"
|
||||
"a fresh auto-save links to the created row's id"
|
||||
)
|
||||
# Set on open: the boot load links to the fetched id.
|
||||
load_body = _fn(js, "restoreSavedChatFromUrl")
|
||||
assert "currentChatId = chatId" in load_body
|
||||
# Hydrated on the local restore: the record carries the link.
|
||||
restore_body = _fn(js, "restoreConversation")
|
||||
assert "currentChatId = record ? record.chatId : null" in restore_body, (
|
||||
"the local restore hydrates the link from the record (phase 55)"
|
||||
)
|
||||
# Cleared by New chat.
|
||||
new_body = _fn(js, "startNewChat")
|
||||
assert "currentChatId = null" in new_body, "New chat unlinks"
|
||||
@@ -137,24 +153,24 @@ def test_current_chat_id_module_scope_and_lifecycle() -> None:
|
||||
assert save_body.count("currentChatId = null") >= 1
|
||||
|
||||
|
||||
# ---------- the upsert branch ----------
|
||||
# ---------- the headless auto-save (phase 55, A2) ----------
|
||||
|
||||
|
||||
def test_save_upsert_put_when_linked_post_when_not() -> None:
|
||||
"""saveCurrentChat: linked → PUT /api/chats/<id> with the messages
|
||||
payload (re-Save updates the SAME row — no title in the body, so the
|
||||
row keeps its current one); unlinked → POST /api/chats (the server
|
||||
auto-titles). The 404 from the PUT unlinks and retries as a create.
|
||||
Empty conversation → no request, live-region "Nothing to save
|
||||
yet."; success → live-region "Conversation saved." (status text
|
||||
only, no banner); 403/5xx/network → the error banner."""
|
||||
def test_persist_conversation_upsert_semantics() -> None:
|
||||
"""persistConversation (the headless replacement of the phase-50
|
||||
Save handler): the EXACT upsert semantics, unchanged — linked →
|
||||
PUT /api/chats/<id> with the messages payload (the SAME row updates
|
||||
— no title in the body, so the row keeps its current one);
|
||||
unlinked → POST /api/chats (the server auto-titles) and link to the
|
||||
created id (201). The 404 from the PUT unlinks and retries as a
|
||||
create — a stale link can never wedge the conversation. Empty
|
||||
conversation → no-op (no request, no feedback line)."""
|
||||
js = _js()
|
||||
body = _fn(js, "saveCurrentChat")
|
||||
# No-op first: nothing to save → live-region line, no fetch.
|
||||
noop = body.find('sendStatus.textContent = "Nothing to save yet."')
|
||||
body = _fn(js, "persistConversation")
|
||||
# No-op first: nothing to save → silent return before any fetch.
|
||||
noop = body.find("if (!conversation.length) return;")
|
||||
first_fetch = body.find("await fetch(")
|
||||
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
|
||||
assert "if (!conversation.length)" in body
|
||||
# The branch: PUT when linked, POST when not.
|
||||
assert "if (currentChatId)" in body
|
||||
assert '`/api/chats/${currentChatId}`' in body
|
||||
@@ -170,46 +186,125 @@ def test_save_upsert_put_when_linked_post_when_not() -> None:
|
||||
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"
|
||||
# Success is status text only — the live region, never stale — and
|
||||
# nothing between the 201 link and the success line may raise a
|
||||
# banner (the !res.ok branch returns before either).
|
||||
assert body.count('sendStatus.textContent = "Conversation saved."') == 1
|
||||
saved_line = 'sendStatus.textContent = "Conversation saved."'
|
||||
between = body[body.find("res.status === 201") : body.find(saved_line)]
|
||||
assert "showErrorBanner" not in between, "no banner on the success path"
|
||||
# Failures raise an actionable banner (non-ok HTTP + network).
|
||||
assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body
|
||||
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
|
||||
# The double-click guard releases on EVERY outcome.
|
||||
finally_idx = body.rfind("finally")
|
||||
assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], (
|
||||
"the button is re-enabled in the finally — never stale"
|
||||
# The 201 branch links to the created row.
|
||||
assert "res.status === 201" in body
|
||||
assert "currentChatId = String(created.id)" in body
|
||||
|
||||
|
||||
def test_persist_conversation_is_headless_and_quiet() -> None:
|
||||
"""The A2 quiet contract: NO error banner anywhere in the helper
|
||||
(the phase-50 banner lines are gone), NO success status text
|
||||
("Conversation saved." is retired — success is silent; the History
|
||||
page is the visible proof), and the failure feedback is the
|
||||
one-line #send-status note — on BOTH failure paths (non-ok HTTP and
|
||||
network) — with the "next save point retries" promise. The
|
||||
module-level `persisting` flag is the double-fire guard (released
|
||||
in the finally — never stuck)."""
|
||||
js = _js()
|
||||
body = _fn(js, "persistConversation")
|
||||
assert "showErrorBanner" not in body, "A2: a failed auto-save never raises a banner"
|
||||
assert "Conversation saved." not in body, "A2: success is silent (no status text)"
|
||||
note = "Couldn't save automatically — will try on the next message."
|
||||
assert body.count(note) == 2, "the one-line note covers non-ok AND network failure"
|
||||
# The non-ok branch notes and returns (no banner, no 201 handling).
|
||||
notok = body.find("if (!res.ok)")
|
||||
first_note = body.find(note)
|
||||
assert -1 < notok < first_note, "the non-ok branch lands on the one-line note"
|
||||
# The network path (catch) notes too.
|
||||
catch_idx = body.find("} catch {")
|
||||
assert catch_idx != -1 and first_note < body.rfind(note) < body.rfind("finally"), (
|
||||
"the catch branch carries the second note"
|
||||
)
|
||||
# The module-level double-fire guard, released on EVERY outcome.
|
||||
assert re.search(r"^let persisting = false", js, re.M), (
|
||||
"the persisting flag is module scope (save points can overlap)"
|
||||
)
|
||||
assert "if (persisting) return;" in body, "an in-flight upsert skips the second call"
|
||||
assert "persisting = true;" in body
|
||||
finally_idx = body.rfind("finally")
|
||||
assert finally_idx != -1 and "persisting = false" in body[finally_idx:], (
|
||||
"the flag is released in the finally — never stuck"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_save_wired_to_the_save_points() -> None:
|
||||
"""The headless helper is referenced from the save points: the user
|
||||
send (runTurn's ``!reask`` block — after the localStorage
|
||||
saveConversation()) and the brain save point (rememberBrainTurn —
|
||||
after its saveConversation()). The pagehide partial rides
|
||||
rememberBrainTurn: NO second direct call there."""
|
||||
js = _js()
|
||||
# Save point 1: the user send in runTurn's !reask block.
|
||||
turn = js.find("async function runTurn")
|
||||
reask_block = js[js.find("if (!reask) {", turn) : js.find("let wrap = null", turn)]
|
||||
save1 = reask_block.find("saveConversation();")
|
||||
persist1 = reask_block.find("persistConversation();")
|
||||
assert -1 < save1 < persist1, "the user-send save point rides persistConversation()"
|
||||
# Save point 2: rememberBrainTurn (the brain-done + stop + pagehide path).
|
||||
body = _fn(js, "rememberBrainTurn")
|
||||
save2 = body.find("saveConversation();")
|
||||
persist2 = body.find("persistConversation();")
|
||||
assert -1 < save2 < persist2, "the brain save point rides persistConversation()"
|
||||
# The pagehide handler itself carries no direct persist call — the
|
||||
# partial rides rememberBrainTurn (no extra wiring, phase 20
|
||||
# contract untouched).
|
||||
m = re.search(r'window\.addEventListener\("pagehide", \(\) => \{([\s\S]*?)\n\}\);', js)
|
||||
assert m, "the pagehide handler must exist"
|
||||
assert "persistConversation" not in m.group(1), (
|
||||
"the pagehide partial rides rememberBrainTurn — no second call"
|
||||
)
|
||||
|
||||
|
||||
def test_record_carries_the_row_link() -> None:
|
||||
"""Phase 55 (A2): the bor.chat.v1 record carries ``chatId``. The
|
||||
write (saveConversation) persists the CURRENT currentChatId (null
|
||||
when unlinked) with the versioned record; the reader
|
||||
(loadStoredRecord) reads it back with old-record safety — a
|
||||
pre-55 record without the field (or a non-string) reads as null,
|
||||
never throws; the restore validates the version before trusting
|
||||
anything."""
|
||||
js = _js()
|
||||
save_body = _fn(js, "saveConversation")
|
||||
assert "chatId: currentChatId" in save_body, ("the write persists the current link")
|
||||
assert "v: STORAGE_VERSION" in save_body
|
||||
assert "trimToBudget(conversation)" in save_body
|
||||
read_body = _fn(js, "loadStoredRecord")
|
||||
assert "data.v !== STORAGE_VERSION" in read_body, "version validated first"
|
||||
assert "Array.isArray(data.messages)" in read_body
|
||||
# Old-record safety: optional field, string check, null fallback.
|
||||
assert 'typeof data.chatId === "string"' in read_body
|
||||
assert "data.chatId.length ? data.chatId : null" in read_body
|
||||
# The defensive message filter survives the reshape.
|
||||
assert 'm.who === "user" || m.who === "brain"' in read_body
|
||||
assert 'typeof m.text === "string"' in read_body
|
||||
|
||||
|
||||
# ---------- boot-load precedence ----------
|
||||
|
||||
|
||||
def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
|
||||
"""Inside the boot IIFE: after fetchIsAdmin() + the reveal gate,
|
||||
restoreSavedChatFromUrl() runs; only when it returns false does the
|
||||
phase-14 local restore run. Header init stays first (shared-module
|
||||
contract)."""
|
||||
"""Inside the boot IIFE: after fetchIsAdmin(), restoreSavedChatFromUrl()
|
||||
runs; only when it returns false does the phase-14 local restore run
|
||||
(which hydrates the row link from the record — phase 55). Header init
|
||||
stays first (shared-module contract). The phase-50 Save-reveal line is
|
||||
GONE — and phase 55 task 03 removed the Share-reveal line too (the
|
||||
pill is static, always-visible markup: no reveal step at boot)."""
|
||||
js = _js()
|
||||
boot_start = js.find("(async () => {")
|
||||
assert boot_start != -1, "the boot IIFE must exist"
|
||||
boot = js[boot_start:]
|
||||
init_i = boot.find("await initSharedHeader();")
|
||||
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
|
||||
reveal_i = boot.find("saveBtn.hidden = !isAdmin")
|
||||
saved_i = boot.find("await restoreSavedChatFromUrl();")
|
||||
local_i = boot.find("restoreConversation();")
|
||||
assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, (
|
||||
"boot order: header init → whoami → Save reveal → ?chat= load → local fallback"
|
||||
assert -1 < init_i < admin_i < saved_i < local_i, (
|
||||
"boot order: header init → whoami → ?chat= load → local fallback"
|
||||
)
|
||||
assert "shareBtn.hidden" not in boot, ("no Share-reveal line left in boot (phase 55 task 03)")
|
||||
assert "if (!openedSaved) restoreConversation();" in boot, (
|
||||
"the local restore runs ONLY when the saved-chat load did not open"
|
||||
)
|
||||
assert "saveBtn" not in boot, "no Save-reveal line left in boot (phase 55)"
|
||||
|
||||
|
||||
def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
|
||||
@@ -261,20 +356,17 @@ def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
|
||||
# ---------- the reveal gate ----------
|
||||
|
||||
|
||||
def test_save_button_revealed_only_for_admin() -> None:
|
||||
"""The ship-hidden/reveal-for-admin contract: app.js queries
|
||||
#save-chat-btn, binds the click to saveCurrentChat, and the boot
|
||||
IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden —
|
||||
hidden is display:none, no trace for anonymous)."""
|
||||
def test_no_save_pill_wiring_left_in_app_js() -> None:
|
||||
"""Phase 55 (A2): the Save pill's wiring is GONE — no
|
||||
#save-chat-btn query, no click binding to a save handler, no boot
|
||||
reveal line. The headless persistConversation() replaces all of it
|
||||
(no button, no admin gate: every visitor's conversation auto-saves
|
||||
— the write surface is public, phase 55 task 01)."""
|
||||
js = _js()
|
||||
assert 'document.querySelector("#save-chat-btn")' in js
|
||||
assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js
|
||||
assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
|
||||
# The reveal happens in the boot IIFE (after whoami), not at module
|
||||
# evaluation (isAdmin is false there).
|
||||
boot_start = js.find("(async () => {")
|
||||
reveal = js.find("saveBtn.hidden = !isAdmin")
|
||||
assert boot_start < reveal, "the reveal must run at boot, after whoami resolves"
|
||||
assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
|
||||
assert "saveCurrentChat" not in js, "the button handler is gone"
|
||||
assert 'addEventListener("click", saveCurrentChat)' not in js, "no save binding"
|
||||
assert "saveBtn" not in js, "no saveBtn symbol anywhere (the tune form uses its own)"
|
||||
|
||||
|
||||
def test_boot_load_adds_no_direct_storage_access() -> None:
|
||||
@@ -296,29 +388,32 @@ def test_no_cdn_added() -> None:
|
||||
# ---------- the Share button on the chat page (phase 51, task 02) ----------
|
||||
|
||||
|
||||
def test_share_button_ships_hidden_beside_save() -> None:
|
||||
def test_share_button_ships_visible_beside_new_chat() -> None:
|
||||
"""#share-chat-btn: a real type=button with the accessible name
|
||||
"Share chat", SHIPPED HIDDEN (app.js reveals it for admin only),
|
||||
BESIDE #save-chat-btn in .chat-shell inside <main>, above
|
||||
#messages — the chat-shell actions read as a pair (Save | Share).
|
||||
No other page carries it (chat-page only, like Save)."""
|
||||
"Share chat", SHIPPED VISIBLE to every visitor (phase 55 task 03 —
|
||||
NO ``hidden`` attribute, no reveal step; the phase-51 admin-only
|
||||
ship-hidden gate is gone), BESIDE #new-chat-btn in .chat-shell
|
||||
inside <main>, above #messages — the chat-shell actions read as a
|
||||
pair (New chat | Share; the Save pill is gone, phase 55). No other
|
||||
page carries it (chat-page only, like New chat)."""
|
||||
html = _index()
|
||||
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
|
||||
assert btn, "index.html must contain #share-chat-btn"
|
||||
tag = btn.group(0)
|
||||
assert 'type="button"' in tag
|
||||
assert 'aria-label="Share chat"' in tag
|
||||
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
|
||||
assert "hidden" not in tag, ("the button ships visible — no reveal step (phase 55 task 03)")
|
||||
# The label: the visible text is "Share" (the link SVG is aria-hidden
|
||||
# decoration; the aria-label carries the accessible name).
|
||||
btn_block = html[btn.start() : html.find("</button>", btn.start())]
|
||||
assert '>Share</span>' in btn_block
|
||||
# Beside Save: after it, still inside .chat-shell, above #messages.
|
||||
# Beside New chat (the Save pill is gone): after it, still inside
|
||||
# .chat-shell, above #messages.
|
||||
shell_idx = html.find('class="container chat-shell"')
|
||||
save_idx = html.find('id="save-chat-btn"')
|
||||
new_idx = html.find('id="new-chat-btn"')
|
||||
messages_idx = html.find('id="messages"')
|
||||
assert -1 < shell_idx < save_idx < btn.start() < messages_idx, (
|
||||
"the button must sit beside #save-chat-btn in .chat-shell, above #messages"
|
||||
assert -1 < shell_idx < new_idx < btn.start() < messages_idx, (
|
||||
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
|
||||
)
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
@@ -328,11 +423,13 @@ def test_share_button_ships_hidden_beside_save() -> None:
|
||||
|
||||
|
||||
def test_share_button_css_is_the_exact_save_family() -> None:
|
||||
"""styles.css: .share-chat-btn carries the EXACT visual family of
|
||||
.save-chat-btn (same solid brand pill — --bg on --brand = 5.2:1, AA;
|
||||
borderless; 999px radius; ≥44px target; hover lightens the brand
|
||||
fill); the ≤640px block mirrors the Save overrides (label stays
|
||||
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
|
||||
"""styles.css: .share-chat-btn carries the EXACT visual family of the
|
||||
phase-50 Save pill (now the .new-chat-btn family — the Save rules
|
||||
are gone with the pill, phase 55): same solid brand pill — --bg on
|
||||
--brand = 5.2:1, AA; borderless; 999px radius; ≥44px target; hover
|
||||
lightens the brand fill; the ≤640px block mirrors the New chat
|
||||
overrides (label stays visible in .chat-shell, icon hidden there).
|
||||
"""
|
||||
css = _css()
|
||||
block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .share-chat-btn"
|
||||
@@ -368,9 +465,11 @@ def test_share_current_chat_save_then_share_branch() -> None:
|
||||
copied — the clipboard try succeeds → the live region reads
|
||||
"Share link copied."; the rejection (a non-secure http origin)
|
||||
renders the .share-link-fallback field + "Share link ready — copy it
|
||||
from the field." 403/5xx → the actionable banner (signed-out hint);
|
||||
network → the reachable? banner. The double-click guard releases in
|
||||
the finally — never stale."""
|
||||
from the field." 403/5xx → the actionable banner (NEUTRAL "try
|
||||
again" — the write surface is public, phase 55 task 01, so a 403
|
||||
is no longer a sign-in problem for a guest); network → the
|
||||
reachable? banner. The double-click guard releases in the finally —
|
||||
never stale."""
|
||||
js = _js()
|
||||
body = _fn(js, "shareCurrentChat")
|
||||
# No-op first: nothing to share → live-region line, no fetch.
|
||||
@@ -405,11 +504,15 @@ def test_share_current_chat_save_then_share_branch() -> None:
|
||||
' : "Share link ready — copy it from the field."'
|
||||
) in body
|
||||
# Failures raise an actionable banner (non-ok HTTP + network).
|
||||
# Phase 55 task 03: the 403/5xx copy is NEUTRAL ("try again") — no
|
||||
# sign-in wording anywhere in the share handler (the write surface
|
||||
# is public); the network banner keeps its own line.
|
||||
assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body
|
||||
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
|
||||
assert body.count("check you're still signed in and try again") == 2, (
|
||||
"both the linked and the unlinked branch carry the non-ok banner"
|
||||
neutral = "Couldn't share the conversation — try again."
|
||||
assert body.count(neutral) == 2, (
|
||||
"both the linked and the unlinked branch carry the neutral non-ok banner"
|
||||
)
|
||||
assert "signed in" not in body, "no sign-in wording left in the share handler (task 03)"
|
||||
# The double-click guard releases on EVERY outcome.
|
||||
finally_idx = body.rfind("finally")
|
||||
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
|
||||
@@ -430,23 +533,239 @@ def test_share_current_chat_save_then_share_branch() -> None:
|
||||
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
|
||||
|
||||
|
||||
def test_share_button_revealed_only_for_admin() -> None:
|
||||
"""The ship-hidden/reveal-for-admin contract: app.js queries
|
||||
#share-chat-btn, binds the click to shareCurrentChat, and the boot
|
||||
IIFE sets shareBtn.hidden = !isAdmin in the SAME admin-reveal block
|
||||
as Save (phase 16 absent-not-hidden — no trace for anonymous)."""
|
||||
def test_share_button_has_no_reveal_gate() -> None:
|
||||
"""Phase 55 task 03: the pill is VISIBLE TO EVERY VISITOR — app.js
|
||||
queries #share-chat-btn and binds the click to shareCurrentChat, but
|
||||
there is NO reveal step: no ``shareBtn.hidden`` assignment ANYWHERE
|
||||
in app.js (the phase-51 ship-hidden/admin-reveal gate is gone; the
|
||||
markup ships visible and task 01 opened the write surface to all).
|
||||
"""
|
||||
js = _js()
|
||||
assert 'document.querySelector("#share-chat-btn")' in js
|
||||
assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js
|
||||
assert "shareBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
|
||||
# The reveal happens in the boot IIFE (after whoami), not at module
|
||||
# evaluation — and right next to Save's own reveal line.
|
||||
boot_start = js.find("(async () => {")
|
||||
reveal = js.find("shareBtn.hidden = !isAdmin")
|
||||
save_reveal = js.find("saveBtn.hidden = !isAdmin")
|
||||
assert boot_start < save_reveal < reveal, (
|
||||
"the Share reveal joins the same admin-reveal block as Save"
|
||||
assert "shareBtn.hidden" not in js, "no reveal step — the pill ships visible to all"
|
||||
|
||||
|
||||
# ---------- the share-success toast (phase 55, task 04, A4) ----------
|
||||
|
||||
|
||||
def test_show_toast_helper_single_instance_and_aria_hidden() -> None:
|
||||
"""showToast (A4 owner-locked): a SINGLE node — lazy-created on the
|
||||
first call and REUSED thereafter (toasts never stack), a plain
|
||||
``<div class="toast">`` appended to ``document.body``; the text
|
||||
lands via ``textContent`` (XSS-safe — never innerHTML); the node is
|
||||
``aria-hidden="true"`` (visual only — #send-status is the
|
||||
announcer). Re-triggering the entry (a second share while the first
|
||||
toast is up): clear the pending dismiss timer, remove the visible
|
||||
state class, force a reflow (``offsetWidth`` — restarts the CSS
|
||||
transition), re-add the class. Auto-dismiss: a 4000ms timer set
|
||||
AFTER the visible class is added, removing the class on fire."""
|
||||
js = _js()
|
||||
body = _fn(js, "showToast")
|
||||
# Lazy single instance, appended to <body>, marked visual-only.
|
||||
assert "if (!toastEl)" in body, "the node is created once, on first use"
|
||||
assert 'document.createElement("div")' in body
|
||||
assert 'toastEl.className = "toast"' in body
|
||||
assert 'toastEl.setAttribute("aria-hidden", "true")' in body, ("A4: visual only")
|
||||
assert "document.body.appendChild(toastEl)" in body
|
||||
# textContent only — never innerHTML.
|
||||
assert "toastEl.textContent = message" in body
|
||||
assert "innerHTML" not in body, "XSS contract: textContent only"
|
||||
# Single instance: module-scope node + timer, reused (no stacking).
|
||||
assert re.search(r"^let toastEl = null", js, re.M), ("the node is module scope")
|
||||
assert re.search(r"^let toastTimer = 0", js, re.M), ("the timer is module scope")
|
||||
# Re-trigger order: clear dismiss → remove class → force reflow →
|
||||
# re-add the visible class.
|
||||
clear_i = body.find("clearTimeout(toastTimer)")
|
||||
remove_i = body.find('toastEl.classList.remove("is-visible")')
|
||||
reflow_i = body.find("void toastEl.offsetWidth")
|
||||
add_i = body.find('toastEl.classList.add("is-visible")')
|
||||
assert -1 < clear_i < remove_i < reflow_i < add_i, (
|
||||
"dismiss cleared → class removed → reflow forced → visible re-added"
|
||||
)
|
||||
# Auto-dismiss ~4s, armed AFTER the visible class is set.
|
||||
timer_i = body.find("setTimeout")
|
||||
assert -1 < add_i < timer_i and "4000" in body
|
||||
assert 'toastEl.classList.remove("is-visible")' in body[timer_i:], (
|
||||
"the pending dismiss removes the visible state"
|
||||
)
|
||||
|
||||
|
||||
def test_toast_called_from_both_share_success_branches_only() -> None:
|
||||
"""shareCurrentChat (task 04): BOTH success paths call showToast
|
||||
with their own text — the clipboard path → "Share link copied.",
|
||||
the fallback-field path → "Share link ready — copy it from the
|
||||
field." — and both calls ride the SUCCESS branch (after the copy,
|
||||
after the untouched #send-status live-region lines). showToast
|
||||
appears EXACTLY twice in the handler and never in a failure branch
|
||||
(the two !res.ok banners precede the copy; the network catch —
|
||||
the error banner is the failure UI — carries no toast)."""
|
||||
js = _js()
|
||||
body = _fn(js, "shareCurrentChat")
|
||||
assert body.count("showToast(") == 2, "exactly one toast per success path"
|
||||
copy_i = body.find("copyShareLinkWithFallback(absoluteShareUrl(shareUrl))")
|
||||
assert copy_i != -1, "the copy (the success branch) must exist"
|
||||
t1 = body.find('showToast("Share link copied.")')
|
||||
t2 = body.find('showToast("Share link ready — copy it from the field.")')
|
||||
assert t1 != -1 and t2 != -1, "both success paths toast their own text"
|
||||
assert -1 < copy_i < min(t1, t2), ("the toasts ride the SUCCESS branch (after the copy)")
|
||||
# The #send-status lines stay exactly as they were (the a11y
|
||||
# announcer) and precede the toast calls.
|
||||
status_i = body.find("sendStatus.textContent = copied")
|
||||
assert -1 < status_i < min(t1, t2)
|
||||
# Never on failure: the catch block carries no toast.
|
||||
catch_i = body.rfind("} catch {")
|
||||
assert catch_i != -1 and "showToast" not in body[catch_i:], (
|
||||
"a failed share shows the error banner, no toast"
|
||||
)
|
||||
|
||||
|
||||
def test_toast_css_top_right_brand_family_and_reduced_motion() -> None:
|
||||
"""styles.css (task 04): .toast — position: fixed, top-right just
|
||||
under the sticky header (--header-h + offset — the variable steps
|
||||
64px → 58px at ≤640px), z-index 1000 (the modal overlay contract —
|
||||
above the header's 20), a small max-width so long text wraps, the
|
||||
solid brand fill (--bg text on --brand = 5.2:1, AA — the
|
||||
.new-chat-btn family), rounded + shadowed. Hidden by default
|
||||
(opacity 0 + pointer-events: none — it never intercepts clicks when
|
||||
idle) and resting at translateY(-8px), with the ~200ms entry
|
||||
transition; .toast.is-visible lands at opacity 1 / translateY(0).
|
||||
Under prefers-reduced-motion: reduce the transform is dropped for
|
||||
BOTH states (.is-visible would otherwise out-specify the bare
|
||||
.toast) and the opacity fade remains."""
|
||||
css = _css()
|
||||
block = re.search(r"\.toast \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .toast"
|
||||
body = block.group(1)
|
||||
for prop in (
|
||||
"position: fixed",
|
||||
"top: calc(var(--header-h) + 0.75rem)",
|
||||
"right: 1rem",
|
||||
"z-index: 1000",
|
||||
"max-width: min(22rem, calc(100vw - 2rem))",
|
||||
"background: var(--brand)",
|
||||
"color: var(--bg)",
|
||||
"border-radius: var(--radius-sm)",
|
||||
"box-shadow: var(--shadow)",
|
||||
"opacity: 0",
|
||||
"pointer-events: none",
|
||||
"transform: translateY(-8px)",
|
||||
):
|
||||
assert prop in body, f".toast must keep {prop}"
|
||||
assert "transition:" in body and "200ms" in body, ("the entry is a ~200ms slide-down + fade")
|
||||
visible = re.search(r"\.toast\.is-visible \{([\s\S]*?)\n\}", css)
|
||||
assert visible, "the .toast.is-visible state class (toggled by showToast) must exist"
|
||||
assert "opacity: 1" in visible.group(1)
|
||||
assert "transform: translateY(0)" in visible.group(1)
|
||||
# The reduced-motion override: transform dropped (BOTH states
|
||||
# named), the opacity fade kept.
|
||||
rm = None
|
||||
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css):
|
||||
if ".toast" in m.group(1):
|
||||
rm = m.group(1)
|
||||
break
|
||||
assert rm is not None, "a reduced-motion block must cover .toast"
|
||||
assert ".toast.is-visible { transform: none; }" in rm, ("the slide is dropped for BOTH states")
|
||||
assert re.search(r"\.toast \{ transition: opacity", rm), ("the opacity fade remains")
|
||||
|
||||
|
||||
# ---------- the chat-actions row (phase 55, task 05, A5) ----------
|
||||
|
||||
|
||||
def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
"""index.html (task 05, A5): ONE ``<div class="chat-actions">``
|
||||
wraps BOTH pills — its element children are exactly the two
|
||||
buttons, in the A5 order New chat → Share. The wrapper replaces
|
||||
the two pills as direct children of ``.chat-shell`` (a normal
|
||||
column child): inside the shell, above ``#messages``; nothing else
|
||||
lands between the steering announcer and the row, and nothing but
|
||||
the phase-49 comment lands between the row and ``#messages``. No
|
||||
other page carries ``.chat-actions`` (chat-page only, like the
|
||||
pills)."""
|
||||
html = _index()
|
||||
start = html.find('<div class="chat-actions">')
|
||||
assert start != -1, "index.html must carry the .chat-actions wrapper"
|
||||
end = html.find("</div>", start)
|
||||
assert end != -1, "the wrapper must close"
|
||||
wrap = html[start:end]
|
||||
# Exactly two element children: the two pill buttons, New chat first.
|
||||
assert wrap.count("<div") == 1, "no nested div inside the row wrapper"
|
||||
assert wrap.count("<button") == 2, "the row holds exactly the two pills"
|
||||
new_i = wrap.find('id="new-chat-btn"')
|
||||
share_i = wrap.find('id="share-chat-btn"')
|
||||
assert -1 < new_i < share_i, "A5 order: New chat first, then Share"
|
||||
# Position: a .chat-shell column child, above #messages — the
|
||||
# kb-banner / stale-banner / steering / announcer structure is
|
||||
# untouched (nothing else with an id around the row).
|
||||
shell_idx = html.find('class="container chat-shell"')
|
||||
messages_idx = html.find('id="messages"')
|
||||
assert -1 < shell_idx < start < end < messages_idx, (
|
||||
"the row is a .chat-shell column child, above #messages"
|
||||
)
|
||||
ann_idx = html.find('id="steering-announcer"')
|
||||
between = html[html.find("</p>", ann_idx):start]
|
||||
assert "id=" not in between and "<button" not in between, (
|
||||
"no other element lands between the announcer and the row"
|
||||
)
|
||||
after = html[end:messages_idx]
|
||||
assert "id=" not in after and "<button" not in after, (
|
||||
"nothing but the phase-49 comment lands between the row and #messages"
|
||||
)
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the action row is chat-page only"
|
||||
)
|
||||
|
||||
|
||||
def test_chat_actions_row_on_desktop_and_stack_at_640() -> None:
|
||||
"""styles.css (task 05, A5): the base ``.chat-actions`` rule is a
|
||||
horizontal flex row — ``display: flex; flex-direction: row;
|
||||
align-items: center; gap: 0.6rem``. The ``align-items: center`` is
|
||||
load-bearing: the wrapper is a flex ITEM of the ``.chat-shell``
|
||||
column (which stretches its items), and the row's own
|
||||
cross-axis ``center`` (not the column default ``stretch``) keeps
|
||||
each pill at its intrinsic content width — two pills side by side,
|
||||
left-aligned, never full-column. The ≤640px override flips the
|
||||
row to a full-width vertical stack — ``flex-direction: column;
|
||||
align-items: stretch; gap: 0.5rem`` (New chat above Share) — and
|
||||
the EXISTING ≤640px pill rules (padding squeeze, the icon/label
|
||||
handling, the ``.chat-shell`` label overrides) stay in place for
|
||||
the stacked pills."""
|
||||
css = _css()
|
||||
block = re.search(r"\.chat-actions \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .chat-actions (base row)"
|
||||
for prop in (
|
||||
"display: flex",
|
||||
"flex-direction: row",
|
||||
"align-items: center",
|
||||
"gap: 0.6rem",
|
||||
):
|
||||
assert prop in block.group(1), f".chat-actions must keep {prop}"
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "mobile media query missing"
|
||||
mbody = mobile.group(1)
|
||||
m = re.search(r"\.chat-actions \{([^}]*)\}", mbody)
|
||||
assert m, "the ≤640px override (vertical stack) must exist"
|
||||
for prop in (
|
||||
"flex-direction: column",
|
||||
"align-items: stretch",
|
||||
"gap: 0.5rem",
|
||||
):
|
||||
assert prop in m.group(1), f"the ≤640px .chat-actions must keep {prop}"
|
||||
# The stacked pills keep their existing mobile treatment (the rules
|
||||
# the phase-50/51 pairs established — untouched by this task).
|
||||
for rule in (
|
||||
".new-chat-btn { padding: 0.4rem 0.3rem; }",
|
||||
".share-chat-btn { padding: 0.4rem 0.3rem; }",
|
||||
".new-chat-label { display: none; }",
|
||||
".share-chat-label { display: none; }",
|
||||
".chat-shell .new-chat-label { display: inline; }",
|
||||
".chat-shell .new-chat-btn svg { display: none; }",
|
||||
".chat-shell .share-chat-label { display: inline; }",
|
||||
".chat-shell .share-chat-btn svg { display: none; }",
|
||||
):
|
||||
assert rule in mbody, f"the existing ≤640px pill rule must stay: {rule}"
|
||||
|
||||
|
||||
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
|
||||
@@ -571,7 +890,7 @@ 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
|
||||
follows persistConversation'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);
|
||||
@@ -616,19 +935,22 @@ def test_stale_regenerate_binding_and_element_queries() -> None:
|
||||
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
|
||||
|
||||
|
||||
def test_stale_banner_cleared_on_new_chat_and_resave() -> None:
|
||||
def test_stale_banner_cleared_on_new_chat_and_autosave() -> 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."""
|
||||
auto-save re-stamps the row to the current generation (task 03) —
|
||||
the banner is done the moment the save succeeds (on the success
|
||||
path only — after the !res.ok early return and the 201 link)."""
|
||||
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"
|
||||
save_body = _fn(js, "persistConversation")
|
||||
notok_idx = save_body.find("if (!res.ok)")
|
||||
two01_idx = save_body.find("res.status === 201")
|
||||
hide_idx = save_body.find("staleBanner.hidden = true")
|
||||
catch_idx = save_body.find("} catch {")
|
||||
assert -1 < notok_idx < two01_idx < hide_idx < catch_idx, (
|
||||
"the banner clears on the success path, after the 201 link, never on failure"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user