Parity gallery: unify around canonical manifest, fix populated-state rendering

Single source of truth: `com.tt.honeyDue.testing.GalleryScreens` lists
every user-reachable screen with its category (DataCarrying / DataFree)
and per-platform reachability. Both platforms' test harnesses are
CI-gated against it — `GalleryManifestParityTest` on each side fails
if the surface list drifts from the manifest.

Variant matrix by category: DataCarrying captures 4 PNGs
(empty/populated × light/dark), DataFree captures 2 (light/dark only).
Empty variants for DataCarrying use `FixtureDataManager.empty(seedLookups = false)`
so form screens that only read DM lookups can diff against populated.

Detail-screen rendering fixed on both platforms. Root cause: VM
`stateIn(Eagerly, initialValue = …)` closures evaluated
`_selectedX.value` before screen-side `LaunchedEffect` / `.onAppear`
could set the id, leaving populated captures byte-identical to empty.

  Kotlin: `ContractorViewModel` + `DocumentViewModel` accept
  `initialSelectedX: Int? = null` so the id is set in the primary
  constructor before `stateIn` computes its seed.

  Swift: `ContractorViewModel`, `DocumentViewModelWrapper`,
  `ResidenceViewModel`, `OnboardingTasksViewModel` gained pre-seed
  init params. `ContractorDetailView`, `DocumentDetailView`,
  `ResidenceDetailView`, `OnboardingFirstTaskContent` gained
  test/preview init overloads that accept the pre-seeded VM.
  Corresponding view bodies prefer cached success state over
  loading/error — avoids a spinner flashing over already-visible
  content during background refreshes (production benefit too).

Real production bug fixed along the way: `DataManager.clear()` was
missing `_contractorDetail`, `_documentDetail`, `_contractorsByResidence`,
`_taskCompletions`, `_notificationPreferences`. On logout these maps
leaked across user sessions; in the gallery they leaked the previous
surface's populated state into the next surface's empty capture.

`ImagePicker.android.kt` guards `rememberCameraPicker` with
`LocalInspectionMode` — `FileProvider.getUriForFile` can't resolve the
Robolectric test-cache path, so `add_document` / `edit_document`
previously failed the entire capture.

Honest reclassifications: `complete_task`, `manage_users`, and
`task_suggestions` moved to DataFree. Their first-paint visible state
is driven by static props or APILayer calls, not by anything on
`IDataManager` — populated would be byte-identical to empty without
a significant production rewire. The manifest comments call this out.

Manifest counts after all moves: 43 screens = 12 DataCarrying + 31
DataFree, 37 on both platforms + 3 Android-only (home, documents,
biometric_lock) + 3 iOS-only (documents_warranties, add_task,
profile_edit).

Test results after full record:
  Android: 11/11 DataCarrying diff populated vs empty
  iOS:     12/12 DataCarrying diff populated vs empty

Also in this change:
- `scripts/build_parity_gallery.py` parses the Kotlin manifest
  directly, renders rows in product-flow order, shows explicit
  `[missing — <platform>]` placeholders for expected-but-absent
  captures and muted `not on <platform>` placeholders for
  platform-specific screens. Docs regenerated.
- `scripts/cleanup_orphan_goldens.sh` safely removes PNGs from prior
  test configurations (theme-named, compare artifacts, legacy
  empty/populated pairs for what is now DataFree). Dry-run by default.
- `docs/parity-gallery.md` rewritten: canonical-manifest workflow,
  adding-a-screen guide, variant matrix explained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-20 18:10:32 -05:00
parent 316b1f709d
commit 9fa58352c0
298 changed files with 2496 additions and 1343 deletions

View File

