Files
honeyDueKMP/scripts/build_parity_gallery.py

386 lines
15 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
Images are emitted as raw `<img>` 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'<img src="{html.escape(src)}" alt="{html.escape(alt)}" '
f'width="{img_w}" height="{img_h}">'
)
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 = 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 = """<!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; }
/* Force every screenshot — Android and iOS — into the same display box.
Native capture sizes differ (Android 360×800 @1x, iOS 390×844 @2x) so
without a forced aspect-ratio + object-fit the row heights shift by a
few percent per platform, making side-by-side comparisons noisy. */
.row img { width: 100%; aspect-ratio: 9 / 19.5; object-fit: contain;
background: #0d1117; border: 1px solid #30363d; border-radius: 4px;
display: block; }
.missing { display: flex; flex-direction: column; align-items: center; justify-content: center;
aspect-ratio: 9 / 19.5; width: 100%;
background: #21262d; border-radius: 4px;
font-size: 13px; font-weight: 600; padding: 8px; box-sizing: border-box; }
.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())