mirror of
https://github.com/zensical/zensical.git
synced 2026-09-25 07:45:36 +00:00
284 lines
10 KiB
Python
Executable File
Vendored
284 lines
10 KiB
Python
Executable File
Vendored
#!/usr/bin/env python
|
|
|
|
# Copyright (c) 2025-2026 Zensical and contributors
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# All contributions are certified under the DCO
|
|
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to
|
|
# deal in the Software without restriction, including without limitation the
|
|
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
# sell copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
# IN THE SOFTWARE.
|
|
|
|
"""Compare generated Material and Zensical social cards and metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from bs4 import BeautifulSoup
|
|
from PIL import Image, ImageChops, ImageStat
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURES = ROOT / "python" / "tests" / "fixtures" / "social"
|
|
|
|
|
|
def arguments() -> argparse.Namespace:
|
|
"""Parse builder paths and optional cases."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--mkdocs", type=Path, required=True)
|
|
parser.add_argument("--zensical", type=Path, required=True)
|
|
parser.add_argument("--case", action="append", dest="cases")
|
|
parser.add_argument(
|
|
"--output", type=Path, help="keep builds in this empty directory"
|
|
)
|
|
parser.add_argument(
|
|
"--font-cache", type=Path, help="existing social font cache"
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def build(executable: Path, project: Path, *, strict: bool) -> None:
|
|
"""Build one copied fixture in its own project directory."""
|
|
command = [
|
|
str(executable),
|
|
"build",
|
|
"--clean",
|
|
"--config-file",
|
|
"mkdocs.yml",
|
|
]
|
|
if strict:
|
|
command.append("--strict")
|
|
result = subprocess.run(
|
|
command, cwd=project, capture_output=True, text=True, check=False
|
|
)
|
|
(project / "build.log").write_text(
|
|
f"$ {' '.join(command)}\n{result.stdout}{result.stderr}",
|
|
encoding="utf-8",
|
|
)
|
|
if result.returncode:
|
|
details = (result.stderr or result.stdout).strip().splitlines()
|
|
summary = details[-1] if details else "no diagnostics"
|
|
raise RuntimeError(
|
|
f"{executable.name} build failed in {project} "
|
|
f"({result.returncode}): {summary}"
|
|
)
|
|
|
|
|
|
def manifest(site: Path) -> dict:
|
|
"""Extract only social metadata and generated image facts."""
|
|
pages = {}
|
|
for path in sorted(site.rglob("*.html")):
|
|
if path.name == "404.html":
|
|
continue
|
|
soup = BeautifulSoup(path.read_text(encoding="utf-8"), "lxml")
|
|
if soup.head is None:
|
|
raise ValueError(f"generated page lacks a head element: {path}")
|
|
tags = [
|
|
[property_name, tag.get("content")]
|
|
for tag in soup.head.find_all("meta", property=True)
|
|
if isinstance(property_name := tag.get("property"), str)
|
|
and property_name.startswith(("og:", "twitter:", "x:"))
|
|
]
|
|
pages[path.relative_to(site).as_posix()] = tags
|
|
|
|
cards = {}
|
|
for path in sorted(site.rglob("*.png")):
|
|
relative = path.relative_to(site).as_posix()
|
|
if not relative.startswith(("assets/images/social/", "assets/cards/")):
|
|
continue
|
|
with Image.open(path) as image:
|
|
cards[relative] = list(image.size)
|
|
return {"pages": pages, "cards": cards}
|
|
|
|
|
|
def image_difference(first: Path, second: Path) -> float:
|
|
"""Measure mean RGB channel difference independent of PNG encoding."""
|
|
with Image.open(first) as left, Image.open(second) as right:
|
|
if left.size != right.size:
|
|
return float("inf")
|
|
difference = ImageChops.difference(
|
|
left.convert("RGB"), right.convert("RGB")
|
|
)
|
|
return sum(ImageStat.Stat(difference).mean) / 3
|
|
|
|
|
|
def compare(name: str, material: Path, zensical: Path) -> bool:
|
|
"""Show semantic and visual differences for one fixture."""
|
|
expected = manifest(material / "site")
|
|
actual = manifest(zensical / "site")
|
|
(material / "manifest.json").write_text(
|
|
json.dumps(expected, indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
(zensical / "manifest.json").write_text(
|
|
json.dumps(actual, indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
cards = set(expected["cards"]) | set(actual["cards"])
|
|
differences = {
|
|
path: round(
|
|
image_difference(
|
|
material / "site" / path, zensical / "site" / path
|
|
),
|
|
3,
|
|
)
|
|
for path in sorted(set(expected["cards"]) & set(actual["cards"]))
|
|
}
|
|
matched = expected == actual and all(
|
|
value <= 1 for value in differences.values()
|
|
)
|
|
print(
|
|
f"{name}: {'MATCH' if matched else 'DIFF'}; "
|
|
f"{len(expected['pages'])} pages, {len(cards)} card paths; "
|
|
f"mean RGB error {max(differences.values(), default=0):.3f}"
|
|
)
|
|
if expected != actual:
|
|
for key in ("pages", "cards"):
|
|
for path in sorted(set(expected[key]) | set(actual[key])):
|
|
if expected[key].get(path) != actual[key].get(path):
|
|
print(f" {key}/{path}")
|
|
print(f" Material: {expected[key].get(path)}")
|
|
print(f" Zensical: {actual[key].get(path)}")
|
|
for path, error in differences.items():
|
|
if error > 1:
|
|
print(f" pixels/{path}: mean RGB error {error:.3f}")
|
|
return matched
|
|
|
|
|
|
def snapshot(project: Path) -> dict:
|
|
"""Capture visible metadata and card contents between cached builds."""
|
|
site = project / "site"
|
|
current = manifest(site)
|
|
return {
|
|
"pages": current["pages"],
|
|
"cards": {
|
|
path: hashlib.sha256((site / path).read_bytes()).hexdigest()
|
|
for path in current["cards"]
|
|
},
|
|
}
|
|
|
|
|
|
def apply_step(project: Path, step: dict) -> None:
|
|
"""Apply one exact source replacement in a copied fixture."""
|
|
path = project / step["path"]
|
|
source = path.read_text(encoding="utf-8")
|
|
before = step["before"]
|
|
if source.count(before) != 1:
|
|
raise ValueError(f"expected one occurrence of {before!r} in {path}")
|
|
path.write_text(source.replace(before, step["after"]), encoding="utf-8")
|
|
|
|
|
|
def run(root: Path, args: argparse.Namespace) -> bool:
|
|
"""Build selected fixtures in both engines and compare their outputs."""
|
|
names = args.cases or sorted(
|
|
path.name
|
|
for path in FIXTURES.iterdir()
|
|
if (path / "mkdocs.yml").is_file()
|
|
)
|
|
unknown = [
|
|
name for name in names if not (FIXTURES / name / "mkdocs.yml").is_file()
|
|
]
|
|
if unknown:
|
|
raise ValueError(f"unknown social cases: {', '.join(unknown)}")
|
|
matched = True
|
|
for name in names:
|
|
project = FIXTURES / name
|
|
material = root / name / "material"
|
|
zensical = root / name / "zensical"
|
|
shutil.copytree(project, material)
|
|
shutil.copytree(project, zensical)
|
|
if args.font_cache and name in {
|
|
"bundled",
|
|
"custom-typography",
|
|
"debug",
|
|
"debug-no-grid",
|
|
"layout-options",
|
|
"logo-icon",
|
|
"theme-defaults",
|
|
}:
|
|
for destination in (material, zensical):
|
|
shutil.copytree(
|
|
args.font_cache,
|
|
destination / "social-cache/fonts",
|
|
)
|
|
strict = name not in {
|
|
"debug",
|
|
"debug-no-grid",
|
|
"deprecated",
|
|
"no-site-url",
|
|
}
|
|
try:
|
|
build(args.mkdocs, material, strict=strict)
|
|
build(args.zensical, zensical, strict=strict)
|
|
matched &= compare(name, material, zensical)
|
|
steps_file = project / "steps.json"
|
|
if not steps_file.is_file():
|
|
continue
|
|
for index, step in enumerate(json.loads(steps_file.read_text())):
|
|
previous = {
|
|
"material": snapshot(material),
|
|
"zensical": snapshot(zensical),
|
|
}
|
|
for destination in (material, zensical):
|
|
apply_step(destination, step)
|
|
build(args.mkdocs, material, strict=strict)
|
|
build(args.zensical, zensical, strict=strict)
|
|
label = f"{name}/step-{index + 1}"
|
|
matched &= compare(label, material, zensical)
|
|
for engine, destination in (
|
|
("material", material),
|
|
("zensical", zensical),
|
|
):
|
|
current = snapshot(destination)
|
|
for kind in step["changes"]:
|
|
if previous[engine][kind] == current[kind]:
|
|
print(f"{label}: {engine} {kind} did not change")
|
|
matched = False
|
|
except RuntimeError as error:
|
|
print(f"{name}: ERROR; {error}")
|
|
matched = False
|
|
continue
|
|
return matched
|
|
|
|
|
|
def main() -> int:
|
|
"""Run the selected compatibility matrix."""
|
|
args = arguments()
|
|
args.mkdocs = args.mkdocs.resolve()
|
|
args.zensical = args.zensical.resolve()
|
|
if args.font_cache:
|
|
args.font_cache = args.font_cache.resolve()
|
|
if args.output:
|
|
root = args.output.resolve()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
if any(root.iterdir()):
|
|
raise ValueError(f"output directory must be empty: {root}")
|
|
print(f"Builds: {root}")
|
|
return 0 if run(root, args) else 1
|
|
with tempfile.TemporaryDirectory(prefix="zensical-social-parity-") as raw:
|
|
return 0 if run(Path(raw), args) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|