**Phase 193 — final verification pass: all gates green.**
- **Verified (all 3 tasks already complete in `complete/193_sync_remaining_count/`):** `fmtSyncLabel`'s count part is `total > 0 && total - done > 0 ? \`${total - done} remaining\` : ""` (plain int, hidden at `N==0`, `total===0` → no part); docstrings updated; chunk/image/summaries parts + `counts_line` terminal + `app/` byte-untouched (`git diff --stat`: no `app/` files).
- **Completion criteria:** `N remaining` shape + marker pins ✅ · upload shares the builder (E2E upload arm) ✅ · chunk/image/summaries pins green ✅ · terminal `counts_line` green unedited + A4 title/`#sync-progress` pin ✅ · suite green, coverage 99% ✅ · lint/types clean ✅ · commit + `todo/→complete/` left to the harness (no git index/history commands run).
- `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, junit: **4792 tests, 0 failures/errors/skipped**, `TOTAL 7181 16 → 99%` (>90%)
- `uv run pytest tests/e2e/test_sync_remaining_count.py -v --no-cov` → **3 passed** (isolated, 53 s)
- Isolated `--no-cov` sweep of the 14 sync-lineage suites (label_mobile, upload_progress, chunk_progress, image_progress, progress_box, summary_visibility, archive_upload_sources, status_freshness, upload_no_scan, peak_memory_visible, session_reset, fast_path_deferred_content, git_freeze, smoke) → **14/14 exit 0**
- `uv run ruff check .` → All checks passed · `uv run pyright` → **0 errors**
- **Docs:** README.md reviewed in full (all 2408 lines) + AGENTS.md. Fixed 2 inconsistencies: the sync running-label clause claimed the upload label shows the count — now states it shares the ONE builder/shape but reports no counts since phase 90 (renders bare); phase-190's "the other ~72 rows" → "the other 83 rows of the panel's 86-row catalogue" (mechanically: 86 CATALOG rows, 84 overridable). `N remaining` already in the sync paragraph; task-01's `(n/m)` examples gone. AGENTS.md verified consistent (no clause names the running count part → unchanged per the phase's "do not add if absent"). Mechanically re-verified: all 86 `BOR_*` settings documented + defaults match, `.env.example` 88 vars covered, 0 broken README anchors, all cited file paths exist, 17 palette colors, migrations head `0030`, pages/shell routes, ports 8123/8901, E2E port allocations (new suite's 8211/8914 unique).
- **Notable:** task 02's documented deviation stands — `files_done` is the importer's 1-based *current* file, so the phase doc's `[1-4]` range is unreachable; count-bearing frames are `1 … N−1` (pinned). Pre-existing, untouched: `E2E_APP_PORT_TOOLBUDGET`/`SAVEDOC` both 8129 and `SYNCUP`/`SYCNCEL` both 8133 — harmless under the one-suite-at-a-time rule.
- **Next pending phase:** none — `193_sync_remaining_count` is the last entry in `.agents/phases/todo/`.
2418 lines
118 KiB
Python
2418 lines
118 KiB
Python
"""Unit: the RAG-page sync button's phase-64 (task 04) live-file contract.
|
||
|
||
The browser behavior is E2E-covered (tests/e2e/test_sync_upload_progress.py,
|
||
task 06); here we pin the source-level wiring in sources.js, styles.css,
|
||
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-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-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.
|
||
|
||
Phase 64 task 05 adds the Sources-page (git-sources.js) upload
|
||
contract: the "Successfully uploaded — <file>" toast on the 202
|
||
(page-local, phase-55 pattern, success-only), the "Processing…"
|
||
label driven by the 2 s GET /api/git-sources/upload/status poll, the
|
||
409 re-attach (no error banner), the poll's terminal decision tree
|
||
(success → result line + announce + reload, NO second toast; failed
|
||
→ sanitized error banner + reload; idle → defensive restore), the
|
||
finally's never-restore-while-polling guard (PLAN §7.4), and the boot
|
||
re-attach branches.
|
||
|
||
Phase 90 re-points the upload contract to UNPACK + REGISTER ONLY:
|
||
the processing state is the BARE "Processing…" for the whole
|
||
background run (no current_file, no "(n/m)" counts, no title — the
|
||
scan's progress moved to the RAG page's Sync button), the button
|
||
reads exactly "Upload" (the phase-64 scan-suffixed label is gone), the
|
||
settled result line is
|
||
"Uploaded <name> — press Sync sources to import it." (fmtUploadResult
|
||
off the status's {"message": "uploaded"} detail; the nameless variant
|
||
after a reload / on the 409 re-attach), and the success announce
|
||
names the next step.
|
||
|
||
Phase 98 (task 02) pins the SYNC job's phase-aware label (D2):
|
||
fmtSyncPhaseLabel — "overview" → exactly "Writing KB overview…",
|
||
"summaries" → "Summarizing folders… <current_summary> (n/m)" (the
|
||
folder part omitted while current_summary is null → the bare
|
||
"Summarizing folders… (n/m)"), anything else → the byte-identical
|
||
phase-64 fmtSyncLabel("sync", …) fall-through — plus the status-object
|
||
threading (the tick's branch 1 + the initSyncButton running re-attach
|
||
pass the WHOLE status; the upload job and the 202/409 click stay on
|
||
the bare label — the upload status has no phase) and the A4 title-rule
|
||
adjustment (the full untruncated label rides the button title for
|
||
EVERY running label — the file-only rule is gone).
|
||
|
||
Phase 128 (task 01) re-points the label pins to the PARTS shape: the
|
||
label's string is split into a shrinks-to-fit MAIN part (prefix +
|
||
file/folder — the part CSS may ellipsize) and a pinned COUNT part
|
||
("(n/m)" — NEVER clipped, hidden whenever absent), so on a phone the
|
||
file path ellipsizes while the position stays on-screen. The builders
|
||
no longer return one string — they return the parts ({ main, count },
|
||
the same decision tree, the same byte-identical copy) — and ONE
|
||
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-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.
|
||
|
||
Phase 129 (task 04) adds the label's THIRD pinned part — the chunk
|
||
position: #sync-label ships a hidden .sync-label-chunk span after the
|
||
count span (the pinned "· chunk n/m" — the in-flight file's
|
||
embed-batch position, batch granularity: n the 1-based first chunk of
|
||
the in-flight batch, m the file's total content chunks). The ONLY
|
||
builder that reads the status's chunk fields is fmtSyncPhaseLabel's
|
||
import fall-through — "· chunk n/m" renders while chunks_total > 0
|
||
and hides in every other state (the prelude, the 202/409 click, the
|
||
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-progress
|
||
(A4 — the in-flight import reads "Syncing… <file> 1 remaining · chunk
|
||
14/38" — phase 193 counts the files DOWN). The part keeps the pinned
|
||
mono CSS (flex 0 0 auto, nowrap,
|
||
--mono, the rule right after .sync-label-count). A node harness (the
|
||
test-document-viewer pattern) behaviorally pins the rendered parts +
|
||
the full title across every label state.
|
||
|
||
Phase 134 (task 02) extends the node harness to the Cancel control:
|
||
the extracted functions gain the REAL show/hide helpers + the REAL
|
||
settleSyncButton (step 8 now runs the settle itself — it goes through
|
||
the writer as before, plus it hides the Cancel control), and every
|
||
row snapshots the control's hidden/disabled pair — the behavioral
|
||
contract: shown + enabled in EVERY sync-running state (the entry
|
||
shows it for kind "sync"), hidden + disabled for the upload run
|
||
(the upload job is NOT cancellable in this phase) and after the
|
||
settle (settleSyncButton always hides it).
|
||
|
||
Phase 149 (task 01) adds the `running` flag to the single writer:
|
||
setSyncLabel(mainText, countText = "", chunkText = "", running =
|
||
false) also writes the SAME untruncated full label into the detail
|
||
box (#sync-progress's .sync-progress-text — textContent only) and
|
||
reveals the box IFF running (the running entry passes true, the
|
||
settle passes the default false), and owns the button's accessible
|
||
name (full live label while running, the ship value "Sync sources"
|
||
restored on every non-running call). The running entry passes
|
||
`running = true` + adds the `is-running` class (with the spin), and
|
||
no longer sets aria-label (the writer owns it); the settle removes
|
||
`is-running` (with the spin). The node harness rows gain the box's
|
||
text/hidden pair, the button's `is-running` + the icon's
|
||
`is-spinning` flags, and the button's aria-label — the behavioral
|
||
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).
|
||
|
||
Phase 152 (task 03) adds the label's FOURTH part — the vision-image
|
||
position: fmtSyncPhaseLabel's import fall-through gains "· image
|
||
n/m" (rendered while images_total > 0 — a PDF's vision span is in
|
||
flight; n the 1-based image about to be described, m the file's
|
||
total images, ONE continuous counter across the PDF's three vision
|
||
spans), the overview/summaries branches return image: "" (the
|
||
position belongs to the import phase only — the status's image
|
||
fields are cleared at those transitions, so the branches never read
|
||
them), and the bare builder stays image-free (the upload label and
|
||
the 202/409 click stay bare). setSyncLabel(mainText, countText =
|
||
"", chunkText = "", running = false, imageText = "") appends the
|
||
image part LAST to the composed full string — so the box, the
|
||
button title, and the running aria-label all carry
|
||
"Syncing… <file> 1 remaining · chunk 14/38 · image 3/15" (phase 193).
|
||
The image part
|
||
gets NO span of its own (the running button is the spinner — its
|
||
parts are hidden, phase 149): it rides ONLY the composed string,
|
||
while the chunk part keeps its .sync-label-chunk span; the two
|
||
positions are distinct and each disappears independently. The node
|
||
harness gains the two in-flight image rows (both parts at once —
|
||
the owner's stuck label — and the image-only pictorial PDF) and
|
||
pins the composed full string byte-for-byte.
|
||
|
||
Phase 154 (task 02) extends the Sources-page upload line with the
|
||
COMPLETION STAMP (the phase's date-qualified freshness): the settled
|
||
line reads "Uploaded <name> (<stamp>) — press Sync sources to import
|
||
it." (the fresh 202 settle, the page-local name) or the nameless
|
||
"Uploaded <stamp> — press Sync sources to import it." (the boot
|
||
re-render — the stamp comes from the SERVER payload, so the line is
|
||
self-dating even with the name unknown); a running / legacy / partial
|
||
payload without timestamps renders the byte-identical pre-phase-154
|
||
strings (the nameful / nameless "Uploaded … — press Sync sources to
|
||
import it." lines). The stamp is PRECOMPUTED by the page-local
|
||
fmtUploadStamp(status) (the per-page duplication idiom — the same A1
|
||
date-qualifier as sources.js's fmtSyncTime, duplicated, not shared;
|
||
the rule's boundary set is pinned in test_sync_button.py on a fixed
|
||
clock): it reads status.finished_at first (the success state always
|
||
has it on the server), falls back to status.started_at (a defensive
|
||
guard — a corrupt finished_at degrades to the started_at stamp rather
|
||
than to no stamp), and yields "" when both are absent or every ISO is
|
||
unparseable. Both render sites — the poll's success settle and the
|
||
boot re-attach — pass fmtUploadStamp(status) as the third argument;
|
||
the NO server change (the /upload/status payload is untouched —
|
||
started_at / finished_at already shipped, phase 64).
|
||
|
||
Phase 154 (task 03) pins the BYTE-IDENTITY of the no-stamp fallbacks
|
||
against the VERBATIM pre-phase-154 strings: the old 2-arg
|
||
fmtUploadResult (git history) is pinned in this file as a reference
|
||
implementation (the name renamed, the body byte-for-byte) and run
|
||
side by side with the real 3-arg function under node — the no-stamp
|
||
(nameful / nameless) outputs + the legacy 2-arg call are asserted
|
||
byte-identical to the reference's output, and the reference's
|
||
output to the verbatim old line strings (assertable, not assumed).
|
||
Clock-free: `when` is a precomputed argument, so the no-stamp
|
||
shapes need no clock at all. The /upload/status full-dict pin
|
||
(tests/integration/test_git_sources_upload.py, the phase-64
|
||
precedent) stays UNCHANGED — this phase adds no server keys (the
|
||
app/ no-touch regression guard). The qualifier's byte-identity
|
||
(same-day + fallbacks vs the verbatim pre-phase fmtSyncTime) is
|
||
pinned in test_sync_button.py on the shared fixed clock; the four
|
||
shapes and the fmtUploadStamp behavior (finished / started /
|
||
missing / garbage) ride the task-02 pins below.
|
||
|
||
Phase 193 (task 01, D1) re-points the running file-count part: the
|
||
fmtSyncLabel COUNT part is the COUNT-DOWN `N remaining` (N =
|
||
total - done) — shown while N >= 1, HIDDEN at the final file (N == 0:
|
||
the main part names the file in flight — a "0 remaining" next to the
|
||
last file's name is the stale-looking edge the owner's complaint is
|
||
about), the total > 0 pre-walk guard KEPT, plain integer (no thousands
|
||
separator); the UPLOAD job shares the builder and the shape (A2). The
|
||
node harness rows that carried "(2/3)" now carry "1 remaining" (the
|
||
same 2/3 fixture — the part renders the distance to the end), the
|
||
harness gains the final-file row (the count part hidden), and the
|
||
summaries phase's own (n/m) folder position, the "· chunk n/m" /
|
||
"· image n/m" parts and the terminal counts line stay byte-identical.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
#: The node harness needs node (the test-document-viewer pattern).
|
||
HAVE_NODE = shutil.which("node") is not None
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||
# Phase 76 (task 02): both HTML pages are folded into the ONE-document
|
||
# shell — the pinned comments now live in the RAG / Sources view
|
||
# 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:
|
||
return SOURCES_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _css() -> str:
|
||
return STYLES_CSS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _html() -> str:
|
||
return SHELL_HTML.read_text(encoding="utf-8")
|
||
|
||
|
||
def _gjs() -> str:
|
||
return GIT_SOURCES_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _ghtml() -> str:
|
||
return SHELL_HTML.read_text(encoding="utf-8")
|
||
|
||
|
||
def _gfn(js: str, name: str) -> str:
|
||
"""The source of the first `function <name>` in git-sources.js
|
||
(brace balanced — since phase 76 task 02 the functions live inside
|
||
mount(root), so the closing brace is indented, not line-leading).
|
||
"""
|
||
fn = js.find(f"function {name}")
|
||
assert fn != -1, f"{name} must be defined in git-sources.js"
|
||
open_idx = js.find("{", fn)
|
||
depth = 0
|
||
for i in range(open_idx, len(js)):
|
||
c = js[i]
|
||
if c == "{":
|
||
depth += 1
|
||
elif c == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return js[fn : i + 1]
|
||
raise AssertionError(f"unbalanced braces in {name}")
|
||
|
||
|
||
def _utick(js: str) -> str:
|
||
"""The upload poll tick inside startUploadPolling — from `const
|
||
tick = async () => {` to the next function (initUploadStatus), so
|
||
the whole decision tree is in the slice."""
|
||
fn = js.find("function startUploadPolling")
|
||
assert fn != -1, "startUploadPolling must be defined in git-sources.js"
|
||
tick = js.find("const tick = async () => {", fn)
|
||
assert tick != -1, "the tick must live inside startUploadPolling"
|
||
end = js.find("async function initUploadStatus", tick)
|
||
assert end != -1, "initUploadStatus must follow startUploadPolling"
|
||
return js[tick:end]
|
||
|
||
|
||
def _usubmit(js: str) -> str:
|
||
"""The upload form's submit handler — from the addEventListener to
|
||
the next top-level function (focusNewRow), so every branch (202 /
|
||
409 / other non-2xx / network / finally) is in the slice."""
|
||
start = js.find('uploadFormEl.addEventListener("submit"')
|
||
assert start != -1, "the upload form must wire a submit handler"
|
||
end = js.find("function focusNewRow", start)
|
||
assert end != -1, "focusNewRow must follow the upload handler"
|
||
return js[start:end]
|
||
|
||
|
||
def _fn(js: str, name: str) -> str:
|
||
"""The source of the first `function <name>` in js (brace balanced —
|
||
since phase 76 task 02 the functions live inside mount(root), so
|
||
the closing brace is indented, not line-leading; the house pin
|
||
pattern from tests/unit/test_sync_button.py)."""
|
||
fn = js.find(f"function {name}")
|
||
assert fn != -1, f"{name} must be defined in sources.js"
|
||
open_idx = js.find("{", fn)
|
||
depth = 0
|
||
for i in range(open_idx, len(js)):
|
||
c = js[i]
|
||
if c == "{":
|
||
depth += 1
|
||
elif c == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return js[fn : i + 1]
|
||
raise AssertionError(f"unbalanced braces in {name}")
|
||
|
||
|
||
def _tick(js: str) -> str:
|
||
"""The poll tick inside startSyncPolling — from `const tick = async
|
||
() => {` to the next function (startSync), so the whole two-job
|
||
decision tree is in the slice."""
|
||
fn = js.find("function startSyncPolling")
|
||
assert fn != -1, "startSyncPolling must be defined in sources.js"
|
||
tick = js.find("const tick = async () => {", fn)
|
||
assert tick != -1, "the tick must live inside startSyncPolling"
|
||
end = js.find("async function startSync", tick)
|
||
assert end != -1, "startSync must follow startSyncPolling"
|
||
return js[tick:end]
|
||
|
||
|
||
def _doc_above(js: str, name: str) -> str:
|
||
"""The block comment immediately above `function <name>` (the
|
||
builder's docstring — _fn starts AT the keyword, so the lineage
|
||
paragraphs need this slice)."""
|
||
i = js.find(f"function {name}")
|
||
assert i != -1, f"{name} must be defined in sources.js"
|
||
start = js.rfind("/*", 0, i)
|
||
assert start != -1, f"{name} must carry a docstring"
|
||
end = js.find("*/", start)
|
||
assert start < end < i, f"{name}'s docstring must sit above the function"
|
||
return js[start:end]
|
||
|
||
|
||
# ---------- fmtSyncLabel: the live-file label PARTS contract ----------
|
||
|
||
|
||
def test_fmt_sync_label_returns_the_parts_contract() -> None:
|
||
"""fmtSyncLabel(kind, currentFile, done, total) — Phase 128
|
||
(task 01): the builder returns the PARTS, not one string. `kind`
|
||
picks the prefix — "upload" → "Importing" (the background run's
|
||
word, A3), anything else → "Syncing…". The MAIN part — the
|
||
prefix, the file appended only when present (sync: the bare
|
||
prefix shows during clone/pull, before any file is indexed — A4;
|
||
phase 90: the upload run never carries a file or counts, so its
|
||
label is always the bare "Importing"); the COUNT part — only when
|
||
total > 0 (the import has started) — is the phase-193 COUNT-DOWN
|
||
`N remaining` (N = total - done), NO join space of its own (the
|
||
space between the parts is the CSS gap — the full string's space
|
||
is re-joined by setSyncLabel for the title / announcer, A4). File
|
||
before counts in the full-string order."""
|
||
body = _fn(_js(), "fmtSyncLabel")
|
||
assert "function fmtSyncLabel(kind, currentFile, done, total)" in body
|
||
assert 'kind === "upload"' in body
|
||
assert '"Importing"' in body and '"Syncing…"' in body
|
||
# the upload prefix must come from the kind check (ternary, in order)
|
||
i_kind = body.find('kind === "upload"')
|
||
i_importing = body.find('"Importing"')
|
||
i_syncing = body.find('"Syncing…"')
|
||
assert -1 < i_kind < i_importing < i_syncing
|
||
# the MAIN part: the file appended only when present
|
||
assert "currentFile ?" in body
|
||
assert "`${prefix} ${currentFile}`" in body
|
||
# the COUNT part (phase 193, D1): only when the import has
|
||
# started (the kept total > 0 pre-walk guard) AND the run is not
|
||
# on its final file (total - done > 0), no leading join space
|
||
# (the parts are joined by setSyncLabel, A4)
|
||
assert "total > 0" in body
|
||
assert "`${total - done} remaining`" in body
|
||
assert "(${done}/${total})" not in body, (
|
||
"the pre-phase (done/total) shape is gone (phase 193)"
|
||
)
|
||
i_file = body.find("currentFile ?")
|
||
i_counts = body.find("total > 0")
|
||
assert -1 < i_file < i_counts, "the file lands before the counts"
|
||
# the parts return (the full strings hold when setSyncLabel joins
|
||
# them: "Syncing…", "Syncing… <file>",
|
||
# "Syncing… <file> N remaining", "Importing")
|
||
assert "return { main, count }" in body
|
||
|
||
|
||
def test_fmt_sync_label_count_part_is_the_n_remaining_part() -> None:
|
||
"""Phase 193 (task 01, D1): the running file-count part counts
|
||
DOWN. The fmtSyncLabel count expression is pinned VERBATIM — the
|
||
kept `total > 0` pre-walk guard (phase 64: no count part before
|
||
the walk knows the total) AND the new `total - done > 0` guard
|
||
(the part hides at the final file, ASSUMPTION A1) around the
|
||
plain-integer `${total - done} remaining` shape (no thousands
|
||
separator — the existing count part's house style). The docstring
|
||
names the new part (N = total - done, shown while N >= 1), the
|
||
hidden-at-the-final-file edge (N == 0 — the main part names the
|
||
file in flight; a "0 remaining" next to the last file's name is
|
||
the stale-looking edge), and the upload job sharing the builder
|
||
and the shape (A2)."""
|
||
js = _js()
|
||
body = _fn(js, "fmtSyncLabel")
|
||
# the exact expression, byte-for-byte (both guards + the shape +
|
||
# the empty-string else)
|
||
assert (
|
||
"const count = total > 0 && total - done > 0"
|
||
" ? `${total - done} remaining` : \"\";"
|
||
) in body, "the count expression is the D1 shape"
|
||
assert "total > 0 &&" in body, "the total > 0 pre-walk guard stays"
|
||
assert "total - done > 0" in body, (
|
||
"the part hides at the final file (N == 0, A1)"
|
||
)
|
||
# plain integer — no separator/format helper joins the number
|
||
assert "toLocaleString" not in body and ".toFixed" not in body, (
|
||
"plain integer, no thousands separator (the house label style)"
|
||
)
|
||
doc = _doc_above(js, "fmtSyncLabel")
|
||
assert "`N remaining`" in doc and "N = total - done" in doc, (
|
||
"the docstring describes the new part"
|
||
)
|
||
assert "N >= 1" in doc, "shown while N >= 1 (A1)"
|
||
assert "HIDDEN at the final file" in doc and "N == 0" in doc, (
|
||
"the hidden-at-final-file edge is named in the docstring (A1)"
|
||
)
|
||
assert "thousands separator" in doc, "the plain-integer clause is named"
|
||
assert "pre-walk" in doc, "the phase-64 total > 0 contract is named"
|
||
assert "shares this builder" in doc, (
|
||
"the upload job sharing the builder and the shape is named (A2)"
|
||
)
|
||
|
||
|
||
def test_remaining_part_leaves_the_other_parts_byte_identical() -> None:
|
||
"""Phase 193 (task 01): the COUNT-DOWN moved exactly ONE part.
|
||
The fmtSyncPhaseLabel import fall-through still builds the
|
||
phase-129 "· chunk n/m" and the phase-152 "· image n/m" parts
|
||
from the status's own fields, the summaries branch still emits
|
||
its FOLDER position `(${summaries_done}/${summaries_total})`,
|
||
the bare fmtSyncLabel builder stays free of all three (each part
|
||
lives only in its own builder), and the terminal counts line
|
||
(fmtSyncResult — a different surface) never grows the word."""
|
||
js = _js()
|
||
phase = _fn(js, "fmtSyncPhaseLabel")
|
||
assert "`· chunk ${status.current_chunk}/${status.chunks_total}`" in phase
|
||
assert "`· image ${status.images_done}/${status.images_total}`" in phase
|
||
assert (
|
||
"count: `(${status.summaries_done}/${status.summaries_total})`"
|
||
) in phase, "the summaries folder position keeps its (n/m) shape"
|
||
bare = _fn(js, "fmtSyncLabel")
|
||
for part in ("chunk", "image", "summaries"):
|
||
assert part not in bare, (
|
||
f"fmtSyncLabel stays {part}-free — the part lives only in its own builder"
|
||
)
|
||
assert "remaining" not in _fn(js, "fmtSyncResult"), (
|
||
"the terminal result line is untouched (the running count part"
|
||
" is the one change)"
|
||
)
|
||
|
||
|
||
def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||
"""setSyncLabel(mainText, countText = "", chunkText = "",
|
||
running = false, imageText = "") — Phase 128 (task 01) + Phase
|
||
129 (task 04) + Phase 152 (task 03): the SINGLE point that writes
|
||
#sync-label. The MAIN part gets
|
||
mainText (textContent only — the house rule, no HTML strings);
|
||
the COUNT part gets countText when present (textContent +
|
||
hidden=false — the "(n/m)" position is visible, NEVER clipped)
|
||
and is emptied + hidden=true when absent; the CHUNK part
|
||
(phase 129) gets chunkText under the SAME contract (present →
|
||
the pinned "· chunk n/m" is visible, absent → emptied +
|
||
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
|
||
: "") + (imageText ? " " + imageText : "") — rides the button
|
||
title (A4 — the screen reader hears the
|
||
whole "Syncing… <file> 1 remaining · chunk 14/38 · image 3/15" there,
|
||
and in the box — see below). The image part (phase 152) gets NO
|
||
span of its own (the running button is the spinner — its parts
|
||
are hidden, phase 149): it rides only the composed full string.
|
||
Phase 149 (task 01): the fourth parameter
|
||
`running` (the in-flight flag — the running entry passes true,
|
||
the settle the default false); phase 152 (task 03) adds the
|
||
fifth — `imageText` (the bare builders and the non-running calls
|
||
pass nothing, so it defaults "" and the part stays out of the
|
||
string). 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")
|
||
# the exact signature, byte-for-byte (concatenated — the literal
|
||
# is 105 columns, over the line cap): main, count, chunk, the
|
||
# phase-149 `running` flag (fourth), the phase-152 `imageText`
|
||
# (fifth — the bare builders + non-running calls pass nothing,
|
||
# so it defaults to the empty string).
|
||
expected_sig = (
|
||
'function setSyncLabel(mainText, countText = "", chunkText = "",'
|
||
' running = false, imageText = "")'
|
||
)
|
||
assert expected_sig in body, (
|
||
"phase 149: the in-flight flag is the fourth parameter; phase"
|
||
" 152: the image part is the fifth, defaulting to the empty"
|
||
" string (the part stays out of the composed full string)"
|
||
)
|
||
# the parts: textContent only (the house rule), null-guarded refs
|
||
assert "syncLabelMain.textContent = mainText" in body
|
||
assert "syncLabelCount.textContent = countText" in body
|
||
assert "syncLabelChunk.textContent = chunkText" in body
|
||
# present count → visible; absent count → hidden, emptied
|
||
assert "syncLabelCount.hidden = false" in body
|
||
assert 'syncLabelCount.textContent = ""' in body
|
||
assert "syncLabelCount.hidden = true" in body
|
||
i_vis = body.find("syncLabelCount.hidden = false")
|
||
i_hid = body.find("syncLabelCount.hidden = true")
|
||
assert -1 < i_vis < i_hid, "the present-count branch precedes the absent one"
|
||
# present chunk → visible; absent chunk → hidden, emptied (same contract)
|
||
assert "syncLabelChunk.hidden = false" in body
|
||
assert 'syncLabelChunk.textContent = ""' in body
|
||
assert "syncLabelChunk.hidden = true" in body
|
||
i_cvis = body.find("syncLabelChunk.hidden = false")
|
||
i_chid = body.find("syncLabelChunk.hidden = true")
|
||
assert -1 < i_cvis < i_chid, "the present-chunk branch precedes the absent one"
|
||
# A4: the untruncated full label rides the title + the announcer —
|
||
# main + " " + count + " " + chunk (the join spaces = the CSS
|
||
# gaps), bare main when both are absent.
|
||
assert (
|
||
"const full =\n"
|
||
" mainText +\n"
|
||
" (countText ? ` ${countText}` : \"\") +\n"
|
||
" (chunkText ? ` ${chunkText}` : \"\") +\n"
|
||
" (imageText ? ` ${imageText}` : \"\");"
|
||
) in body, (
|
||
"the full label is main + ' ' + count + ' ' + chunk + ' ' +"
|
||
" image — the image part LAST (spaces = the CSS gaps)"
|
||
)
|
||
i_full = body.find("const full")
|
||
i_title = body.find("syncBtn.title = full")
|
||
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_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"
|
||
|
||
|
||
# ---------- enterSyncRunningState: the parts + the A4 writer ----------
|
||
|
||
|
||
def test_fmt_sync_phase_label_is_the_exact_d2_parts_contract() -> None:
|
||
"""fmtSyncPhaseLabel(status) — the SYNC job's phase-aware label
|
||
(phase 98 task 02, D2; phase 128 task 01: the PARTS; phase 152
|
||
task 03: the IMAGE part). "overview"
|
||
→ main exactly "Writing KB overview…" (no folder, no count part);
|
||
"summaries" → main "Summarizing folders… <current_summary>" +
|
||
count "(n/m)" — the folder part (one leading space +
|
||
current_summary) is omitted while current_summary is null (the
|
||
phase's first poll → the bare "Summarizing folders…" main), and
|
||
the COUNT IS ALWAYS EMITTED in this phase (the hook always carries
|
||
a total); anything else (the null prelude, "import") → the
|
||
phase-64 fmtSyncLabel parts, byte-identical (same builder, same
|
||
fields, same order). The UPLOAD job never enters here — its status
|
||
has no phase (D2). Phase 152 (task 03): the import fall-through
|
||
is the ONLY branch that reads the status's image fields — "·
|
||
image n/m" renders while images_total > 0 (a PDF's vision span is
|
||
in flight — n the 1-based image about to be described, m the
|
||
file's total images) and hides at 0/0; the overview/summaries
|
||
branches return image: "" and never read the fields (the position
|
||
belongs to the import phase only — the runner clears it at those
|
||
transitions)."""
|
||
body = _fn(_js(), "fmtSyncPhaseLabel")
|
||
assert "function fmtSyncPhaseLabel(status)" in body
|
||
# "overview": the exact copy, no folder, no counts.
|
||
i_ov = body.find('status.phase === "overview"')
|
||
i_sum = body.find('status.phase === "summaries"')
|
||
assert -1 < i_ov < i_sum
|
||
ov = body[i_ov:i_sum]
|
||
assert 'main: "Writing KB overview…"' in ov
|
||
assert 'count: ""' in ov, "the overview label carries no count part"
|
||
assert "current_summary" not in ov and "summaries_done" not in ov, (
|
||
"the overview label carries no folder and no counts"
|
||
)
|
||
# "summaries": the exact template — optional folder, counts always,
|
||
# NO chunk part (the summaries phase's own (n/m) is its position —
|
||
# the status's chunk fields are cleared at this transition, so the
|
||
# branch never reads them).
|
||
i_fall = body.find("fmtSyncLabel(")
|
||
assert i_sum < i_fall, "the summaries branch precedes the fall-through"
|
||
sum_ = body[i_sum:i_fall]
|
||
assert 'status.current_summary ? ` ${status.current_summary}` : ""' in sum_, (
|
||
"the folder part — one leading space + the folder, or nothing"
|
||
)
|
||
assert "main: `Summarizing folders…${folder}`" in sum_, (
|
||
"the exact D2 main copy (with and without the folder)"
|
||
)
|
||
assert (
|
||
"count: `(${status.summaries_done}/${status.summaries_total})`"
|
||
) in sum_, "the count part is ALWAYS emitted in the summaries phase"
|
||
assert "total > 0" not in sum_, ("no conditional — the hook always carries a total")
|
||
assert 'chunk: ""' in ov, "the overview label carries no chunk part"
|
||
assert 'chunk: ""' in sum_, (
|
||
"the summaries label carries no chunk part (its (n/m) is its position)"
|
||
)
|
||
assert "chunks_total" not in ov, "the overview branch never reads the chunk fields"
|
||
assert "chunks_total" not in sum_, "the summaries branch never reads the chunk fields"
|
||
# phase 152 (task 03): the overview/summaries labels carry NO
|
||
# image part either (the position belongs to the import phase
|
||
# only) and never read the status's image fields (the runner
|
||
# clears them at those transitions).
|
||
assert 'image: ""' in ov, "the overview label carries no image part"
|
||
assert 'image: ""' in sum_, (
|
||
"the summaries label carries no image part (its (n/m) is its position)"
|
||
)
|
||
assert "images_total" not in ov, "the overview branch never reads the image fields"
|
||
assert "images_total" not in sum_, "the summaries branch never reads the image fields"
|
||
# anything else (null prelude, "import"): byte-identical to today's
|
||
# sync label — same builder, same fields, same order — plus the
|
||
# phase-129 chunk part: "· chunk n/m" ONLY while chunks_total > 0
|
||
# (a file's embed batch is in flight — n the 1-based first chunk
|
||
# of the batch, m the file's total content chunks, batch
|
||
# granularity), "" otherwise (0/0 before the first batch — the
|
||
# part hides) + the phase-152 image part: "· image n/m" ONLY
|
||
# while images_total > 0 (a PDF's vision span is in flight — n
|
||
# the 1-based image about to be described, m the file's total
|
||
# images — ONE continuous counter across the PDF's three vision
|
||
# spans), "" otherwise (0/0 — the part hides). The fall-through
|
||
# is the ONLY branch that reads the status's chunk + image
|
||
# fields, and the two parts are independent (both may show,
|
||
# each disappears on its own 0/0).
|
||
i_destr = body.find("const { main, count } = fmtSyncLabel(")
|
||
assert -1 < i_destr < i_fall, "the fall-through destructures the phase-64 parts"
|
||
fall = body[i_destr:]
|
||
assert (
|
||
'const { main, count } = fmtSyncLabel(\n'
|
||
' "sync", status.current_file, status.files_done, status.files_total\n'
|
||
" );"
|
||
) in fall, "the phase-64 parts builder, unchanged"
|
||
assert "status.chunks_total > 0" in fall, (
|
||
"the chunk part renders only while a batch is in flight"
|
||
)
|
||
assert "`· chunk ${status.current_chunk}/${status.chunks_total}`" in fall
|
||
i_guard = fall.find("status.chunks_total > 0")
|
||
i_else = fall.find(": \"\";", i_guard)
|
||
assert -1 < i_guard < i_else, "0/0 (before the first batch) → the empty chunk part"
|
||
# phase 152 (task 03): the image part — "· image n/m" ONLY while
|
||
# images_total > 0, the exact template + the 0/0 → "" guard, and
|
||
# the return gains the fourth part.
|
||
assert "status.images_total > 0" in fall, (
|
||
"the image part renders only while a vision span is in flight"
|
||
)
|
||
assert "`· image ${status.images_done}/${status.images_total}`" in fall
|
||
i_iguard = fall.find("status.images_total > 0")
|
||
i_ielse = fall.find(": \"\";", i_iguard)
|
||
assert -1 < i_iguard < i_ielse, "0/0 (before the first image) → the empty image part"
|
||
assert "return { main, count, chunk, image }" in fall
|
||
# the bare builder (the upload job + the 202/409 click) returns NO
|
||
# chunk OR image part — those labels stay bare.
|
||
bare = _fn(_js(), "fmtSyncLabel")
|
||
assert "chunk" not in bare, "fmtSyncLabel stays chunk-free (upload + the click stay bare)"
|
||
assert "image" not in bare, "fmtSyncLabel stays image-free (upload + the click stay bare)"
|
||
|
||
|
||
def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
|
||
"""The running-state entry keeps the §7.4 never-stale mechanics
|
||
(disabled, aria-busy, spinning icon, the stale .is-error removed)
|
||
and — phase 98 (task 02, D2) — is phase-aware for the SYNC job:
|
||
a sync-kind call carrying the status object takes the
|
||
fmtSyncPhaseLabel parts, while the upload job (no status — its
|
||
endpoint has no phase) and the 202/409 click keep the bare
|
||
fmtSyncLabel parts, byte-identical. Phase 128 (task 01): the entry
|
||
itself no longer writes the label — it hands the parts to the
|
||
SINGLE writer (setSyncLabel(main, count, chunk, running, image) —
|
||
phase 129: the chunk part destructures with a "" default, phase
|
||
152: the image part does too), 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-progress (A4 — set
|
||
for EVERY running label; CSS ellipsizes the main part only —
|
||
phase 152: with the image part appended last when a PDF's vision
|
||
span is in flight; it gets no span of its own). 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)"
|
||
in body
|
||
)
|
||
assert "syncBtn.disabled = true" in body
|
||
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
|
||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||
assert "syncBtn.classList.remove(\"is-error\")" in body
|
||
# the phase-aware split: sync + status → the D2 parts builder; the
|
||
# fall-through (upload / the click, no status) is the byte-
|
||
# identical phase-64 parts builder.
|
||
i_phase = body.find("fmtSyncPhaseLabel(status)")
|
||
i_fall = body.find("fmtSyncLabel(kind, currentFile, done, total)")
|
||
assert 'kind === "sync" && status' in body
|
||
assert -1 < i_phase < i_fall, "the phase label precedes the fall-through"
|
||
# the parts are handed to the single writer (A4 lives in it) —
|
||
# phase 129: the chunk part destructures with a "" default (the
|
||
# bare builders — the upload job, the 202/409 click — return no
|
||
# chunk part, so the part stays hidden there); phase 152: the
|
||
# image part does too (it rides only the composed full string —
|
||
# no span); phase 149: the
|
||
# call passes `running = true` (the detail box reveals + the
|
||
# full-label aria-label ride in the writer).
|
||
assert 'const { main, count, chunk = "", image = "" } =' in body, (
|
||
"the chunk + image parts destructure with the '' defaults"
|
||
" (bare builders return none)"
|
||
)
|
||
i_set = body.find("setSyncLabel(main, count, chunk, true, image)")
|
||
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 "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)"
|
||
)
|
||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||
assert "syncBtn.classList.add(\"is-running\")" in body, (
|
||
"the icon-only running button class is added with the spin (phase 149)"
|
||
)
|
||
|
||
|
||
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||
"""Every OTHER #sync-label writer — the idle "Sync sources", the
|
||
success settle "Synced HH:MM" (via settleSyncButton from
|
||
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 (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()
|
||
settle = _fn(js, "settleSyncButton")
|
||
assert "function settleSyncButton(label)" in settle
|
||
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 — 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 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(")
|
||
# Phase 187 (task 03): the clause builder rides the SAME writer call
|
||
# (the counts line + its trailing " · peak N MB", null rendering no
|
||
# clause), so the ordering pin reads the new argument list.
|
||
i_counts = success.find(
|
||
"setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status))"
|
||
)
|
||
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")
|
||
|
||
|
||
# =====================================================================
|
||
# Phase 129 (task 04) — the label's third part: the chunk position
|
||
# =====================================================================
|
||
|
||
|
||
def test_sync_label_markup_ships_the_hidden_chunk_part() -> None:
|
||
"""#sync-label (the shell's RAG view) ships the phase-129 THIRD
|
||
part after the count span — the pinned "· chunk n/m" span
|
||
(.sync-label-chunk), hidden + empty by default (the same ship
|
||
contract as the count span: no chunk position at idle — the part
|
||
renders only while a file's embed batch is in flight)."""
|
||
html = _html()
|
||
btn = html[html.find('id="sync-btn"') : html.find("</button>", html.find('id="sync-btn"'))]
|
||
label = re.search(
|
||
r'<span\b[^>]*id="sync-label"[^>]*>'
|
||
r'<span class="sync-label-main">Sync sources</span>'
|
||
r'<span class="sync-label-count" hidden></span>'
|
||
r'<span class="sync-label-chunk" hidden></span>'
|
||
r"</span>",
|
||
btn,
|
||
)
|
||
assert label, (
|
||
"the #sync-label parts markup (main + hidden count + hidden chunk) is missing"
|
||
)
|
||
|
||
|
||
def test_sync_progress_box_markup_ships_under_the_button_row() -> None:
|
||
"""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" 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 error banner: the RAG view's
|
||
# .page-head block precedes the box, and the box precedes
|
||
# #sync-error-banner (the retired announcer span is gone).
|
||
rag = html.find('id="view-rag"')
|
||
head = html.find('class="page-head"', rag)
|
||
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 "
|
||
"the error banner (directly under the button row)"
|
||
)
|
||
# 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 '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:
|
||
""".sync-label-chunk (phase 129 task 04): the same pinned-part
|
||
language as .sync-label-count — flex 0 0 auto (NEVER clipped),
|
||
white-space nowrap, the --mono font (a position reads as data) —
|
||
and it sits right AFTER the count rule (both mono parts read as
|
||
data)."""
|
||
css = _css()
|
||
count_block = re.search(r"\.sync-label-count\s*\{", css)
|
||
chunk_block = re.search(r"\.sync-label-chunk\s*\{([^}]*)\}", css)
|
||
assert chunk_block, "styles.css must style .sync-label-chunk"
|
||
for prop in ("flex: 0 0 auto", "white-space: nowrap", "font-family: var(--mono)"):
|
||
assert prop in chunk_block.group(1), f".sync-label-chunk must carry {prop!r}"
|
||
assert count_block and count_block.start() < chunk_block.start(), (
|
||
"the chunk rule sits right after the count rule"
|
||
)
|
||
|
||
|
||
#: 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 (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 };
|
||
// 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 /
|
||
// is-spinning travel-together contract is behavioral, not source.
|
||
const syncBtn = {
|
||
disabled: false,
|
||
title: "",
|
||
attrs: {},
|
||
setAttribute(k, v) { this.attrs[k] = v; },
|
||
removeAttribute(k) { delete this.attrs[k]; },
|
||
classes: new Set(),
|
||
classList: {
|
||
add(c) { syncBtn.classes.add(c); },
|
||
remove(c) { syncBtn.classes.delete(c); },
|
||
has(c) { return syncBtn.classes.has(c); },
|
||
},
|
||
};
|
||
const syncIcon = {
|
||
classes: new Set(),
|
||
classList: {
|
||
add(c) { syncIcon.classes.add(c); },
|
||
remove(c) { syncIcon.classes.delete(c); },
|
||
has(c) { return syncIcon.classes.has(c); },
|
||
},
|
||
};
|
||
// Phase 134 (task 02): the Cancel control — ships hidden + disabled
|
||
// (the markup's state); the extracted REAL show/hide helpers mutate
|
||
// it (show also sets the aria-label — the attrs pocket records it).
|
||
const syncCancelBtn = {
|
||
hidden: true,
|
||
disabled: true,
|
||
attrs: {},
|
||
setAttribute(k, v) { this.attrs[k] = v; },
|
||
removeAttribute(k) { delete this.attrs[k]; },
|
||
};
|
||
"""
|
||
|
||
_CHUNK_NODE_TAIL = """
|
||
const show = (tag) => {
|
||
console.log(JSON.stringify([
|
||
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,
|
||
syncCancelBtn.hidden, syncCancelBtn.disabled,
|
||
// Phase 149 (task 01): the detail box (text + hidden), the
|
||
// is-running / is-spinning flags, the button's accessible name.
|
||
syncProgressText.textContent, syncProgress.hidden,
|
||
syncBtn.classes.has('is-running'), syncIcon.classes.has('is-spinning'),
|
||
syncBtn.attrs['aria-label'] || '',
|
||
]));
|
||
};
|
||
// 1. import, batch in flight (the task's fixture values, 14/38):
|
||
enterSyncRunningState("sync", "src/a/b.md", 2, 3, {
|
||
phase: "import", current_file: "src/a/b.md", files_done: 2, files_total: 3,
|
||
current_chunk: 14, chunks_total: 38,
|
||
});
|
||
show("import-inflight");
|
||
// 1b. Phase 152 (task 03): import, a PDF's vision span in flight
|
||
// (the owner's stuck label — the last chunk position 125/126 is
|
||
// STILL on the status + the climbing image position 3/15): the
|
||
// composed full string carries BOTH parts (chunk keeps its span,
|
||
// the image part rides the string only — no span):
|
||
enterSyncRunningState("sync", "src/a/b.md", 2, 3, {
|
||
phase: "import", current_file: "src/a/b.md", files_done: 2, files_total: 3,
|
||
current_chunk: 125, chunks_total: 126, images_done: 3, images_total: 15,
|
||
});
|
||
show("import-image-span");
|
||
// 1c. Phase 152 (task 03): import, a purely pictorial PDF (no text
|
||
// chunks — the chunk position never left 0/0) in its vision
|
||
// span: the image part shows ALONE (the chunk part stays hidden
|
||
// + empty — the two parts are independent):
|
||
enterSyncRunningState("sync", "src/a/b.md", 2, 3, {
|
||
phase: "import", current_file: "src/a/b.md", files_done: 2, files_total: 3,
|
||
current_chunk: 0, chunks_total: 0, images_done: 7, images_total: 7,
|
||
});
|
||
show("import-image-only");
|
||
// 2. import before the first batch (0/0) — the chunk part hides:
|
||
enterSyncRunningState("sync", "src/a/b.md", 2, 3, {
|
||
phase: "import", current_file: "src/a/b.md", files_done: 2, files_total: 3,
|
||
current_chunk: 0, chunks_total: 0,
|
||
});
|
||
show("import-prebatch");
|
||
// 2b. Phase 193 (task 01, A1): the FINAL file (files_done ==
|
||
// files_total) — the count part HIDES (the main part names the
|
||
// file in flight; a "0 remaining" next to it is the stale-looking
|
||
// edge):
|
||
enterSyncRunningState("sync", "src/a/b.md", 3, 3, {
|
||
phase: "import", current_file: "src/a/b.md", files_done: 3, files_total: 3,
|
||
current_chunk: 0, chunks_total: 0,
|
||
});
|
||
show("import-final");
|
||
// 3. the prelude (null phase, no file yet):
|
||
enterSyncRunningState("sync", null, 0, 0, {
|
||
phase: null, current_file: null, files_done: 0, files_total: 0,
|
||
current_chunk: 0, chunks_total: 0,
|
||
});
|
||
show("prelude");
|
||
// 4. the 202/409 click (no status yet):
|
||
enterSyncRunningState("sync", null, 0, 0);
|
||
show("click");
|
||
// 5. the upload run (no status — its endpoint has no chunk fields, D2):
|
||
enterSyncRunningState("upload", null, 0, 0);
|
||
show("upload");
|
||
// 6. overview (the chunk fields are cleared at this transition):
|
||
enterSyncRunningState("sync", null, 4, 4, {
|
||
phase: "overview", current_file: null, files_done: 4, files_total: 4,
|
||
current_chunk: 0, chunks_total: 0,
|
||
});
|
||
show("overview");
|
||
// 7. summaries (the phase's own (n/m) is its position):
|
||
enterSyncRunningState("sync", null, 4, 4, {
|
||
phase: "summaries", current_summary: "src/alpha", files_done: 4, files_total: 4,
|
||
summaries_done: 2, summaries_total: 3, current_chunk: 0, chunks_total: 0,
|
||
});
|
||
show("summaries");
|
||
// 8. the idle settle goes through the REAL settle (phase 134 task
|
||
// 02: it hides the Cancel control too) — the settle itself goes
|
||
// 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");
|
||
"""
|
||
|
||
|
||
def _run_node(script: str) -> str:
|
||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||
return proc.stdout
|
||
|
||
|
||
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 (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 + 150's result mode)."""
|
||
js = _js()
|
||
fns = "\n".join(
|
||
_fn(js, name)
|
||
for name in (
|
||
"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.
|
||
"showSyncCancelBtn",
|
||
"hideSyncCancelBtn",
|
||
"enterSyncRunningState",
|
||
"settleSyncButton",
|
||
)
|
||
)
|
||
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
|
||
return [json.loads(line) for line in _run_node(script).splitlines() if line]
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_chunk_part_renders_only_in_the_import_phase_under_node() -> None:
|
||
"""Behavioral check (node) of the REAL label path — the extracted
|
||
fmtSyncLabel / fmtSyncPhaseLabel / setSyncLabel /
|
||
enterSyncRunningState run against stub element refs (the
|
||
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-progress
|
||
(the sole live region) read exactly
|
||
"Syncing… src/a/b.md 1 remaining · chunk 14/38" (A4 — phase
|
||
193: the 2/3 fixture now renders the distance to the end);
|
||
* import before the first batch (0/0): the chunk part hides and
|
||
the label stays "Syncing… src/a/b.md 1 remaining";
|
||
* import on the FINAL file (phase 193, A1: files_done ==
|
||
files_total): the COUNT part hides (no "0 remaining") — the
|
||
main part alone names the file in flight;
|
||
* every OTHER state — prelude (null phase), the 202/409 click (no
|
||
status), the upload run (no status, D2), overview, summaries (its
|
||
own (n/m) is its position), the idle settle — leaves the chunk
|
||
part hidden + empty.
|
||
|
||
Phase 134 (task 02): every row also snapshots the Cancel
|
||
control's hidden/disabled pair — the behavioral contract:
|
||
shown + enabled in EVERY sync-running state, hidden + disabled
|
||
for the upload run (the upload job is NOT cancellable in this
|
||
phase) and after the (real) settle.
|
||
"""
|
||
rows = _chunk_harness_rows()
|
||
by_tag = {row[0]: row for row in rows}
|
||
# 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]
|
||
|
||
# 1. the in-flight import — the chunk part renders, the full
|
||
# untruncated label rides the title + the announcer (A4).
|
||
row = by_tag["import-inflight"]
|
||
assert row[3] == "Syncing… src/a/b.md", row
|
||
assert row[4] == "1 remaining" and row[5] is False, (
|
||
"phase 193 (D1): the count part is the count-DOWN (3 − 2 = 1)"
|
||
)
|
||
assert row[6] == "· chunk 14/38" and row[7] is False, (
|
||
"the chunk part shows the batch position (1-based first chunk / file total)"
|
||
)
|
||
assert row[1] == "Syncing… src/a/b.md 1 remaining · chunk 14/38", row[1]
|
||
assert row[2] == row[1], "the announcer hears the whole thing (A4)"
|
||
|
||
# 2. the import before the first batch (0/0) — the chunk part
|
||
# hides, the label keeps its phase-64 shape with the phase-193
|
||
# count part.
|
||
row = by_tag["import-prebatch"]
|
||
assert row[1] == "Syncing… src/a/b.md 1 remaining", row[1]
|
||
assert row[2] == row[1]
|
||
assert row[6] == "" and row[7] is True, row
|
||
|
||
# 2b. the FINAL file (N == 0) — the count part HIDES (phase 193,
|
||
# A1): no "0 remaining", the main part names the file, and
|
||
# the A4 full label is just the main part.
|
||
row = by_tag["import-final"]
|
||
assert row[3] == "Syncing… src/a/b.md", row
|
||
assert row[4] == "" and row[5] is True, (
|
||
"the count part hides at the final file (total - done == 0)"
|
||
)
|
||
assert row[1] == "Syncing… src/a/b.md", row[1]
|
||
assert row[2] == row[1]
|
||
|
||
# 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 (
|
||
("import-final", "Syncing… src/a/b.md"),
|
||
("prelude", "Syncing…"),
|
||
("click", "Syncing…"),
|
||
("upload", "Importing"),
|
||
("overview", "Writing KB overview…"),
|
||
("summaries", "Summarizing folders… src/alpha (2/3)"),
|
||
):
|
||
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
|
||
# upload job is NOT cancellable in this phase) and hidden +
|
||
# disabled after the real settle (settleSyncButton always hides
|
||
# it — every terminal + idle passes through it).
|
||
for tag in (
|
||
"import-inflight",
|
||
"import-prebatch",
|
||
"import-final",
|
||
"prelude",
|
||
"click",
|
||
"overview",
|
||
"summaries",
|
||
):
|
||
row = by_tag[tag]
|
||
assert row[8] is False and row[9] is False, (
|
||
f"{tag}: the Cancel control must be shown + enabled while a sync run is in flight"
|
||
)
|
||
for tag in ("upload", "idle"):
|
||
row = by_tag[tag]
|
||
assert row[8] is True and row[9] is True, (
|
||
f"{tag}: the Cancel control must be hidden + disabled "
|
||
"(the upload job is not cancellable / the settle hides it)"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_image_part_renders_only_in_the_import_phase_under_node() -> None:
|
||
"""Phase 152 (task 03): behavioral check (node) of the REAL label
|
||
path — the extracted fmtSyncPhaseLabel / setSyncLabel /
|
||
enterSyncRunningState run against the stub element refs. The
|
||
pinned "· image n/m" part (the task's fixture values):
|
||
|
||
* import, a PDF's vision span in flight (images_total 15 > 0)
|
||
WITH the last chunk position still on the status (125/126):
|
||
the composed full string carries BOTH parts — the box, the
|
||
button title, and the running aria-label all read exactly
|
||
"Syncing… src/a/b.md 1 remaining · chunk 125/126 · image 3/15"
|
||
(the owner's stuck label, now moving; phase 193: the count part
|
||
is the count-DOWN); the chunk part KEEPS
|
||
its span ("· chunk 125/126" visible) and the image part gets
|
||
NO span of its own (it rides the composed string only — the
|
||
running button is the spinner, phase 149);
|
||
* import, a purely pictorial PDF (the chunk position never left
|
||
0/0) in its vision span: the image part shows ALONE —
|
||
"Syncing… src/a/b.md 1 remaining · image 7/7" — and the chunk part
|
||
stays hidden + empty (the two parts are distinct and each
|
||
disappears independently on its own 0/0);
|
||
* every OTHER running state (the pre-batch import, the prelude,
|
||
the 202/409 click, the upload run, the overview, the
|
||
summaries — the status's image fields are 0/0 or cleared at
|
||
those transitions) leaves the composed string image-free.
|
||
|
||
The box / Cancel-control / is-running contracts of the running
|
||
state apply to the new rows exactly as to every other running
|
||
state (the harness's other pins cover them per tag)."""
|
||
rows = _chunk_harness_rows()
|
||
by_tag = {row[0]: row for row in rows}
|
||
# 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]
|
||
|
||
# 1b. both parts at once — the composed full string, byte-for-byte
|
||
# (the image part LAST, after the chunk part), on the box, the
|
||
# title, and the running aria-label (A4) — the chunk span
|
||
# keeps its own part.
|
||
full = "Syncing… src/a/b.md 1 remaining · chunk 125/126 · image 3/15"
|
||
row = by_tag["import-image-span"]
|
||
assert row[1] == full, row[1]
|
||
assert row[2] == full, "the announcer (the box) hears the whole thing (A4)"
|
||
assert row[14] == full, "the running button's accessible name is the full label"
|
||
assert row[10] == full, "the detail box carries the EXACT full label"
|
||
assert row[6] == "· chunk 125/126" and row[7] is False, (
|
||
"the chunk part keeps its own span alongside the image part"
|
||
)
|
||
|
||
# 1c. the image part alone (the chunk position never left 0/0 —
|
||
# the two parts are independent).
|
||
row = by_tag["import-image-only"]
|
||
assert row[1] == "Syncing… src/a/b.md 1 remaining · image 7/7", row[1]
|
||
assert row[2] == row[1] and row[14] == row[1], row
|
||
assert row[6] == "" and row[7] is True, (
|
||
"the chunk part stays hidden + empty (its own 0/0)"
|
||
)
|
||
|
||
# The pre-batch import (0/0 on BOTH positions) and every other
|
||
# running state: the composed string is image-free (the part
|
||
# hides on its own 0/0 — the overview/summaries transitions
|
||
# clear the position server-side, the bare builders carry none).
|
||
for tag, expected_title in (
|
||
("import-inflight", "Syncing… src/a/b.md 1 remaining · chunk 14/38"),
|
||
("import-prebatch", "Syncing… src/a/b.md 1 remaining"),
|
||
("import-final", "Syncing… src/a/b.md"),
|
||
("prelude", "Syncing…"),
|
||
("click", "Syncing…"),
|
||
("upload", "Importing"),
|
||
("overview", "Writing KB overview…"),
|
||
("summaries", "Summarizing folders… src/alpha (2/3)"),
|
||
):
|
||
row = by_tag[tag]
|
||
assert row[1] == expected_title, (tag, row)
|
||
assert "· image" not in row[1], (
|
||
f"{tag}: the image part shows only while images_total > 0"
|
||
)
|
||
|
||
# The running-state contracts apply to the new rows too: box
|
||
# visible + is-running + spinning, the Cancel control shown +
|
||
# enabled (a sync run in flight).
|
||
for tag in ("import-image-span", "import-image-only"):
|
||
row = by_tag[tag]
|
||
assert row[11] is False, f"{tag}: the box must be visible while in flight"
|
||
assert row[12] is True and row[13] is True, f"{tag}: is-running + spinning"
|
||
assert row[8] is False and row[9] is False, (
|
||
f"{tag}: the Cancel control is shown + enabled while a sync runs"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
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 /
|
||
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
|
||
overview, the summaries): the box is REVEALED (hidden=false)
|
||
and carries the full label (the same string as the button
|
||
title / announcer — the writer's `full`), the button carries
|
||
`is-running` + the icon `is-spinning` (the class travels with
|
||
the spin), and the button's accessible name (aria-label) is
|
||
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`): 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 (the box's text — the sole live
|
||
# region, phase 150), main, count, countHidden, chunk,
|
||
# chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||
# progressHidden, btnRunning, iconSpinning, ariaLabel]
|
||
|
||
# Every running state: box visible + the EXACT full label +
|
||
# is-running + spinning + the full-label accessible name.
|
||
for tag in (
|
||
"import-inflight",
|
||
"import-prebatch",
|
||
"import-final",
|
||
"prelude",
|
||
"click",
|
||
"upload",
|
||
"overview",
|
||
"summaries",
|
||
):
|
||
row = by_tag[tag]
|
||
assert row[11] is False, (
|
||
f"{tag}: the detail box must be VISIBLE while the run is in flight"
|
||
)
|
||
assert row[10] == row[1], (
|
||
f"{tag}: the box carries the EXACT full label (title == box)"
|
||
)
|
||
assert row[12] is True, (
|
||
f"{tag}: the button must carry is-running while in flight"
|
||
)
|
||
assert row[13] is True, (
|
||
f"{tag}: is-running travels with the spin (the icon spins)"
|
||
)
|
||
assert row[14] == row[1], (
|
||
f"{tag}: the running button's accessible name is the full live label"
|
||
)
|
||
|
||
# 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[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"
|
||
assert row[14] == "Sync sources", (
|
||
"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 +
|
||
render-only-while-images_total>0 contracts: the import
|
||
fall-through is the ONLY place in sources.js that reads the
|
||
status's chunk OR image fields (the summaries/overview branches
|
||
and the bare builders never touch them), and every
|
||
NON-running label emission passes no chunk/image text (both
|
||
parts hide): the settle/idle labels go through setSyncLabel(label)
|
||
— the only running-flagged call site (phase 149: `running = true`)
|
||
is the running entry (phase 152: passing the image part as its
|
||
fifth argument)."""
|
||
js = _js()
|
||
body = _fn(js, "fmtSyncPhaseLabel")
|
||
i_fall = body.find("fmtSyncLabel(")
|
||
pre = body[:i_fall]
|
||
assert "chunks_total" not in pre and "current_chunk" not in pre, (
|
||
"the overview/summaries branches never read the chunk fields"
|
||
)
|
||
assert "images_total" not in pre and "images_done" not in pre, (
|
||
"the overview/summaries branches never read the image fields (phase 152)"
|
||
)
|
||
fall = body[i_fall:]
|
||
assert "status.chunks_total" in fall and "status.current_chunk" in fall
|
||
assert "status.images_total" in fall and "status.images_done" in fall, (
|
||
"the import fall-through is the ONLY reader of the image fields (phase 152)"
|
||
)
|
||
assert js.count("setSyncLabel(main, count, chunk, true, image)") == 1, (
|
||
"the ONLY running-flagged call site is the running entry (phase 149"
|
||
" — phase 152: with the image part)"
|
||
)
|
||
assert "setSyncLabel(label)" in js, (
|
||
"the settle/idle labels stay two-arg (no chunk/image)"
|
||
)
|
||
|
||
|
||
# ---------- the two-job tick (startSyncPolling) ----------
|
||
|
||
|
||
def test_tick_fetches_both_jobs_with_403_and_blip_rules() -> None:
|
||
"""Each tick fetches BOTH status endpoints (the sync and the
|
||
background upload run — phase 90: unpack + register, no scan).
|
||
The 403 backstop (button hidden) applies
|
||
to the SYNC fetch only — a 403 on the upload fetch is simply "no
|
||
upload" (never a hide); a network blip on either fetch retries next
|
||
tick (the tick reschedules, it never dies on a failed fetch)."""
|
||
tick = _tick(_js())
|
||
assert 'fetch("/api/sync/status")' in tick
|
||
assert 'fetch("/api/git-sources/upload/status")' in tick
|
||
assert "r.status === 403" in tick, "the sync fetch keeps the whoami backstop"
|
||
assert "ur.status === 403" not in tick, "upload 403 = no upload (A3)"
|
||
assert "ur.ok" in tick, "the upload fetch is read only when ok"
|
||
assert tick.count("catch {") >= 2, "both fetches are blip-tolerant"
|
||
assert tick.count("setTimeout(tick, SYNC_POLL_MS)") >= 3, (
|
||
"the blip and both running branches all reschedule"
|
||
)
|
||
# the sync-status blip must not settle the button: it reschedules.
|
||
i_nostatus = tick.find("if (!syncStatus)")
|
||
assert i_nostatus != -1
|
||
branch = tick[i_nostatus : i_nostatus + 200]
|
||
assert "setTimeout(tick, SYNC_POLL_MS)" in branch
|
||
assert "applySyncIdle" not in branch, "a blip is not an idle"
|
||
|
||
|
||
def test_tick_decision_tree_order_and_branches() -> None:
|
||
"""The phase-64 decision tree, in order (A3/A4): 1. sync running →
|
||
2. upload running → 3. sync success → 4. sync failed → 5. upload
|
||
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 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)."""
|
||
tick = _tick(_js())
|
||
b_sync_run = tick.find('syncStatus.state === "running"')
|
||
b_up_run = tick.find('uploadStatus && uploadStatus.state === "running"')
|
||
b_sync_ok = tick.find('syncStatus.state === "success"')
|
||
b_sync_fail = tick.find('syncStatus.state === "failed"')
|
||
b_up_ok = tick.find('uploadStatus && uploadStatus.state === "success"')
|
||
b_up_fail = tick.find('uploadStatus && uploadStatus.state === "failed"')
|
||
b_idle = tick.find("applySyncIdle(syncStatus)")
|
||
assert (
|
||
-1 < b_sync_run < b_up_run < b_sync_ok < b_sync_fail < b_up_ok < b_up_fail < b_idle
|
||
), "the tree must fire in the documented order"
|
||
# 1. sync running: the sync-kind live label, reschedule.
|
||
branch1 = tick[b_sync_run:b_up_run]
|
||
assert (
|
||
'"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total'
|
||
in branch1
|
||
)
|
||
assert "setTimeout(tick, SYNC_POLL_MS)" in branch1
|
||
# 2. upload running: the upload-kind live label (A3), reschedule.
|
||
branch2 = tick[b_up_run:b_sync_ok]
|
||
assert (
|
||
'"upload", uploadStatus.current_file, uploadStatus.files_done, uploadStatus.files_total'
|
||
in branch2
|
||
)
|
||
assert "setTimeout(tick, SYNC_POLL_MS)" in branch2
|
||
# 3 + 4. the sync terminals are the unchanged phase-32 appliers.
|
||
assert "applySyncSuccess(syncStatus)" in tick[b_sync_ok:b_sync_fail]
|
||
assert "applySyncFailure(syncStatus)" in tick[b_sync_fail:b_up_ok]
|
||
# 5. upload success: settle + clear + hide stale error + emit idle
|
||
# + the catalog refresh (the new documents must appear).
|
||
up_ok = tick[b_up_ok:b_up_fail]
|
||
for line in (
|
||
'settleSyncButton("Sync sources")',
|
||
'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 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")',
|
||
'setSyncProgress("")', # phase 150: the box hides, empty (A3)
|
||
"hideSyncError()",
|
||
'emitSyncStatus({ state: "idle" })',
|
||
):
|
||
assert line in up_fail, f"the upload-failed settle must carry {line!r}"
|
||
assert "loadTree()" not in up_fail, "no KB change on a failed upload"
|
||
assert "showSyncError" not in up_fail, "no banner on this page (A3)"
|
||
assert "applySyncFailure" not in up_fail and "showSyncModal" not in up_fail
|
||
# 7. both idle: the unchanged idle settle.
|
||
assert "stopSyncPolling()" in tick[b_idle - 60 : b_idle + 40]
|
||
|
||
|
||
# ---------- the click branch + the load-time re-attach ----------
|
||
|
||
|
||
def test_click_branch_enters_sync_running_without_a_file() -> None:
|
||
"""The 202/409 branch of startSync enters the running state with
|
||
the sync kind and no file yet (the run is just starting — model
|
||
check / clone-pull: bare "Syncing…", A4); the emitSyncStatus({
|
||
state: "running" }) dedup via lastSyncState stays, and the poll
|
||
starts."""
|
||
js = _js()
|
||
idx = js.find("r.status === 202 || r.status === 409")
|
||
assert idx != -1
|
||
branch = js[idx : idx + 500]
|
||
assert "enterSyncRunningState(\"sync\", null, 0, 0)" in branch
|
||
assert 'emitSyncStatus({ state: "running" })' in branch
|
||
assert "lastSyncState !== \"running\"" in branch, "the dedup stays"
|
||
assert "startSyncPolling()" in branch
|
||
|
||
|
||
def test_reattach_adopts_a_running_upload_only() -> None:
|
||
"""initSyncButton: the sync terminals render the last result; the
|
||
RUNNING sync re-enters with the live file AND the whole status
|
||
(phase 98 task 02: the label is phase-aware — a mid-summaries
|
||
reload re-enters with the summaries label). With the sync IDLE it
|
||
fetches the upload status: a RUNNING upload run re-attaches
|
||
(running state, upload kind — phase 90: no live file, the label
|
||
stays bare "Importing…" — the synthetic running frame, the poll
|
||
starts); a terminal upload is a no-op — the fall-through is the
|
||
plain idle settle (the boot-time loadTree() already shows the
|
||
current catalog)."""
|
||
body = _fn(_js(), "initSyncButton")
|
||
assert "await fetchIsAdmin()" in body, "admin-only (no extra fetch)"
|
||
assert 'fetch("/api/git-sources/upload/status")' in body
|
||
# the sync running re-attach now carries the live file too
|
||
assert '"sync", status.current_file, status.files_done, status.files_total' in body
|
||
# the ONLY upload branch is the running one (A3)
|
||
assert "upload && upload.state === \"running\"" in body
|
||
assert 'upload.state === "success"' not in body, "a terminal upload never re-attaches"
|
||
assert 'upload.state === "failed"' not in body, "a terminal upload never re-attaches"
|
||
i_check = body.find("upload && upload.state === \"running\"")
|
||
i_fall = body.find("applySyncIdle(status)", i_check)
|
||
assert -1 < i_check < i_fall
|
||
branch = body[i_check:i_fall]
|
||
assert '"upload", upload.current_file, upload.files_done, upload.files_total' in branch
|
||
assert 'emitSyncStatus({ state: "running" })' in branch
|
||
assert "startSyncPolling()" in branch
|
||
# the idle settle is the fall-through (the last statement — the
|
||
# brace-balanced body ends with the closing brace)
|
||
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
|
||
|
||
|
||
def test_tick_sync_running_passes_the_status_for_the_phase_label() -> None:
|
||
"""Branch 1 of the two-job tick (phase 98 task 02): the sync
|
||
running state passes the WHOLE syncStatus object as the fifth
|
||
argument — the phase-aware label (overview / summaries) reads its
|
||
phase fields. The upload running branch stays on the BARE four-arg
|
||
label (its status has no phase — D2): no fifth argument, no status
|
||
object anywhere in the branch."""
|
||
tick = _tick(_js())
|
||
b_sync_run = tick.find('syncStatus.state === "running"')
|
||
b_up_run = tick.find('uploadStatus && uploadStatus.state === "running"')
|
||
b_sync_ok = tick.find('syncStatus.state === "success"')
|
||
assert -1 < b_sync_run < b_up_run < b_sync_ok
|
||
branch1 = tick[b_sync_run:b_up_run]
|
||
i_call = branch1.find("enterSyncRunningState(")
|
||
call = branch1[i_call:branch1.find(");", i_call)]
|
||
assert re.search(
|
||
r'"sync",\s*syncStatus\.current_file,\s*syncStatus\.files_done,\s*'
|
||
r"syncStatus\.files_total,\s*syncStatus\s*$",
|
||
call,
|
||
), "branch 1 passes the whole status (fifth arg) for the phase-aware label"
|
||
branch2 = tick[b_up_run:b_sync_ok]
|
||
i_call2 = branch2.find("enterSyncRunningState(")
|
||
call2 = branch2[i_call2:branch2.find(");", i_call2)]
|
||
assert re.fullmatch(
|
||
r'enterSyncRunningState\(\s*"upload",\s*uploadStatus\.current_file,\s*'
|
||
r"uploadStatus\.files_done,\s*uploadStatus\.files_total\s*$",
|
||
call2,
|
||
), "the upload job keeps the bare four-arg label — no status, no phase (D2)"
|
||
assert "phase" not in call2 and "summaries" not in call2
|
||
|
||
|
||
def test_reattach_sync_running_passes_the_status_for_the_phase_label() -> None:
|
||
"""initSyncButton (phase 98 task 02): the RUNNING sync re-attach
|
||
passes the WHOLE status object — a reload mid-summaries re-enters
|
||
the running state with the summaries label (the never-stale
|
||
contract); the RUNNING upload re-attach stays on the bare four-arg
|
||
label (its status has no phase — D2)."""
|
||
body = _fn(_js(), "initSyncButton")
|
||
i_sync = body.find('"sync", status.current_file')
|
||
assert i_sync != -1
|
||
call = body[i_sync:body.find(");", i_sync)]
|
||
assert re.search(
|
||
r'"sync",\s*status\.current_file,\s*status\.files_done,\s*'
|
||
r"status\.files_total,\s*status\s*$",
|
||
call,
|
||
), "the RUNNING sync re-attach passes the status object (fifth arg)"
|
||
i_up = body.find('"upload", upload.current_file')
|
||
assert i_up != -1
|
||
call2 = body[i_up:body.find(");", i_up)]
|
||
assert re.fullmatch(
|
||
r'"upload",\s*upload\.current_file,\s*upload\.files_done,\s*upload\.files_total\s*$',
|
||
call2,
|
||
), "the RUNNING upload re-attach keeps the bare four-arg label (D2)"
|
||
|
||
|
||
# ---------- the section header + the page comment ----------
|
||
|
||
|
||
def test_section_header_documents_the_two_job_contract() -> None:
|
||
"""The sync-button section marker comment documents the phase-64
|
||
contract: the live file label, the two-job decision tree (both
|
||
status endpoints), and the A3 settle behavior (catalog refresh; the
|
||
upload counts never render here)."""
|
||
js = _js()
|
||
marker = js.find("Sync sources button (Sources page only)")
|
||
assert marker != -1, "the sync section marker comment must stay"
|
||
header = js[marker : js.find("const syncBtn")]
|
||
assert "Phase 64 (task 04)" in header
|
||
assert "/api/git-sources/upload/status" in header, "the second job's endpoint"
|
||
assert "loadTree" in header, "the A3 catalog refresh (phase 97 rename)"
|
||
assert "A3" in header and "A4" in header
|
||
|
||
|
||
def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||
"""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-progress"')
|
||
assert idx != -1
|
||
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 ----------
|
||
|
||
|
||
def test_sync_label_css_ellipsis_truncation() -> None:
|
||
""".sync-label (phase 128 task 02): the live-file label is a FLEX
|
||
ROW of two parts inside the pill (A4) — the container is
|
||
inline-flex with the min(16rem, 40vw) cap + min-width: 0; the
|
||
.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-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)."""
|
||
css = _css()
|
||
block = re.search(r"\.sync-label\s*\{([^}]*)\}", css)
|
||
assert block, "styles.css must style .sync-label"
|
||
body = block.group(1)
|
||
for prop in (
|
||
"display: inline-flex",
|
||
"align-items: baseline",
|
||
"gap: 0.35rem",
|
||
"min-width: 0",
|
||
"max-width: min(16rem, 40vw)",
|
||
):
|
||
assert prop in body, f".sync-label must carry {prop!r}"
|
||
assert "inline-block" not in body, "the old inline-block container is gone"
|
||
assert "vertical-align" not in body, "the flex row aligns the parts"
|
||
main_block = re.search(r"\.sync-label-main\s*\{([^}]*)\}", css)
|
||
assert main_block, "styles.css must style .sync-label-main"
|
||
for prop in (
|
||
"min-width: 0",
|
||
"overflow: hidden",
|
||
"text-overflow: ellipsis",
|
||
"white-space: nowrap",
|
||
):
|
||
assert prop in main_block.group(1), f".sync-label-main must carry {prop!r}"
|
||
count_block = re.search(r"\.sync-label-count\s*\{([^}]*)\}", css)
|
||
assert count_block, "styles.css must style .sync-label-count"
|
||
for prop in (
|
||
"flex: 0 0 auto",
|
||
"white-space: nowrap",
|
||
"font-family: var(--mono)",
|
||
):
|
||
assert prop in count_block.group(1), f".sync-label-count must carry {prop!r}"
|
||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||
assert mobile, "the ≤640px media query must stay"
|
||
mbody = mobile.group(1)
|
||
assert ".sync-btn { width: 100%; }" in mbody, (
|
||
"the mobile Sync pill is full width (the icon-only squeeze is gone)"
|
||
)
|
||
assert ".sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }" in mbody, (
|
||
"the label fills the full-width pill; the main part ellipsizes against the pinned count"
|
||
)
|
||
|
||
|
||
# =====================================================================
|
||
# Phase 64 task 05 — the Sources-page upload (git-sources.js)
|
||
# =====================================================================
|
||
|
||
|
||
# ---------- the "Successfully uploaded" toast (A2) ----------
|
||
|
||
|
||
def test_upload_toast_single_node_status_and_auto_dismiss() -> None:
|
||
"""showUploadToast (A2 owner-locked): the phase-55 share-toast
|
||
pattern made page-local — a SINGLE node, lazy-created on the first
|
||
202 and reused (toasts never stack): a plain ``<div class="toast">``
|
||
appended to ``document.body``; ``role="status" aria-live="polite"``
|
||
(on THIS page the toast is the a11y announcer for the 202); the
|
||
text lands via ``textContent`` (XSS-safe — never innerHTML). A new
|
||
toast replaces a pending one: clear the prior dismiss timer, remove
|
||
the visible class, force a reflow (``offsetWidth`` — restarts the
|
||
CSS transition), re-add the class. Auto-dismiss: the 5000ms
|
||
(UPLOAD_TOAST_MS) timer is armed AFTER the visible class is added
|
||
and removes the class on fire. The existing .toast CSS is reused
|
||
as-is (no new styles)."""
|
||
js = _gjs()
|
||
body = _gfn(js, "showUploadToast")
|
||
assert "if (!uploadToastEl)" in body, "the node is created once, on first use"
|
||
assert 'document.createElement("div")' in body
|
||
assert 'uploadToastEl.className = "toast"' in body, "the phase-55 .toast CSS, as-is"
|
||
assert 'uploadToastEl.setAttribute("role", "status")' in body
|
||
assert 'uploadToastEl.setAttribute("aria-live", "polite")' in body
|
||
assert "document.body.appendChild(uploadToastEl)" in body
|
||
assert "uploadToastEl.textContent = message" in body
|
||
assert "innerHTML" not in body, "XSS contract: textContent only"
|
||
# Single instance: module-scope node + timer.
|
||
assert re.search(r"^let uploadToastEl = null", js, re.M), "the node is module scope"
|
||
assert re.search(r"^let uploadToastTimer = 0", js, re.M), "the timer is module scope"
|
||
assert "const UPLOAD_TOAST_MS = 5000" in js, "the ~5 s auto-dismiss (A2)"
|
||
# Re-trigger order: clear dismiss → remove class → force reflow →
|
||
# re-add the visible class.
|
||
clear_i = body.find("clearTimeout(uploadToastTimer)")
|
||
remove_i = body.find('uploadToastEl.classList.remove("is-visible")')
|
||
reflow_i = body.find("void uploadToastEl.offsetWidth")
|
||
add_i = body.find('uploadToastEl.classList.add("is-visible")')
|
||
assert -1 < clear_i < remove_i < reflow_i < add_i, (
|
||
"dismiss cleared → class removed → reflow forced → visible re-added"
|
||
)
|
||
# The auto-dismiss timer is armed AFTER the visible class is set.
|
||
timer_i = body.find("setTimeout")
|
||
assert -1 < add_i < timer_i and "UPLOAD_TOAST_MS" in body[timer_i:]
|
||
assert 'uploadToastEl.classList.remove("is-visible")' in body[timer_i:], (
|
||
"the pending dismiss removes the visible state"
|
||
)
|
||
|
||
|
||
def test_toast_fires_on_202_with_the_safe_name() -> None:
|
||
"""The 202 branch of the upload submit (A1/A2): the 202 body
|
||
(UploadAccepted) is parsed for the safe source name — a body parse
|
||
failure degrades to the picked file's name — and the toast fires
|
||
with `Successfully uploaded — <name>` BEFORE the scan finishes:
|
||
the file input clears, the processing state enters, and the poll
|
||
starts. The toast is the SINGLE success surface: exactly one call
|
||
site in the whole page (definition + one call — never a failure
|
||
branch, never the poll)."""
|
||
js = _gjs()
|
||
sub = _usubmit(js)
|
||
i202 = sub.find("r.status === 202")
|
||
i409 = sub.find("r.status === 409")
|
||
assert -1 < i202 < i409, "the 202 branch precedes the 409 re-attach"
|
||
branch = sub[i202:i409]
|
||
assert "let name = file.name;" in branch, "the degrade-to-picked-name fallback"
|
||
assert "await r.json()" in branch, "the UploadAccepted body is parsed"
|
||
assert "data.name" in branch, "the safe source name comes from the 202 body"
|
||
assert "lastUploadName = name" in branch, (
|
||
"the accepted 202's safe name is recorded for the result line (phase 90)"
|
||
)
|
||
assert "showUploadToast(`Successfully uploaded — ${name}`)" in branch
|
||
assert 'uploadFileInput.value = ""' in branch, "the file input clears at 202"
|
||
assert "enterUploadProcessingState()" in branch
|
||
assert "startUploadPolling()" in branch
|
||
# Success-only: exactly the definition + the single 202 call.
|
||
assert js.count("showUploadToast(") == 2, (
|
||
"the definition + exactly ONE call site (the 202 branch)"
|
||
)
|
||
|
||
|
||
# ---------- the processing state + the live label ----------
|
||
|
||
|
||
def test_processing_state_is_bare_for_the_whole_run() -> None:
|
||
"""Phase 90 (A2): the button's processing entry (202 / 409) is
|
||
disabled, BARE "Processing…", title cleared — and the tick's
|
||
RUNNING branch renders exactly that same bare label for the whole
|
||
background run: the unpack has no file-level progress, so the
|
||
phase-64 live-file interpolation is gone (no current_file, no
|
||
"(n/m)" counts, no file in the title), then it reschedules at the
|
||
2 s house cadence."""
|
||
js = _gjs()
|
||
state = _gfn(js, "enterUploadProcessingState")
|
||
assert "uploadBtn.disabled = true" in state
|
||
assert 'uploadBtn.textContent = "Processing…"' in state
|
||
assert 'uploadBtn.title = "";' in state, "the title stays clear (no live file)"
|
||
assert "const UPLOAD_POLL_MS = 2000" in js, "the SYNC_POLL_MS house value"
|
||
tick = _utick(js)
|
||
i_run = tick.find('status.state === "running"')
|
||
i_stop = tick.find("stopUploadPolling();")
|
||
assert -1 < i_run < i_stop, "the running branch precedes the terminal stop"
|
||
run = tick[i_run:i_stop]
|
||
assert 'uploadBtn.textContent = "Processing…"' in run, (
|
||
"the bare label — no file, no counts (phase 90, A2)"
|
||
)
|
||
assert 'uploadBtn.title = "";' in run, "the title stays clear"
|
||
assert "status.current_file" not in run, (
|
||
"no live file — the scan's progress moved to the sync"
|
||
)
|
||
assert "files_total" not in run and "files_done" not in run, "no (n/m) counts"
|
||
assert "uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS)" in run, "reschedule"
|
||
|
||
|
||
def test_fmt_upload_result_is_the_ready_for_sync_line() -> None:
|
||
"""Phase 90 (A2/A3) extended by Phase 154 (task 02): the result
|
||
line reads the no-count {"message": "uploaded"} detail and renders
|
||
the ready-for-sync line in the FOUR phase-154 shapes —
|
||
"Uploaded <name> (<stamp>) — press Sync sources to import it." (the
|
||
name + the completion stamp), "Uploaded <name> — press Sync sources
|
||
to import it." (name, no stamp — BYTE-IDENTICAL to the pre-phase
|
||
string), "Uploaded <stamp> — press Sync sources to import it." (no
|
||
name + stamp — the boot re-render, self-dating with the name
|
||
unknown), "Uploaded — press Sync sources to import it." (no name,
|
||
no stamp — BYTE-IDENTICAL to the pre-phase string; the running /
|
||
legacy / partial payload). The non-uploaded detail keeps the
|
||
unchanged "The upload finished."; the old sync-style count keys
|
||
(added / updated / unchanged / pruned) are no longer read at all.
|
||
The 202 branch records the safe name (lastUploadName); the poll's
|
||
settle and the boot re-attach both render the line from (detail,
|
||
lastUploadName, fmtUploadStamp(status)); the button's idle label
|
||
is "Upload"."""
|
||
js = _gjs()
|
||
body = _gfn(js, "fmtUploadResult")
|
||
assert "function fmtUploadResult(detail, name, when)" in body, (
|
||
"phase 154: the precomputed completion stamp is the third parameter"
|
||
)
|
||
assert 'detail.message === "uploaded"' in body
|
||
# The four shapes (name×stamp, name×no-stamp, no-name×stamp,
|
||
# no-name×no-stamp).
|
||
assert "Uploaded ${name} (${when}) — press Sync sources to import it." in body
|
||
assert "Uploaded ${name} — press Sync sources to import it." in body, (
|
||
"the no-stamp nameful variant — byte-identical to pre-phase"
|
||
)
|
||
assert "Uploaded ${when} — press Sync sources to import it." in body, (
|
||
"the nameless + stamp variant (the boot re-render — self-dating)"
|
||
)
|
||
assert "Uploaded — press Sync sources to import it." in body, (
|
||
"the nameless no-stamp variant — byte-identical to pre-phase"
|
||
)
|
||
assert '"The upload finished."' in body, ("the non-uploaded branch is unchanged")
|
||
for key in ("added", "updated", "unchanged", "pruned"):
|
||
assert key not in body, f"the sync-style count {key!r} is gone"
|
||
# The 202 branch records the safe name for the settled line.
|
||
sub = _usubmit(js)
|
||
i202 = sub.find("r.status === 202")
|
||
i409 = sub.find("r.status === 409")
|
||
assert "lastUploadName = name" in sub[i202:i409]
|
||
assert "let lastUploadName = null" in js, "page-local (null after a reload)"
|
||
# Both render sites pass (…, lastUploadName, fmtUploadStamp(status)).
|
||
tick = _utick(js)
|
||
assert re.search(
|
||
r"uploadResult\.textContent = fmtUploadResult\(\s*detail,\s*"
|
||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||
tick,
|
||
), "the poll's success settle passes the completion stamp (phase 154)"
|
||
init = _gfn(js, "initUploadStatus")
|
||
assert re.search(
|
||
r"uploadResult\.textContent = fmtUploadResult\(\s*status\.detail,\s*"
|
||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||
init,
|
||
), "the boot re-attach passes the completion stamp (phase 154)"
|
||
# The button's idle label (static + the poll's restores).
|
||
restore = _gfn(js, "restoreUploadButton")
|
||
assert 'uploadBtn.textContent = "Upload"' in restore
|
||
# The phase-64 scan-suffixed label is gone (exactly "Upload") — the
|
||
# needles are split so this file carries no forbidden literal (the
|
||
# phase-90 rg criterion sweeps frontend/ app/ tests/ for it).
|
||
assert ("Upload " + "& scan") not in js and ("Upload " + "and scan") not in js
|
||
|
||
|
||
def test_start_upload_polling_double_start_guard() -> None:
|
||
"""startUploadPolling: single timer, one loop at a time — the
|
||
first statement bails when a poll is already active (the guard a
|
||
double 202/409 cannot bypass)."""
|
||
js = _gjs()
|
||
i = js.find("function startUploadPolling")
|
||
assert i != -1
|
||
head = js[i : i + 120]
|
||
assert "if (uploadPollTimer !== null) return;" in head
|
||
assert "let uploadPollTimer = null" in js, "null = no poll active"
|
||
|
||
|
||
# ---------- the 409 re-attach + the kept error branches ----------
|
||
|
||
|
||
def test_409_reattaches_without_an_error_banner() -> None:
|
||
"""409 (an upload is already in progress): NO error banner — the
|
||
phase-49 "server detail inline" branch does not apply to 409
|
||
anymore. It re-attaches to the in-flight run: the processing state
|
||
+ the poll (never stale). The OTHER non-2xx (422 format/name, 413
|
||
cap, 5xx) keep the phase-49 apiDetail banner + the kept file
|
||
selection; the network-failure fixed line stays."""
|
||
js = _gjs()
|
||
sub = _usubmit(js)
|
||
i409 = sub.find("r.status === 409")
|
||
assert i409 != -1
|
||
branch = sub[i409 : sub.find("// Other non-2xx", i409)]
|
||
assert "enterUploadProcessingState()" in branch
|
||
assert "startUploadPolling()" in branch
|
||
assert "uploadError" not in branch, "409 never raises the error banner"
|
||
assert "apiDetail" not in branch, "no server-detail branch for 409 anymore"
|
||
# The other non-2xx keeps the phase-49 convention (after the 409).
|
||
i_err = sub.find('await apiDetail(r, "Could not upload the archive — try again.")')
|
||
assert i_err > i409, "the other non-2xx branch follows the 409 re-attach (and was found)"
|
||
assert "uploadError.hidden = false" in sub[i_err:], "the banner shows for the other non-2xx"
|
||
assert "Could not upload the archive — is the app reachable?" in sub, "the network line stays"
|
||
# The no-file guard + the short transfer label stay.
|
||
assert "Choose an archive file to upload." in sub
|
||
assert 'uploadBtn.textContent = "Uploading…"' in sub
|
||
|
||
|
||
# ---------- the poll's terminal decision tree ----------
|
||
|
||
|
||
def test_upload_polling_decision_tree() -> None:
|
||
"""The upload poll tick (task 05): fetches
|
||
GET /api/git-sources/upload/status; a blip (non-ok / network /
|
||
unparseable) reschedules — the tick never dies on a failed fetch.
|
||
Then: running → live label + reschedule; ONE stop for the
|
||
terminals; success → the result line (fmtUploadResult — the
|
||
existing helper reads exactly these keys) + the announce + the row
|
||
reload + the cleared file input + the restored button — NO toast
|
||
(it already fired at the 202, A2); failed → the sanitized server
|
||
error banner (A2 failure UI) + the restored button + the row
|
||
reload (a post-swap failure keeps the row — the list state may
|
||
have changed; the selection is kept for a one-click re-upload);
|
||
idle → the defensive restore (a started run never returns to
|
||
idle)."""
|
||
tick = _utick(_gjs())
|
||
assert 'fetch("/api/git-sources/upload/status")' in tick
|
||
# The blip branch reschedules.
|
||
i_blip = tick.find("if (!status)")
|
||
assert i_blip != -1
|
||
assert "uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS)" in tick[i_blip:i_blip + 150]
|
||
# One stop, placed after the running branch and before the terminals.
|
||
assert tick.count("stopUploadPolling()") == 1
|
||
i_run = tick.find('status.state === "running"')
|
||
i_stop = tick.find("stopUploadPolling();")
|
||
i_ok = tick.find('status.state === "success"')
|
||
i_fail = tick.find('status.state === "failed"')
|
||
assert -1 < i_run < i_stop < i_ok < i_fail, "running < stop < success < failed"
|
||
# success: the ready-for-sync line (phase 90 A2/A3) + the
|
||
# next-step announce + reload, NO toast.
|
||
ok = tick[i_ok:i_fail]
|
||
for line in (
|
||
"fmtUploadResult(",
|
||
"uploadResult.hidden = false",
|
||
'announce("Archive uploaded — press Sync sources to import it.")',
|
||
'uploadFileInput.value = ""',
|
||
"restoreUploadButton()",
|
||
"loadSources()",
|
||
):
|
||
assert line in ok, f"the success settle must carry {line!r}"
|
||
assert re.search(
|
||
r"uploadResult\.textContent = fmtUploadResult\(\s*detail,\s*"
|
||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||
ok,
|
||
), "the success settle passes the completion stamp (phase 154)"
|
||
assert "showUploadToast" not in tick, "no toast in the poll — it fired at the 202 (A2)"
|
||
# failed: the sanitized error banner + reload.
|
||
fail = tick[i_fail:]
|
||
assert "status.error" in fail
|
||
assert "uploadError.hidden = false" in fail
|
||
assert "restoreUploadButton()" in fail
|
||
assert "loadSources()" in fail, "the list state may have changed"
|
||
# idle: the defensive fall-through — a final restore, no more state
|
||
# checks after the failed branch.
|
||
i_idle = tick.rfind("restoreUploadButton()")
|
||
assert i_idle > i_fail
|
||
assert "state ===" not in tick[i_idle:], "the idle settle is the fall-through"
|
||
|
||
|
||
# ---------- the finally's never-restore-while-polling guard ----------
|
||
|
||
|
||
def test_finally_restores_only_when_no_poll_active() -> None:
|
||
"""The submit finally (PLAN §7.4): the button is restored ONLY
|
||
when no poll is active (``uploadPollTimer === null``) — while
|
||
startUploadPolling owns the button (the 202 / 409 paths) it stays
|
||
disabled / "Processing…", so an unconditional finally restore
|
||
would race the poll and leave a stale-looking idle button under a
|
||
running scan."""
|
||
sub = _usubmit(_gjs())
|
||
i = sub.find("} finally {")
|
||
assert i != -1
|
||
fin = sub[i:]
|
||
assert "if (uploadPollTimer === null)" in fin, "the guard: no poll → the button is ours"
|
||
assert "restoreUploadButton()" in fin
|
||
assert "uploadBtn.disabled = false" not in fin, "no unconditional restore in the finally"
|
||
|
||
|
||
# ---------- the boot re-attach ----------
|
||
|
||
|
||
def test_boot_reattach_branches() -> None:
|
||
"""initUploadStatus (the admin branch of the boot): the upload
|
||
status is fetched ONCE. running → the processing state + the poll
|
||
(a reload mid-scan re-attaches — no second upload, no error, no
|
||
toast); success → the last result line ONLY (no announce, no
|
||
toast — A2); failed → the error banner; idle → nothing (no
|
||
branch). The boot IIFE awaits it right after loadSources()."""
|
||
js = _gjs()
|
||
body = _gfn(js, "initUploadStatus")
|
||
assert body.count('fetch("/api/git-sources/upload/status")') == 1, "fetched ONCE at boot"
|
||
i_run = body.find('status.state === "running"')
|
||
i_ok = body.find('status.state === "success"')
|
||
i_fail = body.find('status.state === "failed"')
|
||
assert -1 < i_run < i_ok < i_fail
|
||
run = body[i_run:i_ok]
|
||
assert "enterUploadProcessingState()" in run
|
||
assert "startUploadPolling()" in run
|
||
assert "uploadError" not in run and "showUploadToast" not in run
|
||
ok = body[i_ok:i_fail]
|
||
# The last result line — the nameless variant after a reload
|
||
# (lastUploadName is null: the safe name was page-local, phase 90),
|
||
# self-dating through the completion stamp (phase 154 — the stamp
|
||
# comes from the server payload).
|
||
assert re.search(
|
||
r"uploadResult\.textContent = fmtUploadResult\(\s*status\.detail,\s*"
|
||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||
ok,
|
||
), "the boot re-attach passes the completion stamp (phase 154)"
|
||
assert "uploadResult.hidden = false" in ok
|
||
assert "announce(" not in ok, "no announce at boot (A2)"
|
||
assert "showUploadToast" not in ok, "no toast at boot (A2)"
|
||
fail = body[i_fail:]
|
||
assert "status.error" in fail
|
||
assert "uploadError.hidden = false" in fail
|
||
assert 'status.state === "idle"' not in body, "idle does nothing — no branch"
|
||
# The mount's tail (phase 76 task 02 — the boot IIFE is gone):
|
||
# after the list loads, the re-attach runs (admin branch only — the
|
||
# anonymous path returns before it), and it is the LAST statement
|
||
# of mount(root).
|
||
i_boot = js.rfind("await loadSources();")
|
||
tail = js[i_boot:i_boot + 400]
|
||
assert "await initUploadStatus();" in tail
|
||
assert "})();" not in js, "the top-level boot IIFE is gone (mount owns boot)"
|
||
assert tail.rstrip().removesuffix("}").rstrip().endswith("await initUploadStatus();")
|
||
|
||
|
||
# ---------- the page comment ----------
|
||
|
||
|
||
def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
||
"""The #archive-upload-form comment in the shell's Sources view
|
||
(formerly git-sources.html) documents the phase-64 202 contract
|
||
(the phase-49 synchronous paragraph marked superseded): the 202 =
|
||
"safely on disk" + the JS-created toast (no markup), the bare
|
||
"Processing…" label via the status poll, the 409 re-attach without
|
||
an error banner — and phase 90's unpack-only rework (the run is
|
||
UNPACK + REGISTER ONLY and the success line points at the RAG
|
||
page's "Sync sources" button)."""
|
||
html = _ghtml()
|
||
idx = html.find('id="archive-upload-form"')
|
||
assert idx != -1
|
||
comment = html[max(0, idx - 1600):idx]
|
||
assert "Phase 64" in comment
|
||
assert "superseded" in comment, "the phase-49 synchronous paragraph is marked superseded"
|
||
assert "Successfully uploaded" in comment, "the toast is documented"
|
||
assert "Processing…" in comment, "the (now bare) label is documented"
|
||
assert "GET /api/git-sources/upload/status" in comment, "the polling endpoint"
|
||
assert "409 re-attaches" in comment, "the re-attach without an error banner"
|
||
assert "Phase 90" in comment, "the unpack-only rework is documented"
|
||
assert "UNPACK + REGISTER ONLY" in comment
|
||
assert "Sync sources" in comment, "the result line points at the RAG page's button"
|
||
|
||
|
||
# =====================================================================
|
||
# Phase 154 (task 02) — the Sources upload line's completion stamp
|
||
# =====================================================================
|
||
|
||
|
||
def test_fmt_upload_stamp_is_the_duplicated_qualifier() -> None:
|
||
"""Phase 154 (task 02): ``fmtUploadStamp(status)`` — the page-local
|
||
DUPLICATE of sources.js's ``fmtSyncTime`` date-qualifier (the
|
||
per-page duplication idiom — not shared). It reads
|
||
``status.finished_at`` first (the success state always has it on
|
||
the server), falls back to ``status.started_at`` (the defensive
|
||
guard — a corrupt ``finished_at`` degrades to the ``started_at``
|
||
stamp rather than to no stamp), and yields ``""`` when both are
|
||
missing/absent or every ISO is unparseable (the byte-identical
|
||
pre-phase-154 fallback). The qualifier is the SAME A1 structure as
|
||
the phase-154 task-01 pin in test_sync_button.py — local calendar
|
||
dates, ``HH:MM`` same day, ``M/D, h:MM AM/PM`` same year,
|
||
``M/D/YYYY, h:MM AM/PM`` across years, the 12-hour form WITHOUT a
|
||
leading zero on the hour, month/day without leading zeros — cited
|
||
in sources.js, not restated. The ``status || {}`` guard defends a
|
||
missing status object."""
|
||
js = _gjs()
|
||
body = _gfn(js, "fmtUploadStamp")
|
||
assert "function fmtUploadStamp(status)" in body
|
||
assert "const s = status || {};" in body, (
|
||
"defensive: a missing / null status object yields ''"
|
||
)
|
||
assert "s.finished_at" in body and "s.started_at" in body
|
||
i_fin = body.find("s.finished_at")
|
||
i_st = body.find("s.started_at")
|
||
assert -1 < i_fin < i_st, "finished_at is read first — the started_at fallback"
|
||
assert "if (!iso) continue;" in body, (
|
||
"absent / null timestamps are skipped (the fallback reads)"
|
||
)
|
||
assert "if (!Number.isNaN(t.getTime()))" in body, (
|
||
"an unparseable ISO is skipped — a corrupt finished_at degrades"
|
||
" to started_at, '' when neither parses"
|
||
)
|
||
assert 'if (d === null) return "";' in body, (
|
||
"both absent (or unparseable) → '' — the byte-identical pre-"
|
||
"phase-154 fallback"
|
||
)
|
||
# The duplicated A1 qualifier (cited in sources.js's fmtSyncTime,
|
||
# not restated) — the same structure as the task-01 pin.
|
||
assert "const now = new Date();" in body, "'now' is the page's clock"
|
||
assert (
|
||
"d.getFullYear() === now.getFullYear() &&" in body
|
||
and "d.getMonth() === now.getMonth()" in body
|
||
and "d.getDate() === now.getDate()" in body
|
||
), "same local calendar day as now → HH:MM (byte-identical to pre-phase)"
|
||
assert "d.getHours() % 12 || 12" in body, (
|
||
"12-hour WITHOUT leading zero on the hour (the 12 o'clock edge)"
|
||
)
|
||
assert '"AM"' in body and '"PM"' in body, "the AM/PM family"
|
||
assert "pad(d.getMinutes())" in body, "MM zero-padded in every form"
|
||
assert "`${d.getMonth() + 1}/${d.getDate()}`" in body, (
|
||
"M/D, no leading zeros (same year, different day)"
|
||
)
|
||
assert "`${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`" in body, (
|
||
"M/D/YYYY across years"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_upload_result_four_shapes_under_node() -> None:
|
||
"""Phase 154 (task 02): the REAL git-sources.js ``fmtUploadResult``
|
||
under node — the four shapes, ``when`` PRECOMPUTED by the caller
|
||
(so the shape itself is clock-free):
|
||
|
||
* name + stamp: ``Uploaded docs.zip (21:14) — press Sync sources
|
||
to import it.`` (the fresh 202 settle, the page-local name);
|
||
* name, no stamp (``""``): ``Uploaded docs.zip — press Sync
|
||
sources to import it.`` — BYTE-IDENTICAL to the pre-phase-154
|
||
string (the running / legacy / partial payload);
|
||
* no name + stamp: ``Uploaded 10/2, 3:14 PM — press Sync sources
|
||
to import it.`` (the boot re-render — self-dating with the name
|
||
unknown);
|
||
* no name, no stamp (``""``): ``Uploaded — press Sync sources to
|
||
import it.`` — BYTE-IDENTICAL to the pre-phase-154 string;
|
||
* a legacy 2-arg call (``when`` undefined) renders the pre-phase
|
||
nameful string (the fallback is falsy-driven);
|
||
* the non-``uploaded`` detail and a missing detail keep the
|
||
unchanged ``The upload finished.``."""
|
||
js = _gjs()
|
||
fn = _gfn(js, "fmtUploadResult")
|
||
script = (
|
||
fn
|
||
+ "\n"
|
||
'const S = { message: "uploaded" };\n'
|
||
'const O = { message: "other" };\n'
|
||
"console.log(JSON.stringify([\n"
|
||
' fmtUploadResult(S, "docs.zip", "21:14"),\n'
|
||
' fmtUploadResult(S, "docs.zip", ""),\n'
|
||
' fmtUploadResult(S, null, "10/2, 3:14 PM"),\n'
|
||
' fmtUploadResult(S, null, ""),\n'
|
||
' fmtUploadResult(S, "docs.zip"),\n'
|
||
' fmtUploadResult(O, "docs.zip", "21:14"),\n'
|
||
" fmtUploadResult(null, \"docs.zip\", \"21:14\"),\n"
|
||
"]));\n"
|
||
)
|
||
rows = json.loads(_run_node(script))
|
||
assert rows == [
|
||
"Uploaded docs.zip (21:14) — press Sync sources to import it.",
|
||
# byte-identical to pre-phase (the no-stamp nameful variant):
|
||
"Uploaded docs.zip — press Sync sources to import it.",
|
||
"Uploaded 10/2, 3:14 PM — press Sync sources to import it.",
|
||
# byte-identical to pre-phase (the no-stamp nameless variant):
|
||
"Uploaded — press Sync sources to import it.",
|
||
# the legacy 2-arg call (when undefined → falsy → pre-phase):
|
||
"Uploaded docs.zip — press Sync sources to import it.",
|
||
"The upload finished.",
|
||
"The upload finished.",
|
||
], rows
|
||
|
||
|
||
# =====================================================================
|
||
# Phase 154 (task 03) — the byte-identity pins (the pre-phase
|
||
# strings, pinned verbatim — assertable, not assumed)
|
||
# =====================================================================
|
||
|
||
#: The pre-phase-154 fmtUploadResult, pinned VERBATIM from git
|
||
#: history (the 2-arg no-count formatter — the phase-90 contract
|
||
#: pre-stamp). Only the function NAME is renamed (so the real + the
|
||
#: reference run side by side under node); the body is the old code
|
||
#: byte-for-byte — the two old line strings + "The upload finished."
|
||
#: live in this constant, so the byte-identity below is assertable,
|
||
#: not assumed.
|
||
PRE_PHASE_FMT_UPLOAD_RESULT_JS = """
|
||
function prePhaseUploadResult(detail, name) {
|
||
if (detail && detail.message === "uploaded") {
|
||
return name
|
||
? `Uploaded ${name} — press Sync sources to import it.`
|
||
: "Uploaded — press Sync sources to import it.";
|
||
}
|
||
return "The upload finished.";
|
||
}
|
||
"""
|
||
|
||
#: The two pre-phase-154 result-line strings, pinned verbatim as
|
||
#: constants (the byte-identity targets — the task-03 completion
|
||
#: criterion). The nameful one is the old template instantiated with
|
||
#: the test's input name.
|
||
PRE_PHASE_UPLOAD_LINE_NAMEFUL = "Uploaded docs.zip — press Sync sources to import it."
|
||
PRE_PHASE_UPLOAD_LINE_NAMELESS = "Uploaded — press Sync sources to import it."
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_upload_result_no_stamp_is_byte_identical_to_pre_phase() -> None:
|
||
"""Phase 154 (task 03): the two BYTE-IDENTITY fallbacks — the
|
||
REAL 3-arg ``fmtUploadResult`` and the VERBATIM pre-phase 2-arg
|
||
formatter (pinned above) run side by side under node (clock-free
|
||
— ``when`` is precomputed):
|
||
|
||
* name + ``when === ""`` → the pre-phase nameful string,
|
||
byte-identical (the running / legacy / partial payload);
|
||
* no name + ``when === ""`` → the pre-phase nameless string,
|
||
byte-identical (the same payload, the name unknown);
|
||
* the legacy 2-arg call (``when`` undefined → falsy) renders the
|
||
SAME two byte-identical strings (the old call shape is
|
||
unchanged in output);
|
||
* the non-``uploaded`` detail (and a missing detail) keep
|
||
``The upload finished.`` on BOTH sides (the branch the phase
|
||
never touched).
|
||
|
||
The reference implementation's own output is asserted against the
|
||
verbatim old strings FIRST (a self-check on the pinned constant —
|
||
a typo in the pinned function fails here, not silently), so the
|
||
real-vs-reference identity is an assertion against the verbatim
|
||
pre-phase strings (the completion criterion), not an
|
||
assumption."""
|
||
# Source-level self-check: the pinned constant carries the old
|
||
# strings verbatim (both line templates + the non-uploaded
|
||
# branch).
|
||
assert (
|
||
"return name\n"
|
||
" ? `Uploaded ${name} — press Sync sources to import it.`\n"
|
||
' : "Uploaded — press Sync sources to import it.";'
|
||
in PRE_PHASE_FMT_UPLOAD_RESULT_JS
|
||
), "the old nameful + nameless line templates are pinned verbatim"
|
||
assert '"The upload finished."' in PRE_PHASE_FMT_UPLOAD_RESULT_JS, (
|
||
"the non-uploaded branch is pinned verbatim"
|
||
)
|
||
|
||
fn = _gfn(_gjs(), "fmtUploadResult")
|
||
script = (
|
||
fn
|
||
+ "\n"
|
||
+ PRE_PHASE_FMT_UPLOAD_RESULT_JS
|
||
+ "\n"
|
||
'const S = { message: "uploaded" };\n'
|
||
'const O = { message: "other" };\n'
|
||
"console.log(JSON.stringify({\n"
|
||
" REAL: [\n"
|
||
' fmtUploadResult(S, "docs.zip", ""),\n'
|
||
" fmtUploadResult(S, null, \"\"),\n"
|
||
' fmtUploadResult(S, "docs.zip"),\n' # legacy 2-arg call
|
||
" fmtUploadResult(S, null),\n" # legacy 2-arg call
|
||
' fmtUploadResult(O, "docs.zip", "21:14"),\n'
|
||
' fmtUploadResult(null, "docs.zip", "21:14"),\n'
|
||
" ],\n"
|
||
" PRE: [\n"
|
||
' prePhaseUploadResult(S, "docs.zip"),\n'
|
||
" prePhaseUploadResult(S, null),\n"
|
||
' prePhaseUploadResult(O, "docs.zip"),\n'
|
||
' prePhaseUploadResult(null, "docs.zip"),\n'
|
||
" ]\n"
|
||
"}));\n"
|
||
)
|
||
rows = json.loads(_run_node(script))
|
||
# 1. the pinned reference reproduces the verbatim old strings
|
||
# (self-check on the constant — assertable, not assumed).
|
||
assert rows["PRE"] == [
|
||
PRE_PHASE_UPLOAD_LINE_NAMEFUL,
|
||
PRE_PHASE_UPLOAD_LINE_NAMELESS,
|
||
"The upload finished.",
|
||
"The upload finished.",
|
||
], rows
|
||
# 2. the no-stamp + legacy shapes are byte-identical to the
|
||
# reference (i.e. to the verbatim old strings).
|
||
assert rows["REAL"][:4] == [
|
||
PRE_PHASE_UPLOAD_LINE_NAMEFUL, # name + when === ""
|
||
PRE_PHASE_UPLOAD_LINE_NAMELESS, # no name + when === ""
|
||
PRE_PHASE_UPLOAD_LINE_NAMEFUL, # legacy 2-arg call
|
||
PRE_PHASE_UPLOAD_LINE_NAMELESS, # legacy 2-arg call
|
||
], rows
|
||
assert rows["REAL"][0] == rows["PRE"][0], (
|
||
"the nameful no-stamp shape is byte-identical to pre-phase"
|
||
)
|
||
assert rows["REAL"][1] == rows["PRE"][1], (
|
||
"the nameless no-stamp shape is byte-identical to pre-phase"
|
||
)
|
||
# 3. the non-uploaded branch is unchanged on both sides.
|
||
assert rows["REAL"][4] == rows["PRE"][2] == "The upload finished."
|
||
assert rows["REAL"][5] == rows["PRE"][3] == "The upload finished."
|
||
|
||
|
||
#: Phase 154 (task 02): the fixed clock for the stamp's boundary set —
|
||
#: the SAME fixed now as test_sync_button.py's task-01 pin (Oct 3,
|
||
#: 2026, 18:30 LOCAL). The test body never calls `new Date()` for its
|
||
#: own timing: the fake clock is installed in the driver (global Date
|
||
#: patched BEFORE the call — `new Date()` → the fixed now,
|
||
#: `new Date(iso)` → the real parse), and every ISO is
|
||
#: designator-less (local) so the comparison is
|
||
#: timezone-independent.
|
||
|
||
_UPLOAD_STAMP_NOW_JS = "new RealDate(2026, 9, 3, 18, 30, 0)"
|
||
|
||
|
||
def _upload_stamp_driver(fn_src: str, expr: str) -> str:
|
||
"""Run `expr` under node with the REAL git-sources.js function +
|
||
the fake clock (the idiom: the Date patch precedes the call)."""
|
||
driver = (
|
||
f"{fn_src}\n"
|
||
"const RealDate = Date;\n"
|
||
f"const NOW = {_UPLOAD_STAMP_NOW_JS};\n"
|
||
"globalThis.Date = function (...args) {\n"
|
||
" return args.length === 0 ? NOW : new RealDate(...args);\n"
|
||
"};\n"
|
||
f"{expr}\n"
|
||
)
|
||
proc = subprocess.run(
|
||
["node", "-e", driver], capture_output=True, text=True, timeout=60
|
||
)
|
||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||
return proc.stdout
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_upload_stamp_behavior_fixed_clock() -> None:
|
||
"""Phase 154 (task 02): the REAL git-sources.js
|
||
``fmtUploadStamp`` under node (fake clock, the fixed now Oct 3
|
||
2026 18:30 local — the same clock as test_sync_button.py's
|
||
task-01 pin): finished wins over started; started when finished is
|
||
missing/null; ``""`` when both are absent (or the status object is
|
||
missing — the defensive guard); a garbage ISO → ``""`` with no
|
||
fallback (and a corrupt finished_at degrades to the started_at
|
||
stamp when a started_at is present — the defensive guard); the A1
|
||
date-qualifier's boundary set (same day ``HH:MM`` — the audit's
|
||
own ``21:14`` case + the end-of-day edge; yesterday
|
||
``M/D, h:MM AM/PM`` with the ``12:05 AM`` edge; last year
|
||
``M/D/YYYY, h:MM AM/PM``). The wall clock never appears in an
|
||
assertion."""
|
||
fn = _gfn(_gjs(), "fmtUploadStamp")
|
||
|
||
def _lit(status: object) -> str:
|
||
if status is None:
|
||
return "null"
|
||
assert isinstance(status, dict)
|
||
parts = []
|
||
for k, v in status.items():
|
||
if v is None:
|
||
parts.append(f"{k}: null")
|
||
else:
|
||
parts.append(f'{k}: "{v}"')
|
||
return "{" + ", ".join(parts) + "}"
|
||
|
||
# (status, expected) — the expected strings are the pins.
|
||
cases = [
|
||
(
|
||
{"finished_at": "2026-10-03T16:06:00", "started_at": "2026-10-03T16:05:00"},
|
||
"16:06", # finished wins over started
|
||
),
|
||
({"started_at": "2026-10-02T15:14:00"}, "10/2, 3:14 PM"), # no finished → started
|
||
(
|
||
{"finished_at": None, "started_at": "2025-10-03T21:12:00"},
|
||
"10/3/2025, 9:12 PM", # null finished → started, last year
|
||
),
|
||
({"finished_at": "garbage"}, ""), # unparseable, no fallback
|
||
(
|
||
{"finished_at": "garbage", "started_at": "2026-10-03T16:06:00"},
|
||
"16:06", # corrupt finished degrades to the started_at stamp
|
||
),
|
||
({"finished_at": None, "started_at": None}, ""), # both absent
|
||
({}, ""), # both missing
|
||
(None, ""), # the status object itself missing (the guard)
|
||
({"finished_at": "2026-10-02T00:05:00"}, "10/2, 12:05 AM"), # 12:05 AM edge
|
||
({"finished_at": "2026-10-03T23:59:00"}, "23:59"), # same day, end-of-day
|
||
]
|
||
driver_expr = (
|
||
"const CASES = [" + ", ".join(_lit(s) for s, _ in cases) + "];\n"
|
||
"const STAMPS = CASES.map((c) => fmtUploadStamp(c));\n"
|
||
"console.log(JSON.stringify(STAMPS));"
|
||
)
|
||
out = json.loads(_upload_stamp_driver(fn, driver_expr))
|
||
assert out == [expected for _, expected in cases], out
|