phase: 128_sync_label_mobile
Everything verified and 100% green. The earlier rejection was a report-format issue, not a test failure — all work is in the working tree. Final report: **Phase 128 final pass — verified (all 3 tasks complete; no code defects found)** - Verified: `#sync-label` parts markup, `setSyncLabel` writer, part builders (A4 untruncated title/aria-live), flex-row CSS + mobile media query — all match design 1:1. - `uv run pytest --cov=app --cov-report=term`: 3024 passed; TOTAL coverage **99%** (>90% gate). - `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov`: 4 passed (119s, isolated). - `uv run ruff check .`: clean; `uv run pyright`: 0 errors. - Criteria: (1) mobile 390×844 label in-viewport, count visible in import+summaries — ✅ E2E; (2) desktop strings/cap/title unchanged — ✅ E2E+unit; (3) E2E isolated green — ✅; (4) full suite green — ✅. - Docs: README.md fixed 5 pre-existing gaps (PDF phase-126 section + `pdf` token, hidden-folders toggle ph.105, document dates ph.106, private git-source masked tokens ph.121, doc-draft Discard ph.115); AGENTS.md verified consistent — no changes. - Notable: double-`-q` (addopts `-q` + CLI `-q`) suppresses pytest's summary line — standard `-qq` behavior, not a bug; exit codes confirm green. - Next pending phase: `129_sync_chunk_progress`.
This commit is contained in:
@@ -46,6 +46,23 @@ 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-result (A4 — the screen reader hears the whole thing; 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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -159,18 +176,22 @@ def _tick(js: str) -> str:
|
||||
return js[tick:end]
|
||||
|
||||
|
||||
# ---------- fmtSyncLabel: the live-file label contract ----------
|
||||
# ---------- fmtSyncLabel: the live-file label PARTS contract ----------
|
||||
|
||||
|
||||
def test_fmt_sync_label_signature_and_prefixes() -> None:
|
||||
"""fmtSyncLabel(kind, currentFile, done, total): `kind` picks the
|
||||
prefix — "upload" → "Importing" (the background run's word, A3),
|
||||
anything else → "Syncing…". The file is 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 counts
|
||||
are appended only when total > 0 (the import has started); file
|
||||
before counts."""
|
||||
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), NO join space (the space is
|
||||
the CSS gap between the parts — 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
|
||||
@@ -180,31 +201,74 @@ def test_fmt_sync_label_signature_and_prefixes() -> None:
|
||||
i_importing = body.find('"Importing"')
|
||||
i_syncing = body.find('"Syncing…"')
|
||||
assert -1 < i_kind < i_importing < i_syncing
|
||||
# file appended only when present
|
||||
# the MAIN part: the file appended only when present
|
||||
assert "currentFile ?" in body
|
||||
assert "`${prefix} ${currentFile}`" in body
|
||||
# counts only when the import has started (total > 0)
|
||||
# the COUNT part: only when the import has started (total > 0),
|
||||
# no join space (the parts are joined by setSyncLabel, A4)
|
||||
assert "total > 0" in body
|
||||
assert "` (${done}/${total})`" in body
|
||||
assert "`(${done}/${total})`" in body
|
||||
assert "` (${done}/${total})`" not in body, ("no space in the count part")
|
||||
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 byte-identical full strings hold when
|
||||
# setSyncLabel joins them: "Syncing…", "Syncing… <file>",
|
||||
# "Syncing… <file> (n/m)", "Importing")
|
||||
assert "return { main, count }" in body
|
||||
|
||||
|
||||
# ---------- enterSyncRunningState: full path to title + announcer ----------
|
||||
def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||||
"""setSyncLabel(mainText, countText = "") — Phase 128 (task 01):
|
||||
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 count span is hidden
|
||||
whenever there is no "(n/m)" to show). Then the UNTRUNCATED full
|
||||
label — mainText + (countText ? " " + countText : "") — rides the
|
||||
button title + #sync-result (the aria-live announcer) exactly as
|
||||
before (A4 — the screen reader hears the whole thing)."""
|
||||
body = _fn(_js(), "setSyncLabel")
|
||||
assert 'function setSyncLabel(mainText, countText = "")' in body
|
||||
# the parts: textContent only (the house rule), null-guarded refs
|
||||
assert "syncLabelMain.textContent = mainText" in body
|
||||
assert "syncLabelCount.textContent = countText" 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"
|
||||
# A4: the untruncated full label rides the title + the announcer —
|
||||
# main + " " + count (the join space), bare main when no count.
|
||||
assert (
|
||||
'const full = countText ? `${mainText} ${countText}` : mainText'
|
||||
) in body, "the full label is main + ' ' + count (bare main when absent)"
|
||||
i_full = body.find("const full")
|
||||
i_title = body.find("syncBtn.title = full")
|
||||
i_result = body.find("syncResult.textContent = full")
|
||||
assert -1 < i_full < i_title < i_result, ("built once: full → title → announcer")
|
||||
assert "innerHTML" not in body, "the label path is textContent only"
|
||||
|
||||
|
||||
def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
# ---------- 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): "overview" → exactly "Writing KB
|
||||
overview…" (no folder, no counts); "summaries" →
|
||||
"Summarizing folders… <current_summary> (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… (n/m)"); anything else (the null prelude,
|
||||
"import") → the phase-64 fmtSyncLabel fall-through, byte-identical
|
||||
(same builder, same fields, same order). The UPLOAD job never
|
||||
enters here — its status has no phase (D2)."""
|
||||
(phase 98 task 02, D2; phase 128 task 01: the PARTS). "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)."""
|
||||
body = _fn(_js(), "fmtSyncPhaseLabel")
|
||||
assert "function fmtSyncPhaseLabel(status)" in body
|
||||
# "overview": the exact copy, no folder, no counts.
|
||||
@@ -212,7 +276,8 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
i_sum = body.find('status.phase === "summaries"')
|
||||
assert -1 < i_ov < i_sum
|
||||
ov = body[i_ov:i_sum]
|
||||
assert 'return "Writing KB overview…";' in ov
|
||||
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"
|
||||
)
|
||||
@@ -223,10 +288,13 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
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 (
|
||||
"return `Summarizing folders…${folder} "
|
||||
"(${status.summaries_done}/${status.summaries_total})`;"
|
||||
) in sum_, "the exact D2 copy (with and without the folder)"
|
||||
"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")
|
||||
# anything else (null prelude, "import"): byte-identical to today's
|
||||
# sync label — same builder, same fields, same order.
|
||||
assert (
|
||||
@@ -235,18 +303,19 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
) in body[i_fall:]
|
||||
|
||||
|
||||
def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
|
||||
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 form, while the upload job (no status — its
|
||||
fmtSyncPhaseLabel parts, while the upload job (no status — its
|
||||
endpoint has no phase) and the 202/409 click keep the bare
|
||||
fmtSyncLabel label, byte-identical. The FULL untruncated label —
|
||||
whatever its form — rides the button title (set for EVERY running
|
||||
label: the phase-64 "title only when there is a current file"
|
||||
rule is adjusted away) and #sync-result (the aria-live announcer);
|
||||
CSS ellipsizes only the label span."""
|
||||
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)), which lands the main
|
||||
part, the pinned count part (hidden when absent), and the FULL
|
||||
untruncated label on the button title + #sync-result (A4 — set
|
||||
for EVERY running label; CSS ellipsizes the main part only)."""
|
||||
body = _fn(_js(), "enterSyncRunningState")
|
||||
assert (
|
||||
"function enterSyncRunningState(kind, currentFile, done, total, status)"
|
||||
@@ -256,25 +325,48 @@ def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
|
||||
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 builder; the
|
||||
# the phase-aware split: sync + status → the D2 parts builder; the
|
||||
# fall-through (upload / the click, no status) is the byte-
|
||||
# identical phase-64 builder.
|
||||
# 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"
|
||||
# A4: the untruncated label rides the title for EVERY running label
|
||||
# (the remove-when-no-file rule is gone) and #sync-result.
|
||||
assert "syncBtn.title = label" in body, "the full label on hover, always"
|
||||
assert 'syncBtn.removeAttribute("title")' not in body, (
|
||||
"the title is set for every running label (D2: not just file labels)"
|
||||
)
|
||||
i_title = body.find("syncBtn.title = label")
|
||||
i_span = body.find("syncLabel.textContent = label")
|
||||
i_result = body.find("syncResult.textContent = label")
|
||||
assert -1 < i_title < i_span < i_result, (
|
||||
"one label: built once, written to title, span AND the announcer"
|
||||
)
|
||||
# the parts are handed to the single writer (A4 lives in it).
|
||||
i_set = body.find("setSyncLabel(main, count)")
|
||||
assert -1 < i_fall < i_set, "the parts are written after the split"
|
||||
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
|
||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||
assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
|
||||
|
||||
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 + #sync-result (A4, as with every label state). 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 "syncLabel.textContent" not in settle, ("no direct span write — the parts")
|
||||
# the success settle keeps the "Synced HH:MM" copy + the counts line
|
||||
# AFTER the settle (the settle's announcer write is superseded by
|
||||
# the counts — the phase-32 contract, unchanged).
|
||||
success = _fn(js, "applySyncSuccess")
|
||||
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
|
||||
i_settle = success.find("settleSyncButton(")
|
||||
i_counts = success.find("syncResult.textContent = fmtSyncResult(status.detail)")
|
||||
assert -1 < i_settle < i_counts, "the counts line supersedes the settle label"
|
||||
# NO direct #sync-label write remains anywhere in the module.
|
||||
assert "syncLabel.textContent" not in js, ("the parts writer is the single entry point")
|
||||
|
||||
|
||||
# ---------- the two-job tick (startSyncPolling) ----------
|
||||
@@ -522,34 +614,56 @@ def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||||
|
||||
|
||||
def test_sync_label_css_ellipsis_truncation() -> None:
|
||||
""".sync-label: the live-file label ellipsizes a long
|
||||
source/relative/path inside the pill (A4) — inline-block with the
|
||||
min(16rem, 40vw) cap, overflow hidden, text-overflow ellipsis, no
|
||||
wrap, baseline-aligned; on mobile the pill goes full width with
|
||||
the label visible (the cap lifts, and min-width: 0 engages the
|
||||
ellipsis against the full width — the icon-only squeeze is
|
||||
gone)."""
|
||||
""".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-result 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-block",
|
||||
"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",
|
||||
"vertical-align: bottom",
|
||||
):
|
||||
assert prop in body, f".sync-label must carry {prop!r}"
|
||||
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; min-width: 0; }" in mbody, (
|
||||
"the label stays visible and ellipsizes against the full width"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user