@@ -5,12 +5,24 @@ The output is a single self-contained HTML file that gitea's raw-file view
can render directly. Relative <img> paths resolve within the repo so the
images load without any webserver.
Filename convention (both platforms):
<screen>_<empty|populated>_<light|dark>.png
Variant matrix (driven by the canonical `GalleryScreens` manifest in
`composeApp/src/commonMain/.../testing/GalleryManifest.kt`):
DataCarrying surfaces — 4 PNGs per platform:
<screen>_empty_light.png <screen>_empty_dark.png
<screen>_populated_light.png <screen>_populated_dark.png
DataFree surfaces — 2 PNGs per platform:
<screen>_light.png <screen>_dark.png
Category is detected per-screen from which filename pattern exists on
either platform. Missing captures render as explicit
`[missing — android]` / `[missing — ios]` placeholder boxes so the gap
is visible and actionable, rather than silently omitted.
iOS snapshots live under
iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/
with swift-snapshot-testing's `test_<name>.<nameArg>.png` prefix which we
with swift-snapshot-testing's `test_<func>.<named>.png` format which we
strip to align with Android's plain `<name>.png`.
Usage: python3 scripts/build_parity_gallery.py
@@ -20,27 +32,47 @@ import glob
import html
import os
import re
import sys
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ANDROID_DIR = "composeApp/src/androidUnitTest/roborazzi"
IOS_DIR = "iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests"
OUT_HTML = "docs/parity-gallery.html"
OUT_MD = "docs/parity-gallery-grid.md"
MANIFEST_KT = "composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/GalleryManifest.kt"
# swift-snapshot-testing names files "test_<func>.<name>.png" — strip prefix
# swift-snapshot-testing names files "test_<func>.<name>.png".
IOS_NAME_RE = re.compile(r"^test_[^.]+\.(.+)\.png$")
# Stale golden patterns from prior test configurations — filter out so
# they don't clutter the gallery.
STALE_SUFFIX_RE = re.compile(
r"_(default|midnight|ocean)_(light|dark)$|_(actual|compare)$"
)
# Kotlin manifest row: `GalleryScreen("name", GalleryCategory.X, platformsSet),`
MANIFEST_ROW_RE = re.compile(
r'GalleryScreen\(\s*"(?P<name>\w+)"\s*,\s*'
r'GalleryCategory\.(?P<category>\w+)\s*,\s*'
r'(?P<platforms>\w+)\s*\)',
)
def canonical_name(platform: str, path: str) -> str | None:
"""Return the canonical <screen>_<state>_<mode> key or None if unparseable."""
"""Return the canonical key or None if unparseable / stale."""
base = os.path.basename(path)
if platform == "ios":
m = IOS_NAME_RE.match(base)
if not m:
return None
return m.group(1)
return base[:-4] if base.endswith(".png") else None
key = m.group(1)
else:
if not base.endswith(".png"):
return None
key = base[:-4]
# Drop stale theme-named and compare-artifact files.
if STALE_SUFFIX_RE.search(key):
return None
return key
def load(platform: str, directory: str) -> dict[str, str]:
@@ -56,65 +88,114 @@ def load(platform: str, directory: str) -> dict[str, str]:
return out
def parse_key(key: str) -> tuple[str, str, str]:
"""(screen, state, mode) — e.g. 'login_empty_dark' → ('login', 'empty', 'dark')."""
m = re.match(r"^(.+)_(empty|populated)_(light|dark)$", key)
if m:
return m.group(1), m.group(2), m.group(3)
return key, "?", "?"
def load_manifest() -> list[tuple[str, str, set[str]]]:
"""Parse `GalleryManifest.kt` → list of (name, category, platforms).
def write_markdown(android: dict[str, str], ios: dict[str, str], screens: list[str]) -> None:
"""Emit a gitea-renderable grid as markdown tables.
Gitea serves .html as text/plain (security), but renders .md natively at
its /src/ URL with inline images. This output is the browser-viewable one.
Source of truth for which screens exist, their category, and which
platforms are expected to capture them. The Kotlin manifest is
itself CI-gated against both platforms' tests, so any drift surfaces
there before reaching the gallery builder.
"""
out = os.path.join(REPO_ROOT, OUT_MD)
os.makedirs(os.path.dirname(out), exist_ok=True)
# Markdown image paths are relative to the .md file, which lives in docs/.
# We already compute relative-to-docs paths in load(); those apply here too.
with open(out, "w", encoding="utf-8") as f:
f.write("# honeyDue parity gallery\n\n")
f.write(f"*{len(android)} Android · {len(ios)} iOS · {len(screens)} screens*\n\n")
f.write("Auto-generated by `scripts/build_parity_gallery.py` — do not hand-edit.\n\n")
f.write("See [parity-gallery.md](parity-gallery.md) for the workflow guide.\n\n")
f.write("## Screens\n\n")
for s in screens:
f.write(f"- [{s}](#{s.replace('_', '-')})\n")
f.write("\n---\n\n")
for screen in screens:
anchor = screen.replace("_", "-")
f.write(f"## {screen}<a id='{anchor}'></a>\n\n")
f.write("| State / Mode | Android | iOS |\n")
f.write("|---|---|---|\n")
for state in ("empty", "populated"):
for mode in ("light", "dark"):
key = f"{screen}_{state}_{mode}"
a = android.get(key)
i = ios.get(key)
a_cell = f"![]({a})" if a else "_missing_"
i_cell = f"![]({i})" if i else "_missing_"
f.write(f"| **{state}** / {mode} | {a_cell} | {i_cell} |\n")
f.write("\n[top](#honeydue-parity-gallery)\n\n---\n\n")
print(f"wrote {OUT_MD}")
path = os.path.join(REPO_ROOT, MANIFEST_KT)
if not os.path.isfile(path):
raise SystemExit(
f"Canonical manifest not found at {MANIFEST_KT}. "
"Run this script from the MyCribKMM repo root."
)
with open(path, encoding="utf-8") as f:
text = f.read()
rows: list[tuple[str, str, set[str]]] = []
for m in MANIFEST_ROW_RE.finditer(text):
name = m.group("name")
category = m.group("category")
platforms_ident = m.group("platforms")
# Resolve the platforms-set identifier to platform names. The
# manifest declares `both`, `androidOnly`, `iosOnly` as locals
# inside `GalleryScreens`; we mirror that mapping here.
platforms = {
"both": {"android", "ios"},
"androidOnly": {"android"},
"iosOnly": {"ios"},
}.get(platforms_ident)
if platforms is None:
raise SystemExit(
f"Unknown platforms identifier '{platforms_ident}' in manifest"
)
rows.append((name, category, platforms))
if not rows:
raise SystemExit("No screens parsed from manifest — regex drift?")
return rows
def main() -> int:
android = load("android", ANDROID_DIR)
ios = load("ios", IOS_DIR)
keys = sorted(set(android) | set(ios))
screens = sorted({parse_key(k)[0] for k in keys})
def rows_for(category: str) -> list[tuple[str, str]]:
"""Return the list of (state_label, filename_suffix) for a category."""
if category == "DataCarrying":
return [
("empty / light", "empty_light"),
("empty / dark", "empty_dark"),
("populated / light", "populated_light"),
("populated / dark", "populated_dark"),
]
return [
("light", "light"),
("dark", "dark"),
]
write_markdown(android, ios, screens)
def placeholder(platform: str, screen: str, suffix: str, expected: bool) -> str:
"""HTML for a visible placeholder box.
`expected=True` → screen is in the manifest for this platform but the
PNG is missing (red border, action needed).
`expected=False` → screen is explicitly not-on-this-platform per the
manifest (muted border, no action needed).
"""
if expected:
cls = "missing missing-needed"
label = f"[missing — {platform}]"
else:
cls = "missing missing-platform"
label = f"not on {platform}"
return (
f"<div class='{cls}'>{label}<br>"
f"<span class='hint'>{html.escape(screen)}_{html.escape(suffix)}.png</span></div>"
)
def write_html(
android: dict[str, str],
ios: dict[str, str],
manifest: list[tuple[str, str, set[str]]],
) -> None:
out_path = os.path.join(REPO_ROOT, OUT_HTML)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
# Preserve manifest order so the HTML reads product-flow top-to-bottom.
screen_names = [name for name, _, _ in manifest]
category_by_name = {name: cat for name, cat, _ in manifest}
platforms_by_name = {name: plats for name, _, plats in manifest}
total_pngs_android = len(android)
total_pngs_ios = len(ios)
data_carrying = sum(1 for _, c, _ in manifest if c == "DataCarrying")
data_free = sum(1 for _, c, _ in manifest if c == "DataFree")
with open(out_path, "w", encoding="utf-8") as f:
f.write(PAGE_HEAD)
f.write(f"<div class='meta'>{len(android)} Android · {len(ios)} iOS · {len(screens)} screens</div>\n")
f.write(
f"<div class='meta'>"
f"{len(screen_names)} screens · "
f"{data_carrying} DataCarrying · {data_free} DataFree · "
f"{total_pngs_android} Android PNGs · {total_pngs_ios} iOS PNGs"
f"</div>\n"
)
f.write("<div class='nav'>")
f.write(" ".join(f"<a href='#{html.escape(s)}'>{html.escape(s)}</a>" for s in screens))
f.write(
" ".join(
f"<a href='#{html.escape(s)}'>{html.escape(s)}</a>"
for s in screen_names
)
)
f.write("</div>\n")
f.write(
"<div class='grid-header'>"
@@ -123,34 +204,103 @@ def main() -> int:
"<div>iOS</div>"
"</div>\n"
)
for screen in screens:
for screen in screen_names:
category = category_by_name[screen]
plats = platforms_by_name[screen]
f.write(f"<div class='screen' id='{html.escape(screen)}'>\n")
f.write(f" <h2>{html.escape(screen)}</h2>\n")
for state in ("empty", "populated"):
for mode in ("light", "dark"):
key = f"{screen}_{state}_{mode}"
a = android.get(key)
i = ios.get(key)
a_cell = (
f"<img loading='lazy' src='{html.escape(a)}' alt='{key} Android'/>"
if a
else "<div class='missing'>Android missing</div>"
)
i_cell = (
f"<img loading='lazy' src='{html.escape(i)}' alt='{key} iOS'/>"
if i
else "<div class='missing'>iOS missing</div>"
)
f.write(
f" <div class='row'>"
f"<div class='label'>{state}<br><span class='mode'>{mode}</span></div>"
f"{a_cell}{i_cell}"
f"</div>\n"
)
f.write(
f" <h2>{html.escape(screen)} "
f"<span class='badge badge-{category.lower()}'>{category}</span>"
)
if plats != {"android", "ios"}:
only = "Android" if "android" in plats else "iOS"
f.write(f" <span class='badge badge-only'>{only}-only</span>")
f.write("</h2>\n")
for state_label, suffix in rows_for(category):
key = f"{screen}_{suffix}"
a_rel = android.get(key)
i_rel = ios.get(key)
a_expected = "android" in plats
i_expected = "ios" in plats
a_cell = (
f"<img loading='lazy' src='{html.escape(a_rel)}' alt='{key} Android'/>"
if a_rel
else placeholder("android", screen, suffix, a_expected)
)
i_cell = (
f"<img loading='lazy' src='{html.escape(i_rel)}' alt='{key} iOS'/>"
if i_rel
else placeholder("ios", screen, suffix, i_expected)
)
f.write(
f" <div class='row'>"
f"<div class='label'>{html.escape(state_label)}</div>"
f"{a_cell}{i_cell}"
f"</div>\n"
)
f.write("</div>\n")
f.write(PAGE_FOOT)
print(f"wrote {OUT_HTML}: {len(screens)} screens, {len(android)} Android + {len(ios)} iOS images")
print(
f"wrote {OUT_HTML}: "
f"{len(screen_names)} screens ({data_carrying} DC + {data_free} DF) · "
f"{total_pngs_android} Android · {total_pngs_ios} iOS"
)
def write_markdown(
android: dict[str, str],
ios: dict[str, str],
manifest: list[tuple[str, str, set[str]]],
) -> None:
"""Gitea-renderable grid as markdown tables."""
out = os.path.join(REPO_ROOT, OUT_MD)
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
f.write("# honeyDue parity gallery\n\n")
f.write(
f"*{len(manifest)} screens · {len(android)} Android · {len(ios)} iOS*\n\n"
)
f.write(
"Auto-generated by `scripts/build_parity_gallery.py` — do not hand-edit.\n\n"
)
f.write("See [parity-gallery.md](parity-gallery.md) for the workflow guide.\n\n")
f.write("## Screens\n\n")
for name, category, plats in manifest:
tag = ""
if plats != {"android", "ios"}:
only = "Android" if "android" in plats else "iOS"
tag = f" — *{only}-only*"
f.write(f"- [{name}](#{name.replace('_', '-')}) *({category})*{tag}\n")
f.write("\n---\n\n")
for name, category, plats in manifest:
anchor = name.replace("_", "-")
f.write(f"## {name} *({category})*<a id='{anchor}'></a>\n\n")
f.write("| State / Mode | Android | iOS |\n")
f.write("|---|---|---|\n")
for state_label, suffix in rows_for(category):
key = f"{name}_{suffix}"
a = android.get(key)
i = ios.get(key)
a_cell = f"![]({a})" if a else (
"_\\[missing — android\\]_" if "android" in plats else "_(not on android)_"
)
i_cell = f"![]({i})" if i else (
"_\\[missing — ios\\]_" if "ios" in plats else "_(not on ios)_"
)
f.write(f"| **{state_label}** | {a_cell} | {i_cell} |\n")
f.write("\n[top](#honeydue-parity-gallery)\n\n---\n\n")
print(f"wrote {OUT_MD}")
def main() -> int:
manifest = load_manifest()
android = load("android", ANDROID_DIR)
ios = load("ios", IOS_DIR)
write_html(android, ios, manifest)
write_markdown(android, ios, manifest)
return 0
@@ -167,20 +317,27 @@ PAGE_HEAD = """<!DOCTYPE html>
border-bottom: 1px solid #30363d; font-size: 12px; z-index: 10; }
.nav a { color: #79c0ff; margin-right: 10px; text-decoration: none; white-space: nowrap; }
.nav a:hover { text-decoration: underline; }
.grid-header { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px;
.grid-header { display: grid; grid-template-columns: 140px 1fr 1fr; gap: 12px;
padding: 0 12px 8px; color: #8b949e; font-size: 12px; font-weight: 600;
position: sticky; top: 38px; background: #0d1117; z-index: 9;
border-bottom: 1px solid #30363d; }
.screen { background: #161b22; border-radius: 8px; padding: 12px; margin-bottom: 20px; }
.screen h2 { margin: 0 0 8px; font-size: 16px; color: #e6edf3; }
.row { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px;
.screen h2 { margin: 0 0 8px; font-size: 16px; color: #e6edf3; display: flex; align-items: center; gap: 8px; }
.badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; }
.badge-datacarrying { background: #0d5d56; color: #7ee2d1; }
.badge-datafree { background: #30363d; color: #8b949e; }
.row { display: grid; grid-template-columns: 140px 1fr 1fr; gap: 12px;
margin-bottom: 8px; align-items: start; }
.label { font-size: 12px; color: #c9d1d9; padding-top: 4px; }
.label .mode { color: #8b949e; font-weight: 400; }
.row img { width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }
.missing { display: flex; align-items: center; justify-content: center;
min-height: 200px; background: #21262d; border: 1px dashed #484f58;
border-radius: 4px; color: #8b949e; font-size: 12px; }
.missing { display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 200px; background: #21262d; border-radius: 4px;
font-size: 13px; font-weight: 600; padding: 8px; }
.missing.missing-needed { border: 2px dashed #f85149; color: #f85149; }
.missing.missing-platform { border: 1px solid #30363d; color: #8b949e; }
.missing .hint { color: #6e7681; font-size: 10px; font-weight: 400;
margin-top: 6px; font-family: ui-monospace, monospace; }
.badge-only { background: #484f58; color: #c9d1d9; }
</style></head><body>
<h1>honeyDue parity gallery</h1>
"""
@@ -197,4 +354,4 @@ window.addEventListener('hashchange', () => {
if __name__ == "__main__":
sys.exit(main())
raise SystemExit(main())