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:
@@ -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"" if a else "_missing_"
|
||||
i_cell = f"" 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"" 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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user