#!/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 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: _empty_light.png _empty_dark.png _populated_light.png _populated_dark.png DataFree surfaces — 2 PNGs per platform: _light.png _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_..png` format which we strip to align with Android's plain `.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_..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\w+)"\s*,\s*' r'GalleryCategory\.(?P\w+)\s*,\s*' r'(?P\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"
{label}
" f"{html.escape(screen)}_{html.escape(suffix)}.png
" ) 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"
" f"{len(screen_names)} screens · " f"{data_carrying} DataCarrying · {data_free} DataFree · " f"{total_pngs_android} Android PNGs · {total_pngs_ios} iOS PNGs" f"
\n" ) f.write("\n") f.write( "
" "
" "
Android
" "
iOS
" "
\n" ) for screen in screen_names: category = category_by_name[screen] plats = platforms_by_name[screen] f.write(f"
\n") f.write( f"

{html.escape(screen)} " f"{category}" ) if plats != {"android", "ios"}: only = "Android" if "android" in plats else "iOS" f.write(f" {only}-only") f.write("

\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"{key} Android" if a_rel else placeholder("android", screen, suffix, a_expected) ) i_cell = ( f"{key} iOS" if i_rel else placeholder("ios", screen, suffix, i_expected) ) f.write( f"
" f"
{html.escape(state_label)}
" f"{a_cell}{i_cell}" f"
\n" ) f.write("
\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. Images are emitted as raw `` tags with explicit `width` and `height` attributes rather than markdown `![]()` syntax. Gitea's markdown renderer (goldmark + bluemonday) strips inline `style` attributes, but keeps the `width`/`height` HTML attributes. Forcing both dimensions guarantees identical cell sizes regardless of the underlying PNG resolution (Android is 360×800 @1x, iOS is 780×1688 @2x; without this, row heights would shift a few percent per platform and break side-by-side comparisons). """ # Fixed display size for every image cell. Kept at a ~9:19.5 aspect # ratio (modern phone proportions). Width chosen to fit two tall # portrait screens side-by-side in a typical Gitea markdown pane. img_w, img_h = 260, 560 out = os.path.join(REPO_ROOT, OUT_MD) os.makedirs(os.path.dirname(out), exist_ok=True) def img_tag(src: str, alt: str) -> str: return ( f'{html.escape(alt)}' ) 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})*\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 = img_tag(a, f"{key} Android") if a else ( "_\\[missing — android\\]_" if "android" in plats else "_(not on android)_" ) i_cell = img_tag(i, f"{key} iOS") 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 = """ honeyDue parity gallery

honeyDue parity gallery

""" PAGE_FOOT = """ """ if __name__ == "__main__": raise SystemExit(main())