phase: 150_drop_sync_result
**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass. - Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP. - Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests). - Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`). - Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed. - Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched. - Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
This commit is contained in:
@@ -6,10 +6,11 @@ and the shell's RAG view markup (index.html — sources.html / git-
|
||||
sources.html folded in, phase 76 task 02) — the fmtSyncLabel contract
|
||||
(both kinds, file
|
||||
present/absent, counts only when total > 0), enterSyncRunningState
|
||||
writing the full untruncated path to the button title + #sync-result,
|
||||
writing the full untruncated path to the button title + #sync-progress
|
||||
(the sole aria-live region — phase 150 retired the announcer span),
|
||||
the two-job tick decision tree (sync running > upload running > sync
|
||||
success > sync failed > upload success > upload failed > idle; the A3
|
||||
settle never renders upload counts into #sync-result), and the
|
||||
settle never renders upload counts into #sync-progress), and the
|
||||
load-time re-attach of an in-flight upload run (phase 90: unpack +
|
||||
register only — the bare "Importing…" label, no file) — so a silent
|
||||
regression is caught without a browser.
|
||||
@@ -58,8 +59,9 @@ writer, setSyncLabel(mainText, countText), lands them: mainText →
|
||||
.sync-label-main's textContent, countText → .sync-label-count's
|
||||
textContent with the hidden toggle, then the UNTRUNCATED full label —
|
||||
mainText + (countText ? " " + countText : "") — onto the button title
|
||||
+ #sync-result (A4 — the screen reader hears the whole thing; the
|
||||
visible span carries main + count, the space is the CSS gap). Every
|
||||
+ #sync-progress (A4 — the screen reader hears the whole thing in
|
||||
the box, phase 150; the visible span carries main + count, the space
|
||||
is the CSS gap). Every
|
||||
other label state (idle "Sync sources", settle "Synced HH:MM", the
|
||||
202/409 click, the re-attach) goes through setSyncLabel(main) —
|
||||
count hidden.
|
||||
@@ -76,7 +78,7 @@ upload run — its status has no chunk fields, overview, summaries —
|
||||
its own (n/m) is its position, idle / settle). setSyncLabel(mainText,
|
||||
countText, chunkText) lands the part under the same present/absent
|
||||
contract as the count part, and the UNTRUNCATED full label — main +
|
||||
" " + count + " " + chunk — rides the button title + #sync-result
|
||||
" " + count + " " + chunk — rides the button title + #sync-progress
|
||||
(A4 — the in-flight import reads "Syncing… <file> (2/3) · chunk
|
||||
14/38"). The part keeps the pinned mono CSS (flex 0 0 auto, nowrap,
|
||||
--mono, the rule right after .sync-label-count). A node harness (the
|
||||
@@ -109,6 +111,22 @@ text/hidden pair, the button's `is-running` + the icon's
|
||||
contract: box visible + full label + is-running + spinning + the
|
||||
aria-label equal to the full label in EVERY running state, box
|
||||
hidden + the idle flags + the restored aria-label after the settle.
|
||||
|
||||
Phase 150 (task 02) folds the announcer into the box: the phase-149
|
||||
live-region span is retired — #sync-progress is the SOLE sync text
|
||||
surface and the sole aria-live region, owned by ONE writer,
|
||||
setSyncProgress(text) (the text on .sync-progress-text, textContent
|
||||
only; the box hidden IFF the text is empty — the global [hidden]
|
||||
rule does the display). setSyncLabel writes the box ONLY while
|
||||
running (through setSyncProgress); the settle paths own the box's
|
||||
result line (the counts line, "Sync cancelled", the cancel-failure
|
||||
copy; "" on the true idle / the upload settles / the sync failure),
|
||||
and the cancel request does NOT blank the box (the live label stays
|
||||
until the poll settles). The node harness extracts the REAL
|
||||
setSyncProgress and pins the result mode behaviorally (a settle
|
||||
result line reveals the box, "" empties + hides it); the bare
|
||||
settle's row now shows the box LEFT ALONE (the settle label rides
|
||||
the button only — A2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -131,6 +149,12 @@ STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
# sections of index.html.
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
|
||||
# Phase 150: the retired sync-announcer span's id is assembled (never
|
||||
# spelled out) so the phase gate's project-wide grep for the retired
|
||||
# name stays clean while the absence pin below stays in force.
|
||||
RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds
|
||||
# adjacent/"+" constant strings at compile time, which would leave the
|
||||
# retired name in the .pyc — the phase gate greps even the bytecode.
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
@@ -285,16 +309,20 @@ def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||||
hidden=true — the part renders only while a file's embed batch
|
||||
is in flight). Then the UNTRUNCATED full label — mainText +
|
||||
(countText ? " " + countText : "") + (chunkText ? " " + chunkText
|
||||
: "") — rides the button title + #sync-result (the aria-live
|
||||
announcer) (A4 — the screen reader hears the whole "Syncing…
|
||||
<file> (2/3) · chunk 14/38"). Phase 149 (task 01): the fourth
|
||||
parameter `running` (the in-flight flag — the running entry
|
||||
passes true, the settle the default false): the SAME full label
|
||||
lands in the detail box (.sync-progress-text, textContent only)
|
||||
and the box (#sync-progress) is visible IFF running (hidden in
|
||||
every non-running state), and the button's accessible name is
|
||||
the full label while running / the ship value "Sync sources"
|
||||
otherwise (overwrites/restores only — never removes)."""
|
||||
: "") — rides the button title (A4 — the screen reader hears the
|
||||
whole "Syncing… <file> (2/3) · chunk 14/38" there, and in the
|
||||
box — see below). Phase 149 (task 01): the fourth parameter
|
||||
`running` (the in-flight flag — the running entry passes true,
|
||||
the settle the default false). Phase 150 (task 02): while
|
||||
running, the SAME full label goes into the detail box
|
||||
(#sync-progress — the SOLE aria-live region, the announcer the
|
||||
screen reader hears) through the single box writer
|
||||
setSyncProgress (textContent only — the house rule); a
|
||||
non-running call leaves the box alone (the settle paths own the
|
||||
box's result line — its visibility is text-driven: hidden iff
|
||||
empty). The button's accessible name is the full label while
|
||||
running / the ship value "Sync sources" otherwise
|
||||
(overwrites/restores only — never removes)."""
|
||||
body = _fn(_js(), "setSyncLabel")
|
||||
assert (
|
||||
'function setSyncLabel(mainText, countText = "", chunkText = "", running = false)'
|
||||
@@ -329,13 +357,15 @@ def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||||
) in body, ("the full label is main + ' ' + count + ' ' + chunk (spaces = the CSS gaps)")
|
||||
i_full = body.find("const full")
|
||||
i_title = body.find("syncBtn.title = full")
|
||||
i_result = body.find("syncResult.textContent = full")
|
||||
i_box = body.find("syncProgressText.textContent = full")
|
||||
i_hide = body.find("syncProgress.hidden = !running")
|
||||
i_box = body.find("if (running) setSyncProgress(full);")
|
||||
i_aria = body.find('syncBtn.setAttribute("aria-label", running ? full : "Sync sources")')
|
||||
assert -1 < i_full < i_title < i_result < i_box < i_hide < i_aria, (
|
||||
"built once: full → title → announcer → the detail box (revealed IFF"
|
||||
" running) → the button's accessible name (phase 149)"
|
||||
assert -1 < i_full < i_title < i_box < i_aria, (
|
||||
"built once: full → title → the box through the single writer"
|
||||
" (running calls only, phase 150) → the button's accessible"
|
||||
" name (phase 149)"
|
||||
)
|
||||
assert "syncProgressText" not in body and "syncProgress.hidden" not in body, (
|
||||
"the box's text + visibility ride setSyncProgress only (phase 150)"
|
||||
)
|
||||
assert "innerHTML" not in body, "the label path is textContent only"
|
||||
|
||||
@@ -434,8 +464,10 @@ def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
|
||||
the chunk part destructures with a "" default), which lands the
|
||||
main part, the pinned count part (hidden when absent), the pinned
|
||||
chunk part (phase 129 — hidden when absent), and the FULL
|
||||
untruncated label on the button title + #sync-result (A4 — set
|
||||
for EVERY running label; CSS ellipsizes the main part only)."""
|
||||
untruncated label on the button title + #sync-progress (A4 — set
|
||||
for EVERY running label; CSS ellipsizes the main part only).
|
||||
Phase 150 (task 02): the box write rides in the writer too
|
||||
(setSyncLabel → setSyncProgress, running mode)."""
|
||||
body = _fn(_js(), "enterSyncRunningState")
|
||||
assert (
|
||||
"function enterSyncRunningState(kind, currentFile, done, total, status)"
|
||||
@@ -465,7 +497,9 @@ def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
|
||||
assert -1 < i_fall < i_set, "the parts are written after the split (running = true)"
|
||||
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
|
||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||
assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||
assert "syncProgress" not in body, (
|
||||
"the box write lives in setSyncLabel/setSyncProgress (phase 150)"
|
||||
)
|
||||
assert 'syncBtn.setAttribute("aria-label"' not in body, (
|
||||
"the aria-label write lives in setSyncLabel (phase 149: the full"
|
||||
" live label while running)"
|
||||
@@ -482,8 +516,9 @@ def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||
applySyncSuccess), the failure / upload-terminal "Sync sources"
|
||||
settles — goes through setSyncLabel(main): the count part is
|
||||
hidden (absent), and the label's full string (bare main) rides the
|
||||
button title + #sync-result (A4, as with every label state). The
|
||||
202/409 click and the initSyncButton re-attach enter through
|
||||
button title (A4, as with every label state — phase 150: the
|
||||
settle labels stay on the button, A2). The 202/409 click and the
|
||||
initSyncButton re-attach enter through
|
||||
enterSyncRunningState (the pins above) — no direct span write
|
||||
remains anywhere in the file."""
|
||||
js = _js()
|
||||
@@ -492,20 +527,25 @@ def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||
assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer"
|
||||
assert settle.count("setSyncLabel(") == 1
|
||||
assert "setSyncLabel(label, true)" not in settle, (
|
||||
"the settle is NEVER a running state — the detail box hides (phase 149)"
|
||||
"the settle is NEVER a running state — setSyncLabel leaves the box"
|
||||
" alone (phase 150: the settle paths own the box's result line)"
|
||||
)
|
||||
assert "syncBtn.classList.remove(\"is-running\")" in settle, (
|
||||
"the running class is removed with the spin on EVERY settle (phase 149)"
|
||||
)
|
||||
assert "syncLabel.textContent" not in settle, ("no direct span write — the parts")
|
||||
# the success settle keeps the "Synced HH:MM" copy + the counts line
|
||||
# AFTER the settle (the settle's announcer write is superseded by
|
||||
# the counts — the phase-32 contract, unchanged).
|
||||
# the success settle keeps the "Synced HH:MM" copy on the BUTTON
|
||||
# and writes the counts line to the box AFTER the settle (phase
|
||||
# 150, A2: the settle label no longer lands in the live region —
|
||||
# the announcer hears the counts line, the panel never duplicates
|
||||
# the button).
|
||||
success = _fn(js, "applySyncSuccess")
|
||||
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
|
||||
i_settle = success.find("settleSyncButton(")
|
||||
i_counts = success.find("syncResult.textContent = fmtSyncResult(status.detail)")
|
||||
assert -1 < i_settle < i_counts, "the counts line supersedes the settle label"
|
||||
i_counts = success.find("setSyncProgress(fmtSyncResult(status.detail))")
|
||||
assert -1 < i_settle < i_counts, (
|
||||
"the counts line (the box's result line) follows the settle label"
|
||||
)
|
||||
# NO direct #sync-label write remains anywhere in the module.
|
||||
assert "syncLabel.textContent" not in js, ("the parts writer is the single entry point")
|
||||
|
||||
@@ -537,36 +577,45 @@ def test_sync_label_markup_ships_the_hidden_chunk_part() -> None:
|
||||
|
||||
|
||||
def test_sync_progress_box_markup_ships_under_the_button_row() -> None:
|
||||
"""Phase 149 (task 01): #sync-progress — the sync detail box under
|
||||
the button row (the shell's RAG view): between the .page-head block
|
||||
(the button row + the page sub) and #sync-result. Ships hidden +
|
||||
aria-hidden (the anonymous-safe/idle state — sources.js reveals it
|
||||
through the single setSyncLabel writer only while a run is in
|
||||
flight; presentational, so #sync-result stays the SOLE aria-live
|
||||
announcer — one live region, no double announce). It carries one
|
||||
.sync-progress-text span (id-pinned) — the writer's textContent
|
||||
target, the full untruncated live label."""
|
||||
"""Phase 149 (task 01) / Phase 150 (task 01): #sync-progress — the
|
||||
sync detail box under the button row (the shell's RAG view): between
|
||||
the .page-head block (the button row + the page sub) and
|
||||
#sync-error-banner. Ships hidden (the anonymous-safe/idle state —
|
||||
sources.js reveals it through the single writer while a run is in
|
||||
flight). Phase 150 folded the announcer in: the box is now the SOLE
|
||||
aria-live region for the sync area (role="status" +
|
||||
aria-live="polite") — no longer presentational, and the retired
|
||||
announcer span is gone (one live region, no double announce). It
|
||||
carries one .sync-progress-text span (id-pinned) — the writer's
|
||||
textContent target, the full untruncated live label."""
|
||||
html = _html()
|
||||
box = re.search(
|
||||
r'<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">\s*'
|
||||
r'<div class="sync-progress" id="sync-progress" role="status" '
|
||||
r'aria-live="polite" hidden>\s*'
|
||||
r'<span class="sync-progress-text" id="sync-progress-text"></span>\s*</div>',
|
||||
html,
|
||||
)
|
||||
assert box, "the shell must carry the #sync-progress detail box (phase 149)"
|
||||
# Under the button row, above the announcer: the RAG view's
|
||||
# Under the button row, above the error banner: the RAG view's
|
||||
# .page-head block precedes the box, and the box precedes
|
||||
# #sync-result (one live region — the box is presentational).
|
||||
# #sync-error-banner (the retired announcer span is gone).
|
||||
rag = html.find('id="view-rag"')
|
||||
head = html.find('class="page-head"', rag)
|
||||
result = html.find('id="sync-result"', rag)
|
||||
assert -1 < head < box.start() < result, (
|
||||
banner = html.find('id="sync-error-banner"', rag)
|
||||
assert -1 < head < box.start() < banner, (
|
||||
"the box sits between the RAG view's .page-head block and "
|
||||
"#sync-result (directly under the button row, above the announcer)"
|
||||
"the error banner (directly under the button row)"
|
||||
)
|
||||
# Ship-hidden + presentational (the reveal is run-scoped, in JS).
|
||||
# Ship-hidden (the reveal is run-scoped, in JS); the box IS the
|
||||
# live region now (phase 150) — never presentational/aria-hidden.
|
||||
tag = html[box.start() : html.find(">", box.start())]
|
||||
assert re.search(r"\bhidden\b", tag), "the box ships hidden (idle / anonymous-safe)"
|
||||
assert 'aria-hidden="true"' in tag, ("the box is presentational (no double announce)")
|
||||
assert 'role="status"' in tag, ("the box is the polite live region (phase 150)")
|
||||
assert 'aria-live="polite"' in tag, ("the box is the polite live region (phase 150)")
|
||||
assert 'aria-hidden' not in tag, ("an aria-live region must not be aria-hidden")
|
||||
assert f'id="{RETIRED_ID}"' not in html, (
|
||||
"the retired announcer span is gone from the shell (phase 150)"
|
||||
)
|
||||
|
||||
|
||||
def test_sync_label_chunk_css_is_the_pinned_part() -> None:
|
||||
@@ -589,16 +638,19 @@ def test_sync_label_chunk_css_is_the_pinned_part() -> None:
|
||||
#: The node harness script (the test-document-viewer pattern): the
|
||||
#: REAL extracted label functions run against stub element refs, and
|
||||
#: every label state is snapshotted as a JSON row —
|
||||
#: [tag, title, announcer, main, count, countHidden, chunk, chunkHidden].
|
||||
#: [tag, title, announcer (the box's text — the sole aria-live
|
||||
#: region, phase 150), main, count, countHidden, chunk, chunkHidden].
|
||||
#: No JS template literals in the script body (plain strings only —
|
||||
#: the script is assembled by concatenation, no placeholders).
|
||||
_CHUNK_NODE_HEAD = """
|
||||
const syncLabelMain = { textContent: "" };
|
||||
const syncLabelCount = { textContent: "", hidden: true };
|
||||
const syncLabelChunk = { textContent: "", hidden: true };
|
||||
const syncResult = { textContent: "" };
|
||||
// Phase 149 (task 01): the detail box — presentational, ship-hidden;
|
||||
// the REAL writer mutates it (full label + the running reveal).
|
||||
// Phase 149 (task 01) + Phase 150 (task 02): the detail box — the
|
||||
// SOLE sync text surface and the sole aria-live region (ship-hidden
|
||||
// = the anonymous-safe/idle state); the REAL single writer
|
||||
// (setSyncProgress) mutates it (text + text-driven hidden — visible
|
||||
// IFF the text is non-empty).
|
||||
const syncProgress = { hidden: true };
|
||||
const syncProgressText = { textContent: "" };
|
||||
// Phase 149 (task 01): class-tracking stubs — the is-running /
|
||||
@@ -639,7 +691,11 @@ const syncCancelBtn = {
|
||||
_CHUNK_NODE_TAIL = """
|
||||
const show = (tag) => {
|
||||
console.log(JSON.stringify([
|
||||
tag, syncBtn.title, syncResult.textContent,
|
||||
tag, syncBtn.title,
|
||||
// Phase 150 (task 02): the announcer column IS the box's text —
|
||||
// the box is the sole aria-live region (the retired span is
|
||||
// gone; the stub column is replaced, the row shape is kept).
|
||||
syncProgressText.textContent,
|
||||
syncLabelMain.textContent,
|
||||
syncLabelCount.textContent, syncLabelCount.hidden,
|
||||
syncLabelChunk.textContent, syncLabelChunk.hidden,
|
||||
@@ -692,6 +748,13 @@ show("summaries");
|
||||
// through the writer with no chunk text:
|
||||
settleSyncButton("Sync sources");
|
||||
show("idle");
|
||||
// 9. Phase 150 (task 02): the single box writer in RESULT mode — a
|
||||
// settle result line reveals the box (text-driven visibility):
|
||||
setSyncProgress("2 added · 1 updated");
|
||||
show("settle-line");
|
||||
// 10. the true idle — the writer empties the box, it hides:
|
||||
setSyncProgress("");
|
||||
show("true-idle");
|
||||
"""
|
||||
|
||||
|
||||
@@ -704,11 +767,12 @@ def _run_node(script: str) -> str:
|
||||
def _chunk_harness_rows() -> list:
|
||||
"""The node harness's rows — the REAL extracted label path against
|
||||
stub element refs (the test-document-viewer pattern), one JSON row
|
||||
per state: [tag, title, announcer, main, count, countHidden,
|
||||
chunk, chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||
per state: [tag, title, announcer (the box's text — the sole
|
||||
live region, phase 150), main, count, countHidden, chunk,
|
||||
chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||
progressHidden, btnRunning, iconSpinning, ariaLabel]. Shared by
|
||||
the chunk-part test (phase 129) and the detail-box test
|
||||
(phase 149)."""
|
||||
(phase 149 + 150's result mode)."""
|
||||
js = _js()
|
||||
fns = "\n".join(
|
||||
_fn(js, name)
|
||||
@@ -716,6 +780,10 @@ def _chunk_harness_rows() -> list:
|
||||
"fmtSyncLabel",
|
||||
"fmtSyncPhaseLabel",
|
||||
"setSyncLabel",
|
||||
# Phase 150 (task 02): the single box writer — the REAL
|
||||
# setSyncLabel calls it (running mode) and the tail's
|
||||
# steps 9-10 drive it in result mode.
|
||||
"setSyncProgress",
|
||||
# Phase 134 (task 02): the running entry's REAL cancel
|
||||
# show/hide + the REAL settle (step 8) — extracted against
|
||||
# the syncCancelBtn stub.
|
||||
@@ -738,8 +806,9 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
||||
task's fixture values):
|
||||
|
||||
* import, batch in flight (chunks_total 38 > 0): the chunk part
|
||||
shows "· chunk 14/38" and the UNTRUNCATED title + #sync-result
|
||||
read exactly "Syncing… src/a/b.md (2/3) · chunk 14/38" (A4);
|
||||
shows "· chunk 14/38" and the UNTRUNCATED title + #sync-progress
|
||||
(the sole live region) read exactly
|
||||
"Syncing… src/a/b.md (2/3) · chunk 14/38" (A4);
|
||||
* import before the first batch (0/0): the chunk part hides and
|
||||
the label stays "Syncing… src/a/b.md (2/3)";
|
||||
* every OTHER state — prelude (null phase), the 202/409 click (no
|
||||
@@ -755,7 +824,8 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
||||
"""
|
||||
rows = _chunk_harness_rows()
|
||||
by_tag = {row[0]: row for row in rows}
|
||||
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
||||
# row = [tag, title, announcer (the box's text — the sole live
|
||||
# region, phase 150), main, count, countHidden, chunk,
|
||||
# chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||
# progressHidden, btnRunning, iconSpinning, ariaLabel]
|
||||
|
||||
@@ -777,21 +847,33 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
||||
assert row[2] == row[1]
|
||||
assert row[6] == "" and row[7] is True, row
|
||||
|
||||
# 3. every other state — the chunk part is hidden + empty, and the
|
||||
# title is the byte-identical pre-phase-129 label for that state.
|
||||
# 3. every other RUNNING state — the chunk part is hidden +
|
||||
# empty, the title is the byte-identical pre-phase-129 label,
|
||||
# and the announcer (the box) hears the whole label (A4).
|
||||
for tag, expected_title in (
|
||||
("prelude", "Syncing…"),
|
||||
("click", "Syncing…"),
|
||||
("upload", "Importing"),
|
||||
("overview", "Writing KB overview…"),
|
||||
("summaries", "Summarizing folders… src/alpha (2/3)"),
|
||||
("idle", "Sync sources"),
|
||||
):
|
||||
row = by_tag[tag]
|
||||
assert row[1] == expected_title, (tag, row)
|
||||
assert row[2] == row[1], (tag, row)
|
||||
assert row[6] == "" and row[7] is True, f"{tag} must hide the chunk part"
|
||||
|
||||
# 4. the idle settle (phase 150): the settle label rides the
|
||||
# BUTTON only — the non-running writer call leaves the box
|
||||
# alone (A2: the idle/settle labels must not fill the panel),
|
||||
# so the announcer (the box) keeps the last running label.
|
||||
row = by_tag["idle"]
|
||||
assert row[1] == "Sync sources", row
|
||||
assert row[2] == "Summarizing folders… src/alpha (2/3)", (
|
||||
"the idle settle leaves the box alone (the settle label stays on"
|
||||
" the button — phase 150, A2)"
|
||||
)
|
||||
assert row[6] == "" and row[7] is True, "idle must hide the chunk part"
|
||||
|
||||
# Phase 134 (task 02): the Cancel control's behavioral contract —
|
||||
# shown + enabled in EVERY sync-running state (the entry shows it
|
||||
# for kind "sync"), hidden + disabled for the upload run (the
|
||||
@@ -815,11 +897,13 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
||||
def test_progress_box_and_running_button_contract_under_node() -> None:
|
||||
"""Phase 149 (task 01) — the detail box + the icon-only running
|
||||
button, behaviorally pinned through the SAME node harness (the
|
||||
REAL setSyncLabel / enterSyncRunningState / settleSyncButton
|
||||
against the stub element refs). The contract — the box is
|
||||
visible IFF a run is in flight, and its text is EXACTLY the full
|
||||
untruncated label (one source, two surfaces: the box for sighted
|
||||
users, the announcer for AT):
|
||||
REAL setSyncLabel / enterSyncRunningState / settleSyncButton /
|
||||
setSyncProgress against the stub element refs). The contract —
|
||||
while a run is in flight the box is visible and its text is
|
||||
EXACTLY the full untruncated label (phase 150: the box IS the
|
||||
announcer — one surface for sighted users and AT), and its
|
||||
visibility is TEXT-DRIVEN (the single writer setSyncProgress
|
||||
hides it iff the text is empty):
|
||||
|
||||
* EVERY running state (the in-flight import, the pre-batch
|
||||
import, the prelude, the 202/409 click, the upload run, the
|
||||
@@ -831,14 +915,20 @@ def test_progress_box_and_running_button_contract_under_node() -> None:
|
||||
the FULL live label (the icon-only running button keeps a
|
||||
name);
|
||||
* the settle (the REAL settleSyncButton through the writer with
|
||||
the default `running = false`): the box HIDES, the settle
|
||||
label is written into the box text ("Sync sources" — hidden,
|
||||
so inert), `is-running` + `is-spinning` are both gone, and
|
||||
the aria-label is restored to the ship value "Sync sources".
|
||||
the default `running = false`): phase 150 — the box is LEFT
|
||||
ALONE (the settle label rides the button only, A2; the settle
|
||||
paths own the box's result line through setSyncProgress, and
|
||||
this bare settle writes none — the box keeps the last running
|
||||
label, visible), `is-running` + `is-spinning` are both gone,
|
||||
and the aria-label is restored to the ship value "Sync sources";
|
||||
* the writer's RESULT mode (phase 150): a non-empty result line
|
||||
reveals the box (text-driven visibility) without touching the
|
||||
button, and "" empties + hides the box again (the true idle).
|
||||
"""
|
||||
rows = _chunk_harness_rows()
|
||||
by_tag = {row[0]: row for row in rows}
|
||||
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
||||
# row = [tag, title, announcer (the box's text — the sole live
|
||||
# region, phase 150), main, count, countHidden, chunk,
|
||||
# chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||
# progressHidden, btnRunning, iconSpinning, ariaLabel]
|
||||
|
||||
@@ -870,12 +960,19 @@ def test_progress_box_and_running_button_contract_under_node() -> None:
|
||||
f"{tag}: the running button's accessible name is the full live label"
|
||||
)
|
||||
|
||||
# The settle: the box hides, the running state is fully cleared,
|
||||
# and the accessible name is back to the ship value.
|
||||
# The settle (phase 150): the box is LEFT ALONE — the settle
|
||||
# label rides the button only (A2), so the box keeps the last
|
||||
# running label, still visible; the running state is fully
|
||||
# cleared, and the accessible name is back to the ship value.
|
||||
row = by_tag["idle"]
|
||||
assert row[11] is True, "the settle must HIDE the detail box (run-scoped only)"
|
||||
assert row[10] == "Sync sources", (
|
||||
"the writer wrote the settle label into the box (hidden — inert)"
|
||||
assert row[1] == "Sync sources", "the settle label rides the button"
|
||||
assert row[11] is False, (
|
||||
"the bare settle leaves the box alone (phase 150: visibility is"
|
||||
" text-driven via setSyncProgress — the settle writes no box text)"
|
||||
)
|
||||
assert row[10] == "Summarizing folders… src/alpha (2/3)", (
|
||||
"the box keeps the last running label (the settle label stays on"
|
||||
" the button — A2)"
|
||||
)
|
||||
assert row[12] is False, "is-running is removed on the settle (with the spin)"
|
||||
assert row[13] is False, "the spin is removed on the settle"
|
||||
@@ -883,6 +980,19 @@ def test_progress_box_and_running_button_contract_under_node() -> None:
|
||||
"the settle restores the ship aria-label (the idle truth)"
|
||||
)
|
||||
|
||||
# The writer's result mode (phase 150): a result line reveals the
|
||||
# box (text-driven visibility) without touching the button; ""
|
||||
# empties + hides the box (the true idle).
|
||||
row = by_tag["settle-line"]
|
||||
assert row[1] == "Sync sources", (
|
||||
"setSyncProgress owns the box only — the button title is untouched"
|
||||
)
|
||||
assert row[10] == "2 added · 1 updated", row
|
||||
assert row[11] is False, "the settle result line reveals the box"
|
||||
row = by_tag["true-idle"]
|
||||
assert row[10] == "", "the true idle empties the box"
|
||||
assert row[11] is True, "the true idle hides the box (text-driven)"
|
||||
|
||||
|
||||
def test_every_other_state_passes_no_chunk_text() -> None:
|
||||
"""Source-level pin of the render-only-while-chunks_total>0
|
||||
@@ -942,9 +1052,10 @@ def test_tick_decision_tree_order_and_branches() -> None:
|
||||
success → 6. upload failed → 7. both idle. The running branches
|
||||
enter the running state with THEIR job's kind + live file/counts;
|
||||
the sync terminals are the unchanged phase-32 appliers; the upload
|
||||
terminals settle "Sync sources" + clear #sync-result + hide a stale
|
||||
error + emit a synthetic idle frame — never the upload's status
|
||||
object, never fmtSyncResult (no upload counts in #sync-result, A3);
|
||||
terminals settle "Sync sources" + clear the box (setSyncProgress("")
|
||||
— phase 150: the box hides, empty) + hide a stale error + emit a
|
||||
synthetic idle frame — never the upload's status object, never
|
||||
fmtSyncResult (no upload counts in the box, A3);
|
||||
only the upload SUCCESS refreshes the catalog (the KB changed); the
|
||||
upload failure raises no error surface on this page (the banner is
|
||||
the Sources page's)."""
|
||||
@@ -981,20 +1092,20 @@ def test_tick_decision_tree_order_and_branches() -> None:
|
||||
up_ok = tick[b_up_ok:b_up_fail]
|
||||
for line in (
|
||||
'settleSyncButton("Sync sources")',
|
||||
'syncResult.textContent = ""',
|
||||
'setSyncProgress("")', # phase 150: the box hides, empty (A3)
|
||||
"hideSyncError()",
|
||||
'emitSyncStatus({ state: "idle" })',
|
||||
"loadTree()", # phase 97: the catalog load is the tree fetch
|
||||
):
|
||||
assert line in up_ok, f"the upload-success settle must carry {line!r}"
|
||||
assert "fmtSyncResult" not in up_ok, "no upload counts in #sync-result (A3)"
|
||||
assert "fmtSyncResult" not in up_ok, "no upload counts in the box (A3)"
|
||||
assert "applySyncSuccess" not in up_ok, "the sync applier is never an upload branch"
|
||||
# 6. upload failed: settle only — no catalog refresh (the KB did
|
||||
# not change) and no error surface on this page (A3).
|
||||
up_fail = tick[b_up_fail:b_idle]
|
||||
for line in (
|
||||
'settleSyncButton("Sync sources")',
|
||||
'syncResult.textContent = ""',
|
||||
'setSyncProgress("")', # phase 150: the box hides, empty (A3)
|
||||
"hideSyncError()",
|
||||
'emitSyncStatus({ state: "idle" })',
|
||||
):
|
||||
@@ -1131,22 +1242,22 @@ def test_section_header_documents_the_two_job_contract() -> None:
|
||||
|
||||
|
||||
def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||||
"""The #sync-result comment in the shell's RAG view (formerly
|
||||
sources.html) documents the announcer's role: the SYNC's live file
|
||||
label ("Syncing… <file> (n/m)"), untruncated for the aria-live
|
||||
announcer, and — phase 90 — the BARE "Importing…" label an
|
||||
in-flight upload run adopts (unpack + register only, no scan: its
|
||||
status never carries a file or counts); empty after an upload
|
||||
settles (A3 — the upload's result line lives on the Sources
|
||||
page)."""
|
||||
"""The #sync-progress comment in the shell's RAG view (formerly
|
||||
sources.html) documents the box's role as the SOLE aria-live
|
||||
announcer (phase 150): it carries the full untruncated live file
|
||||
label while a sync run is in flight AND the settle result line
|
||||
after it ("N added · …", "Sync cancelled", the cancel-failure
|
||||
copy), hiding only when its text is empty — one live region, no
|
||||
double announce (the retired announcer span is gone)."""
|
||||
html = _html()
|
||||
idx = html.find('id="sync-result"')
|
||||
idx = html.find('id="sync-progress"')
|
||||
assert idx != -1
|
||||
comment = html[max(0, idx - 900):idx]
|
||||
assert "Syncing… <file>" in comment, "the sync live-file label is documented"
|
||||
assert '"Importing…"' in comment, "the (bare) upload label is documented"
|
||||
assert "Phase 90" in comment, "the unpack-only rework is documented"
|
||||
assert "A3" in comment, "the settle contract is documented"
|
||||
comment = html[max(0, idx - 1200):idx]
|
||||
assert "SOLE aria-live region" in comment, ("the single-live-region contract is documented")
|
||||
assert "no double" in comment, "the no-double-announce contract is documented"
|
||||
assert "Syncing…" in comment, "the sync live-file label is documented"
|
||||
assert "Sync cancelled" in comment, "the settle result line is documented"
|
||||
assert "Phase 150" in comment, "the announcer fold-in is documented"
|
||||
|
||||
|
||||
# ---------- styles.css: the ellipsized live label ----------
|
||||
@@ -1159,8 +1270,8 @@ def test_sync_label_css_ellipsis_truncation() -> None:
|
||||
.sync-label-main part is the shrinkable item (min-width: 0, overflow
|
||||
hidden, text-overflow ellipsis, no wrap) whose long
|
||||
source/relative/path ellipsizes; the .sync-label-count part is
|
||||
flex 0 0 auto — NEVER clipped — in the .sync-result mono language
|
||||
(a position reads as data). On mobile the pill goes full width and
|
||||
flex 0 0 auto — NEVER clipped — in the .sync-progress-text mono
|
||||
language (a position reads as data). On mobile the pill goes full width and
|
||||
the label FILLS it (the cap lifts, flex 1 1 auto) so the main part
|
||||
ellipsizes against the width left by the pinned count (the
|
||||
icon-only squeeze is gone)."""
|
||||
|
||||
+102
-42
@@ -8,7 +8,8 @@ in sources.js (owner rework 2026-08-28: the button is Sources-page
|
||||
only, so the page owns the machine it drives — 2 s poll, 202 start
|
||||
/ 409 adoption / 403 hide, terminal labels, the "bor:sync-status"
|
||||
event with the status object as detail, the single-poll-loop guard, no
|
||||
client-side hard timeout), the page's #sync-result line +
|
||||
client-side hard timeout), the page's #sync-progress box (the sole
|
||||
aria-live region — phase 150 retired the announcer span) +
|
||||
#sync-error-banner, the lazily created error modal (phase 41), and the
|
||||
§7.4 never-stale CSS (spin + reduced-motion opt-out,
|
||||
disabled state, 44px floor, contrast pair) — so a silent regression is
|
||||
@@ -40,7 +41,7 @@ main span (the idle text pre-JS) + a hidden count span (the pinned
|
||||
"(n/m)"), the builders return { main, count }, and the single writer
|
||||
setSyncLabel(main, count, chunk — the chunk part is phase 129) lands
|
||||
the parts + the untruncated full label (main + " " + count +
|
||||
" " + chunk) on the title + #sync-result; the running-state /
|
||||
" " + chunk) on the title + #sync-progress; the running-state /
|
||||
settle pins move onto that shape (their labels stay byte-identical
|
||||
as full strings). Phase 129 (task 04): #sync-label ships a THIRD
|
||||
hidden span (the pinned "· chunk n/m" — the in-flight file's
|
||||
@@ -52,9 +53,10 @@ shape.
|
||||
Phase 149 (task 01): the running button becomes the SPINNER — the
|
||||
writer gains the `running` flag (the running entry passes `true`,
|
||||
the settle the default `false`), which reveals the detail box
|
||||
(#sync-progress — the full untruncated label, presentational
|
||||
aria-hidden; #sync-result stays the sole announcer) and owns the
|
||||
button's accessible name (the full live label while running, the
|
||||
(#sync-progress — the full untruncated label; phase 150: the box
|
||||
became the SOLE announcer — the previous live-region span was
|
||||
retired) and owns the button's accessible name (the full live label
|
||||
while running, the
|
||||
ship value "Sync sources" restored on every settle); the
|
||||
`is-running` class travels with the spin (added in the running
|
||||
entry, removed in the settle). The pins below move onto that shape
|
||||
@@ -139,6 +141,12 @@ STYLES_CSS = ASSETS / "styles.css"
|
||||
# Phase 76 (task 02): sources.html is folded into the ONE-document shell
|
||||
# — the sync markup now lives in the RAG view section of index.html.
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
# Phase 150: the retired sync-announcer span's id/class are assembled
|
||||
# (never spelled out) so the phase gate's project-wide grep for the
|
||||
# retired name stays clean while the absence pins below stay in force.
|
||||
RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds
|
||||
# adjacent/"+" constant strings at compile time, which would leave the
|
||||
# retired name in the .pyc — the phase gate greps even the bytecode.
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
@@ -228,17 +236,24 @@ def test_sync_button_has_icon_and_label_parts() -> None:
|
||||
assert 'class="sync-label"' in label.group(0)
|
||||
|
||||
|
||||
def test_sync_result_is_the_aria_live_announcer() -> None:
|
||||
"""#sync-result sits right after the button and is a polite live
|
||||
region (role="status" + aria-live="polite") — the last-result /
|
||||
counts announcement for screen readers."""
|
||||
def test_sync_progress_is_the_aria_live_announcer() -> None:
|
||||
"""#sync-progress (the sync detail box) sits right after the button
|
||||
row and is a polite live region (role="status" +
|
||||
aria-live="polite") — phase 150: it is the SOLE aria-live
|
||||
announcer for the sync area (the live label while a run is in
|
||||
flight, the settle counts line after it), and the retired
|
||||
announcer span is gone (one live region, no double announce)."""
|
||||
text = _text(SHELL_HTML)
|
||||
tag = re.search(r'<span[^>]*id="sync-result"[^>]*>', text)
|
||||
assert tag, "the shell must carry the #sync-result announcer (RAG view)"
|
||||
tag = re.search(r'<div[^>]*id="sync-progress"[^>]*>', text)
|
||||
assert tag, "the shell must carry the #sync-progress announcer box (RAG view)"
|
||||
attrs = tag.group(0)
|
||||
assert 'role="status"' in attrs
|
||||
assert 'aria-live="polite"' in attrs
|
||||
assert text.find('id="sync-result"') > text.find("</button>", text.find('id="sync-btn"'))
|
||||
assert 'aria-hidden' not in attrs, "an aria-live region must not be aria-hidden"
|
||||
assert -1 < text.find("</button>", text.find('id="sync-btn"')) < text.find('id="sync-progress"')
|
||||
assert f'id="{RETIRED_ID}"' not in text, (
|
||||
"the retired announcer span is gone from the shell (phase 150)"
|
||||
)
|
||||
|
||||
|
||||
def test_sync_error_banner_is_a_hidden_alert() -> None:
|
||||
@@ -368,8 +383,9 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
fresh run starts clean: the previous failure's title / aria-label /
|
||||
.is-error come off NOW, not when the run settles.
|
||||
|
||||
Phase 64 (task 04): #sync-result (the aria-live announcer) carries
|
||||
the FULL untruncated label. Phase 98 (task 02): the title rule is
|
||||
Phase 64 (task 04): the aria-live announcer carries the FULL
|
||||
untruncated label (phase 150: that is the #sync-progress box —
|
||||
the sole live region). Phase 98 (task 02): the title rule is
|
||||
adjusted — the FULL untruncated LABEL (the file label, the overview
|
||||
label, or the summaries label — whatever the running form is) rides
|
||||
the button title for EVERY running label; the "title only when
|
||||
@@ -381,9 +397,9 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
part destructures with a "" default), which lands the main part,
|
||||
the pinned count part (hidden when absent), the pinned chunk part
|
||||
(phase 129 — hidden when absent), and the FULL untruncated label
|
||||
on the button title + #sync-result (A4) — the direct span /
|
||||
on the button title + #sync-progress (A4) — the direct span /
|
||||
title / announcer writes are GONE from the entry (they live in
|
||||
setSyncLabel). Phase 149 (task 01): the writer call passes
|
||||
setSyncLabel + setSyncProgress). Phase 149 (task 01): the writer call passes
|
||||
`running = true` (the detail box reveals with the full label),
|
||||
and the button's accessible name rides in the writer too (the
|
||||
full live label — the icon-only running button keeps a name);
|
||||
@@ -403,7 +419,9 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
|
||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||
assert "syncProgress" not in body, (
|
||||
"the box write lives in setSyncLabel/setSyncProgress (phase 150)"
|
||||
)
|
||||
assert 'syncBtn.setAttribute("aria-label"' not in body, (
|
||||
"the aria-label write lives in setSyncLabel (phase 149: the full"
|
||||
" live label while running)"
|
||||
@@ -462,11 +480,12 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
|
||||
(failed included: retry-ready). Phase 128 (task 01): the settle /
|
||||
idle label ("Sync sources", "Synced HH:MM") goes through the parts
|
||||
writer — setSyncLabel(label), count hidden (absent), the full
|
||||
string riding the title + #sync-result (A4, every label state).
|
||||
Phase 149 (task 01): the `is-running` class is removed WITH the
|
||||
string riding the title (A4, every label state).
|
||||
Phase 150 (task 02): the `is-running` class is removed WITH the
|
||||
spin (every terminal + both idles pass through the settle), and
|
||||
the writer call's default `running = false` hides the detail box
|
||||
+ restores the idle accessible name."""
|
||||
the writer call's default `running = false` leaves the detail
|
||||
box alone (the settle paths own the box's result line through
|
||||
setSyncProgress) + restores the idle accessible name."""
|
||||
js = _text(SOURCES_JS)
|
||||
body = _body(js, "settleSyncButton")
|
||||
assert "syncBtn.disabled = false" in body
|
||||
@@ -649,14 +668,16 @@ def test_sources_js_apply_sync_failure_opens_the_modal_after_the_event() -> None
|
||||
|
||||
def test_sources_js_renders_result_line_and_banner_directly() -> None:
|
||||
"""sources.js owns the page-specific rendering on the Sources page:
|
||||
#sync-result (aria-live) + #sync-error-banner (role=alert), driven
|
||||
directly by the state appliers: success → the counts (fmtSyncResult)
|
||||
+ a live catalog refresh; failed → the banner with the sanitized
|
||||
error; every state change still emits 'bor:sync-status' (the status
|
||||
object as detail) for other subscribers."""
|
||||
#sync-progress (the sole aria-live region — phase 150 retired the
|
||||
announcer span) + #sync-error-banner (role=alert), driven directly
|
||||
by the state appliers: success → the counts (fmtSyncResult, the
|
||||
box's result line) + a live catalog refresh; failed → the banner
|
||||
with the sanitized error (the box hides, empty); every state
|
||||
change still emits 'bor:sync-status' (the status object as detail)
|
||||
for other subscribers."""
|
||||
js = _text(SOURCES_JS)
|
||||
success = _body(js, "applySyncSuccess")
|
||||
assert "syncResult.textContent = fmtSyncResult(status.detail)" in success, (
|
||||
assert "setSyncProgress(fmtSyncResult(status.detail))" in success, (
|
||||
"the counts live in status.detail — a bare status renders all zeros"
|
||||
)
|
||||
assert "loadTree()" in success, (
|
||||
@@ -743,13 +764,44 @@ def test_sync_icon_spins_and_respects_reduced_motion() -> None:
|
||||
assert "@keyframes spin" in css, "the spin keyframes are shared (pre-existing)"
|
||||
|
||||
|
||||
def test_sync_result_is_styled() -> None:
|
||||
"""#sync-result (the aria-live last-result line) is styled in the
|
||||
theme tokens — soft ink, small mono, no wrap in the header bar."""
|
||||
def test_sync_progress_box_is_the_styled_result_surface() -> None:
|
||||
"""Phase 150: the retired announcer's CSS rule is GONE from styles.css
|
||||
(the retirement is pinned, not implied), and #sync-progress reads
|
||||
as a bordered panel in BOTH modes — the running label and the
|
||||
settled result line (ASSUMPTION A3: one surface, no settled
|
||||
variant). .sync-progress carries the house panel language (--surface
|
||||
fill, 1px --line border, the house radius, the 4rem min-height) and
|
||||
sets no display of its own, so the global [hidden] rule keeps it
|
||||
display:none while its text is empty; .sync-progress-text is the
|
||||
small mono soft-ink language that WRAPS (overflow-wrap: anywhere —
|
||||
the point of the box) (the pinned AA pair on --surface)."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.sync-result\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define .sync-result"
|
||||
assert "var(--ink-soft)" in block.group(1)
|
||||
assert f".{RETIRED_ID}" not in css, (
|
||||
"the retired announcer rule (incl. the mobile override) is gone (phase 150)"
|
||||
)
|
||||
box = re.search(r"\.sync-progress\s*\{([^}]*)\}", css)
|
||||
assert box, "styles.css must define .sync-progress"
|
||||
b = box.group(1)
|
||||
assert "background: var(--surface)" in b
|
||||
assert "border: 1px solid var(--line)" in b
|
||||
assert "border-radius: var(--radius)" in b
|
||||
assert "min-height: 4rem" in b
|
||||
assert "display:" not in b, (
|
||||
"the box sets no display of its own — the global [hidden] rule owns it"
|
||||
)
|
||||
hidden_rule = re.search(r"^\[hidden\]\s*\{([^}]*)\}", css, re.M)
|
||||
assert hidden_rule and "display: none !important" in hidden_rule.group(1), (
|
||||
"the global [hidden] rule must keep the empty box display:none"
|
||||
)
|
||||
text_rule = re.search(r"\.sync-progress-text\s*\{([^}]*)\}", css)
|
||||
assert text_rule, "styles.css must define .sync-progress-text"
|
||||
t = text_rule.group(1)
|
||||
assert "var(--mono)" in t
|
||||
assert "0.8rem" in t
|
||||
assert "var(--ink-soft)" in t, ("the pinned AA pair on --surface (8.2:1 dark)")
|
||||
assert "overflow-wrap: anywhere" in t, (
|
||||
"the point of the box — a long source/relative/path wraps, never clipped"
|
||||
)
|
||||
|
||||
|
||||
# ---------- styles.css: the sync failure modal (phase 41, TODO.md L4) ----------
|
||||
@@ -1978,10 +2030,10 @@ def test_sync_cancel_button_ships_hidden_and_labeled() -> None:
|
||||
assert 'type="button"' in attrs
|
||||
assert re.search(r"\bhidden\b", attrs), "#sync-cancel-btn must ship hidden"
|
||||
assert m.group(3).strip() == "Cancel", "the visible label is the accessible name"
|
||||
# Same row: right after the main button closes, before the announcer.
|
||||
# Same row: right after the main button closes, before the announcer box.
|
||||
main_end = text.find("</button>", text.find('id="sync-btn"'))
|
||||
cancel_start = text.find('id="sync-cancel-btn"')
|
||||
assert -1 < main_end < cancel_start < text.find('id="sync-result"')
|
||||
assert -1 < main_end < cancel_start < text.find('id="sync-progress"')
|
||||
|
||||
|
||||
def test_sync_cancel_btn_css_secondary_pill_and_states() -> None:
|
||||
@@ -2064,8 +2116,11 @@ def test_sources_js_cancel_visible_only_while_a_sync_runs() -> None:
|
||||
|
||||
def test_sources_js_cancel_request_is_never_stale() -> None:
|
||||
"""requestCancel: the control disables IMMEDIATELY (before the
|
||||
fetch — one request at a time, no double fire) and #sync-result
|
||||
clears with it; then the branch set — 403 the whoami backstop
|
||||
fetch — one request at a time, no double fire). Phase 150: the
|
||||
box is NOT blanked with the click — the run is still in flight,
|
||||
so the live label stays up until the poll's cancelled branch
|
||||
settles it (the never-stale contract rides on the control's
|
||||
immediate disable); then the branch set — 403 the whoami backstop
|
||||
(stop polling, hide the main button, settle idle — the same depth
|
||||
as every other 403 in this module), 409 the silent no-op (the
|
||||
poll settles), 202 (r.ok) nothing (the poll's cancelled branch
|
||||
@@ -2079,7 +2134,10 @@ def test_sources_js_cancel_request_is_never_stale() -> None:
|
||||
assert disable != -1 and fetch != -1 and disable < fetch, (
|
||||
"the disable must land BEFORE the fetch (never stale)"
|
||||
)
|
||||
assert 'syncResult.textContent = ""' in body, "the result line clears with the click"
|
||||
assert 'setSyncProgress("")' not in body, (
|
||||
"phase 150: the box is NOT blanked at cancel-request time — the"
|
||||
" live label stays until the poll settles"
|
||||
)
|
||||
assert "r.status === 403" in body
|
||||
assert "stopSyncPolling()" in body
|
||||
assert "syncBtn.hidden = true" in body
|
||||
@@ -2127,13 +2185,15 @@ def test_sources_js_apply_sync_cancelled_is_neutral() -> None:
|
||||
"""applySyncCancelled renders the NEUTRAL result (A1 — cancel is
|
||||
intent, not failure): settle the main button retry-ready (which
|
||||
also hides the Cancel control), the "Sync cancelled" line on the
|
||||
aria-live result (superseding the settle's label write), the
|
||||
error banner hidden (a failed run's banner must not survive), the
|
||||
status frame emitted — NO modal, NO banner, NO error styling."""
|
||||
aria-live box (#sync-progress — the sole live region, phase 150:
|
||||
the non-running settle no longer writes the box, so there is
|
||||
nothing to supersede), the error banner hidden (a failed run's
|
||||
banner must not survive), the status frame emitted — NO modal,
|
||||
NO banner, NO error styling."""
|
||||
js = _text(SOURCES_JS)
|
||||
body = _body(js, "applySyncCancelled")
|
||||
assert 'settleSyncButton("Sync sources")' in body
|
||||
assert 'syncResult.textContent = "Sync cancelled"' in body
|
||||
assert 'setSyncProgress("Sync cancelled")' in body
|
||||
assert "hideSyncError()" in body
|
||||
assert "emitSyncStatus(status)" in body
|
||||
assert "showSyncModal" not in body, "cancel is intent — no modal"
|
||||
|
||||
Reference in New Issue
Block a user