365 lines
14 KiB
Python
Executable File
365 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; }
|
||
/* 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())
|