feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
@@ -17,7 +17,13 @@ regression is caught without a browser:
|
||||
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
|
||||
+ ``header.js``'s reveal-for-admin block;
|
||||
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
|
||||
the empty-state row.
|
||||
the empty-state row;
|
||||
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
|
||||
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
|
||||
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
|
||||
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
|
||||
between Updated and Share in ``history.html``, and the ``.stale-pill``
|
||||
rose-family CSS in ``styles.css``.
|
||||
|
||||
The Containerfile stage-1 coverage (history.html copied, history.js
|
||||
bundled) is pinned dynamically by
|
||||
@@ -177,35 +183,39 @@ def test_history_page_scaffold_and_landmarks() -> None:
|
||||
|
||||
|
||||
def test_history_table_skeleton() -> None:
|
||||
"""The table skeleton: ``.history-table`` with the five columns —
|
||||
Title | Messages | Updated | Share (phase 51) | Actions (the
|
||||
Actions header text is visually-hidden — the row buttons carry
|
||||
their own aria-labels) — and the empty-state row (ship-hidden, the
|
||||
exact copy)."""
|
||||
"""The table skeleton: ``.history-table`` with the six columns —
|
||||
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
|
||||
Actions (the Actions header text is visually-hidden — the row
|
||||
buttons carry their own aria-labels) — and the empty-state row
|
||||
(ship-hidden, the exact copy)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
assert '<table class="history-table">' in html
|
||||
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
|
||||
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
|
||||
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
|
||||
'<th scope="col">Share</th>'):
|
||||
assert col in html
|
||||
# The Share column sits BETWEEN Updated and Actions.
|
||||
# The Stale column (phase 53) sits BETWEEN Updated and Share —
|
||||
# i.e. between Updated and Actions — so the phase-51 contract
|
||||
# (Share between Updated and Actions) still holds.
|
||||
assert (
|
||||
html.find('<th scope="col">Updated</th>')
|
||||
< html.find('<th scope="col">Stale</th>')
|
||||
< html.find('<th scope="col">Share</th>')
|
||||
< html.find('visually-hidden">Actions')
|
||||
), "the Share column must sit between Updated and Actions"
|
||||
), "the Stale column must sit between Updated and Share"
|
||||
actions_th = re.search(
|
||||
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
|
||||
html,
|
||||
)
|
||||
assert actions_th, "the Actions column header must be visually-hidden text"
|
||||
assert actions_th.group(1) == "", "no visible text beside the hidden header"
|
||||
# The empty-state row: ship-hidden, colspan 5 (the Share column
|
||||
# joined the table in phase 51), the exact copy.
|
||||
# The empty-state row: ship-hidden, colspan 6 (phase 51 added
|
||||
# Share, phase 53 added Stale), the exact copy.
|
||||
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
|
||||
assert row, "the empty-state row must ship in the skeleton"
|
||||
assert "hidden" in row.group(0)
|
||||
assert 'id="history-empty-row"' in row.group(0)
|
||||
assert "<td colspan=\"5\">" in html
|
||||
assert "<td colspan=\"6\">" in html
|
||||
assert (
|
||||
"No saved chats yet — finish a conversation and press"
|
||||
" <strong>Save</strong> in the chat."
|
||||
@@ -446,20 +456,91 @@ def test_history_table_mobile_behavior() -> None:
|
||||
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
|
||||
"""makeRow: the Share <td> (with the share control) lands BETWEEN
|
||||
the Updated cell and the Actions cell — the column order in
|
||||
history.html is Title | Messages | Updated | Share | Actions."""
|
||||
history.html is Title | Messages | Updated | Stale (phase 53) |
|
||||
Share | Actions."""
|
||||
js = _js()
|
||||
row = _fn(js, "makeRow")
|
||||
updated_i = row.find('updatedTd.className = "history-updated-cell"')
|
||||
stale_i = row.find('staleTd.className = "history-stale-cell"')
|
||||
share_i = row.find('shareTd.className = "history-share-cell"')
|
||||
actions_i = row.find('actionsTd.className = "history-actions-cell"')
|
||||
assert -1 < updated_i < share_i < actions_i, (
|
||||
"the share cell must sit between Updated and Actions"
|
||||
assert -1 < updated_i < stale_i < share_i < actions_i, (
|
||||
"the stale cell (phase 53) must sit between Updated and Share —"
|
||||
" i.e. the share cell must still sit between Updated and Actions"
|
||||
)
|
||||
assert "makeShareControl(chat)" in row
|
||||
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
|
||||
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
|
||||
f"row cell order must be title/count/updated/share/actions, got {seq}"
|
||||
assert seq == [
|
||||
"titleTd", "countTd", "updatedTd", "staleTd", "shareTd", "actionsTd",
|
||||
], f"row cell order must be title/count/updated/stale/share/actions, got {seq}"
|
||||
|
||||
|
||||
# ---------- the Stale column (phase 53, task 04) ----------
|
||||
|
||||
|
||||
def test_stale_cell_branches_on_row_flag_with_aria_label() -> None:
|
||||
"""makeRow (phase 53 task 04): the Stale cell renders from the
|
||||
row's ``stale`` flag — the SERVER computes staleness (task 03),
|
||||
the client never does version math. Stale rows: the rose
|
||||
``.stale-pill`` (the ``Stale`` text + the EXACT hover copy pointing
|
||||
at the Regenerate action on the chat page, task 05). Fresh rows:
|
||||
a plain em-dash (no pill). The <td> carries its own aria-label in
|
||||
BOTH states (WCAG 2.1 AA — the marker must be conveyed without the
|
||||
visual). READ-ONLY badge: the cell binds no events and creates no
|
||||
controls (the Regenerate button lives on the chat-page banner);
|
||||
textContent only (XSS contract)."""
|
||||
js = _js()
|
||||
row = _fn(js, "makeRow")
|
||||
assert 'staleTd.className = "history-stale-cell"' in row
|
||||
assert "tr.appendChild(staleTd)" in row
|
||||
assert "if (chat.stale)" in row, "the cell branches on the row's stale flag"
|
||||
branch = row[row.find("if (chat.stale)") : row.find("tr.appendChild(staleTd)")]
|
||||
# The stale branch: the rose pill with the exact hover copy.
|
||||
assert 'pill.className = "stale-pill"' in branch
|
||||
assert 'pill.textContent = "Stale"' in branch
|
||||
assert (
|
||||
'pill.title = "Sources have changed since this chat was saved'
|
||||
" — open the chat to Regenerate\";"
|
||||
) in branch, "the pill's hover copy points at the Regenerate action (task 05)"
|
||||
# The fresh branch: the plain em-dash, never the pill.
|
||||
else_i = branch.find("} else {")
|
||||
assert else_i != -1, "the fresh branch must exist"
|
||||
fresh = branch[else_i:]
|
||||
assert 'staleTd.textContent = "—"' in fresh, "fresh rows render the em-dash"
|
||||
assert "stale-pill" not in fresh, "fresh rows render the em-dash, not the pill"
|
||||
# The <td> aria-label ships in BOTH states (conveyed without the
|
||||
# visual — WCAG 2.1 AA).
|
||||
assert branch.count('staleTd.setAttribute("aria-label"') == 2, (
|
||||
"the cell's aria-label must exist in the stale AND the fresh branch"
|
||||
)
|
||||
# READ-ONLY: no events, no controls, no innerHTML anywhere in the
|
||||
# cell's construction.
|
||||
assert "addEventListener" not in branch
|
||||
assert "createElement(\"button\")" not in branch
|
||||
assert "innerHTML" not in branch
|
||||
|
||||
|
||||
def test_stale_column_css_rose_family() -> None:
|
||||
"""styles.css (phase 53 task 04): ``.stale-pill`` is the rose
|
||||
family — the Stop-treatment tokens (err-ink on err-bg ≈9.3:1, the
|
||||
err-line border), theme-token based so it stays AA with the
|
||||
palette; a compact rounded pill (border-radius 999px, nowrap). The
|
||||
``.history-stale-cell`` keeps the marker on one line and rides
|
||||
ink-soft (5.1:1 on --surface) for the fresh rows' em-dash."""
|
||||
css = _css()
|
||||
pill = re.search(r"\.stale-pill \{([\s\S]*?)\n\}", css)
|
||||
assert pill, "styles.css must style .stale-pill"
|
||||
body = pill.group(1)
|
||||
assert "background: var(--err-bg)" in body, "the Stop-treatment tokens"
|
||||
assert "color: var(--err-ink)" in body
|
||||
assert "border: 1px solid var(--err-line)" in body
|
||||
assert "border-radius: 999px" in body, "the pill shape"
|
||||
assert "white-space: nowrap" in body
|
||||
cell = re.search(r"\.history-stale-cell \{([^}]*)\}", css)
|
||||
assert cell, "the stale cell must be styled"
|
||||
cbody = cell.group(1)
|
||||
assert "white-space: nowrap" in cbody
|
||||
assert "var(--ink-soft)" in cbody, "the em-dash rides ink-soft (AA on --surface)"
|
||||
|
||||
|
||||
def test_share_control_three_states_and_two_step_unshare() -> None:
|
||||
|
||||
Reference in New Issue
Block a user