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>
358 lines
14 KiB
Python
Executable File
358 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build docs/parity-gallery.html pairing iOS + Android goldens per screen.
|
|
|
|
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.
|
|
|
|
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_<func>.<named>.png` format which we
|
|
strip to align with Android's plain `<name>.png`.
|
|
|
|
Usage: python3 scripts/build_parity_gallery.py
|
|
"""
|
|
from __future__ import annotations
|
|
import glob
|
|
import html
|
|
import os
|
|
import re
|
|
|
|
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".
|
|
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 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
|
|
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]:
|
|
out: dict[str, str] = {}
|
|
full = os.path.join(REPO_ROOT, directory)
|
|
if not os.path.isdir(full):
|
|
return out
|
|
for p in glob.glob(f"{full}/**/*.png", recursive=True):
|
|
key = canonical_name(platform, p)
|
|
if key is None:
|
|
continue
|
|
out[key] = os.path.relpath(p, os.path.join(REPO_ROOT, "docs"))
|
|
return out
|
|
|
|
|
|
def load_manifest() -> list[tuple[str, str, set[str]]]:
|
|
"""Parse `GalleryManifest.kt` → list of (name, category, platforms).
|
|
|
|
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.
|
|
"""
|
|
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 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"),
|
|
]
|
|
|
|
|
|
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'>"
|
|
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 screen_names
|
|
)
|
|
)
|
|
f.write("</div>\n")
|
|
f.write(
|
|
"<div class='grid-header'>"
|
|
"<div class='label'></div>"
|
|
"<div>Android</div>"
|
|
"<div>iOS</div>"
|
|
"</div>\n"
|
|
)
|
|
|
|
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)} "
|
|
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}: "
|
|
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"" if a else (
|
|
"_\\[missing — android\\]_" if "android" in plats else "_(not on android)_"
|
|
)
|
|
i_cell = f"" 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
|
|
|
|
|
|
PAGE_HEAD = """<!DOCTYPE html>
|
|
<html lang='en'><head><meta charset='utf-8'>
|
|
<title>honeyDue parity gallery</title>
|
|
<style>
|
|
:root { color-scheme: dark; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #0d1117; color: #e6edf3; margin: 0; padding: 20px; }
|
|
h1 { margin: 0 0 4px; font-size: 20px; }
|
|
.meta { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
|
|
.nav { position: sticky; top: 0; background: #0d1117; padding: 8px 0; margin-bottom: 16px;
|
|
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: 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; 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; }
|
|
.row img { width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }
|
|
.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>
|
|
"""
|
|
|
|
PAGE_FOOT = """<script>
|
|
// Ctrl/Cmd-F friendly: expose screen names in the document title on anchor change.
|
|
window.addEventListener('hashchange', () => {
|
|
const s = location.hash.slice(1);
|
|
document.title = s ? `${s} · parity` : 'honeyDue parity gallery';
|
|
});
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|