main
John Lauer Publish 0.2.58 e8180ab
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
"""project-page's Cloud Library and PCM package against a fake adom-wiki (and a fake kicad-cli) on PATH:
HTTP 429 retries, one library per board (recorded lib_ id, no duplicates), an incomplete export refused,
the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66) and listed on
the Altium lane's page, and
every part resolving through shared footprints and models while a missing footprint or model is refused (#76).

  python3 tests/project-page-library.py
"""
import json, os, subprocess, sys, tempfile, textwrap, unittest, zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "tools" / "project-page.py"

FAKE_WIKI = r'''#!/usr/bin/env python3
import json, os, sys, zipfile
st_path = os.environ["FAKE_WIKI_STATE"]
st = json.load(open(st_path))
a = [x for x in sys.argv[1:] if x != "--json"]
st.setdefault("calls", []).append(a)
def save(): json.dump(st, open(st_path, "w"))
def out(v, rc=0):
    save(); print(json.dumps(v)); sys.exit(rc)
if a[:2] == ["library", "list"]:
    libs = [{k: v for k, v in l.items() if k != "items"} for l in st["libraries"]]
    out({"status": "ok", "data": {"status": "ok", "data": {"libraries": libs}}})
if a[:2] == ["library", "create"]:
    ref = "lib_%03d" % (len(st["libraries"]) + 1)
    st["libraries"].append({"ref": ref, "name": a[2], "item_count": 0, "active": True, "created_at": "2026-10-18 10:00:0%d" % len(st["libraries"]), "items": []})
    out({"status": "ok", "data": {"ref": ref, "name": a[2]}})
if a[:2] == ["library", "activate"]:
    out({"status": "ok"})
lib = lambda ref: next(l for l in st["libraries"] if l["ref"] == ref)
if a[:2] == ["library", "add"]:
    if st.get("add429", 0) > 0:
        st["add429"] -= 1
        save()
        print(json.dumps({"status": "error", "error": {"code": "RATE_LIMITED", "http": 429, "message": "Too many requests", "retry_after": 0}}))
        sys.exit(1)
    l = lib(a[2])
    for p in a[3:]:
        if p not in l["items"]: l["items"].append(p)
    l["item_count"] = len(l["items"])
    out({"status": "ok", "data": {"added": a[3:]}})
# issue #125 (0.2.41): a reused library is compared with the BOM, stale items removed by id
if a[:2] == ["library", "show"]:
    l = lib(a[2])
    items = [{"id": i, "owner": p.split("/")[0], "slug": p.split("/")[1], "mpn": st["pages"].get(p)} for i, p in enumerate(l["items"])]
    out({"status": "ok", "data": {"ref": l["ref"], "name": l["name"], "items": items}})
if a[:2] == ["library", "remove"]:
    l = lib(a[2]); i = int(a[3])
    if 0 <= i < len(l["items"]): l["items"].pop(i)
    l["item_count"] = len(l["items"])
    out({"status": "ok"})
if a[:2] == ["library", "export"]:
    l = lib(a[2]); dest = a[a.index("-o") + 1]
    name = l["name"].replace("/", "_")
    parts = [p for p in l["items"] if p not in st.get("exportDrop", [])]
    syms, files = [], {}
    for i, p in enumerate(parts):
        mpn = st["pages"][p]
        # parts may share one footprint ("fpOf") and one model ("modelOf"); "dropFootprints"/"dropModels" leave files out
        fp = st.get("fpOf", {}).get(p, mpn); model = st.get("modelOf", {}).get(p, fp)
        # one KiCad 9 style symbol (hide yes, unquoted pin_numbers) under the export's old header
        syms.append('  (symbol "%s" (pin_numbers (hide yes)) (property "Reference" "U") (property "Footprint" "%s:%s") (property "Value" "%s" (effects (hide yes))))' % (mpn, name, fp, mpn))
        if fp not in st.get("dropFootprints", []):
            # "noModelLine": the page's footprint names no model, though the export still carries the page's STEP
            line = "" if p in st.get("noModelLine", []) else ' (model "3dmodels/%s.step")' % model
            files["%s/%s.pretty/%s.kicad_mod" % (name, name, fp)] = '(footprint "%s" (layer "F.Cu")%s)\n' % (fp, line)
        if model not in st.get("dropModels", []):
            files["%s/3dmodels/%s.step" % (name, model)] = "ISO-10303-21;\nEND-ISO-10303-21;\n"
    files["%s/%s.kicad_sym" % (name, name)] = "(kicad_symbol_lib (version 20211014) (generator adom_wiki_cloud_library)\n" + "\n".join(syms) + "\n)\n"
    with zipfile.ZipFile(dest, "w") as z:
        for k, v in files.items(): z.writestr(k, v)
    out({"status": "ok", "data": {"report": {"status": "ok", "data": {"included": len(parts), "skipped": [], "parts": [{"ref": "pg_%d" % i, "name": st["pages"][p], "mpn": st["pages"][p]} for i, p in enumerate(parts)]}}}})
if a[:2] == ["repo", "show"]:
    # "pageFiles": one page's own files ({page: {path: text}}), written to --out when it is given
    pf = st.get("pageFiles", {}).get(a[2], {})
    if a[3] in pf:
        if "--out" in a:
            dest = a[a.index("--out") + 1]; open(dest, "w").write(pf[a[3]]); out({"status": "ok", "data": {"path": dest}})
        print(pf[a[3]]); save(); sys.exit(0)
    if a[3] in st.get("repo", {}): print(st["repo"][a[3]]); save(); sys.exit(0)
    out({"status": "error", "error": "not found"}, 1)
if a[:2] == ["repo", "push"]:
    files = a[a.index("--files") + 1:a.index("-m")] if "-m" in a[a.index("--files"):] else a[a.index("--files") + 1:]
    missing = [f for f in files if not os.path.isfile(os.path.join(os.getcwd(), f))]
    st.setdefault("pushes", []).append({"page": a[2], "files": files, "missing": missing,
                                        "repository": json.load(open(os.path.join(os.getcwd(), next(f for f in files if f.endswith("repository.json"))))) if any(f.endswith("repository.json") for f in files) else None})
    out({"status": "ok", "commit": "abc1234"}, 1 if missing else 0)
if a[:2] == ["page", "create"]:
    st["created"] = True; out({"status": "ok"})
if a[:1] == ["whoami"]:
    out({"status": "ok", "data": {"user": {"username": "adom"}}})
out({"status": "error", "error": "fake adom-wiki: unknown " + " ".join(a)}, 2)
'''

# KiCad 10 as issue #64 found it: a library whose header predates its symbols' syntax does not load
FAKE_KICAD = r'''#!/usr/bin/env python3
import re, sys
src = sys.argv[-1]; dst = sys.argv[sys.argv.index("-o") + 1]
t = open(src).read()
v = int(re.search(r"\(version\s+(\d+)\)", t).group(1))
if v < 20241209 and "(hide yes)" in t:
    print("Unable to load library " + src); sys.exit(1)
open(dst, "w").write(t); print("Saving symbol library in updated format")
'''

PAGES = {"adom/u1-page": "TPS54202DDCR", "adom/l1-page": "AMPLH5030S-150MT", "adom/c1-page": "CL21A226MPQNNNE"}


class ProjectPageLibrary(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory(); self.d = Path(self.tmp.name)
        b = self.d / "bin"; b.mkdir()
        (b / "adom-wiki").write_text(FAKE_WIKI); (b / "adom-wiki").chmod(0o755)
        (b / "kicad-cli").write_text(FAKE_KICAD); (b / "kicad-cli").chmod(0o755)
        self.state = self.d / "state.json"
        self.write_state({"libraries": [], "pages": PAGES})
        self.run_dir = self.d / "run"; self.run_dir.mkdir()
        self.env = dict(os.environ, PATH=f"{b}:{os.environ['PATH']}", FAKE_WIKI_STATE=str(self.state), ADOM_AIFLOW_KICAD_CLI=str(b / "kicad-cli"),
                        ADOM_AIFLOW_WIKI_BACKOFF="0", ADOM_AIFLOW_WIKI_ATTEMPTS="4")

    def tearDown(self):
        self.tmp.cleanup()

    def write_state(self, v):
        self.state.write_text(json.dumps(v))

    def read_state(self):
        return json.loads(self.state.read_text())

    def parts(self, pages=PAGES):
        return [{"refs": [f"X{i}"], "mpn": mpn, "manufacturer": "", "value": "", "wiki": p} for i, (p, mpn) in enumerate(pages.items())]

    def run_page(self, parts=None, **cfg):
        c = {"run": str(self.run_dir), "page": "adom/test-board", "title": "Test board", "brief": "A test board.", "summary": "",
             "parts": self.parts() if parts is None else parts, "create": True, "push": False, "version": "1.0.0"}
        c.update(cfg)
        p = self.d / "config.json"; p.write_text(json.dumps(c))
        r = subprocess.run([sys.executable, str(SCRIPT), str(p)], capture_output=True, text=True, env=self.env, stdin=subprocess.DEVNULL)
        try:
            return json.loads(r.stdout or r.stderr)
        except Exception:
            raise AssertionError(f"no JSON: {r.stdout!r} {r.stderr!r}")

    def creates(self):
        return [c for c in self.read_state()["calls"] if c[:2] == ["library", "create"]]

    def test_rate_limited_adds_are_retried(self):
        s = self.read_state(); s["add429"] = 2; self.write_state(s)
        v = self.run_page()
        self.assertTrue(v["ok"], v)
        self.assertEqual(len(v["adds"]["rateLimitWaits"]), 2)
        self.assertEqual(v["counts"], {"distinctParts": 3, "symbols": 3, "footprints": 3, "models": 3})
        self.assertEqual(self.read_state()["libraries"][0]["item_count"], 3)

    def test_persistent_rate_limit_is_an_error(self):
        s = self.read_state(); s["add429"] = 100; self.write_state(s)
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("could not be added", v["error"])
        self.assertIn("adom/u1-page", v["error"])
        self.assertTrue(v["cloudLibrary"].startswith("lib_"))

    def test_incomplete_export_is_an_error(self):
        s = self.read_state(); s["exportDrop"] = ["adom/l1-page"]; self.write_state(s)
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("AMPLH5030S-150MT", v["error"])
        self.assertIn("1 of 3 parts are not in the exported library", v["error"])

    def test_shared_footprint_and_model_pass(self):
        # issue #76: two capacitors on one 0805 footprint, and the inductor's footprint using that same model
        s = self.read_state()
        s["fpOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805"}
        s["modelOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805", "adom/l1-page": "C_0805"}
        s["pages"] = dict(PAGES, **{"adom/c2-page": "GRM21BR61E106KA73L"}); self.write_state(s)
        v = self.run_page(parts=self.parts(s["pages"]))
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["counts"], {"distinctParts": 4, "symbols": 4, "footprints": 3, "models": 2})

    def test_missing_footprint_names_the_part(self):
        s = self.read_state()
        s["fpOf"] = {"adom/c1-page": "C_0805"}; s["dropFootprints"] = ["C_0805"]; self.write_state(s)
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("CL21A226MPQNNNE (X2): symbol CL21A226MPQNNNE -> footprint C_0805 is not in the library", v["error"])

    def test_missing_model_names_the_part(self):
        s = self.read_state(); s["dropModels"] = ["TPS54202DDCR"]; self.write_state(s)
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("TPS54202DDCR (X0): footprint TPS54202DDCR -> model TPS54202DDCR.step is not in the library", v["error"])
        # a part that says it has no model (a test point, a fiducial) is not refused for it
        parts = self.parts(); parts[0]["noModelExpected"] = True
        self.assertTrue(self.run_page(parts=parts)["ok"])

    def test_second_run_reuses_the_recorded_library(self):
        self.assertTrue(self.run_page()["ok"])
        rec = json.loads((self.run_dir / "project-page" / "cloud-library.json").read_text())
        v = self.run_page()
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["cloudLibrary"], rec["ref"])
        self.assertEqual(len(self.creates()), 1)
        self.assertEqual(len(self.read_state()["libraries"]), 1)
        # the run's own libRef wins even when the work folder is gone
        (self.run_dir / "project-page" / "cloud-library.json").unlink()
        v = self.run_page(libRef=rec["ref"])
        self.assertEqual(v["cloudLibrary"], rec["ref"]); self.assertEqual(len(self.creates()), 1)

    def test_duplicate_libraries_are_never_added_to(self):
        libs = [{"ref": "lib_a", "name": "adom/test-board", "item_count": 0, "active": False, "created_at": "2026-10-17 01:00:00", "items": []},
                {"ref": "lib_b", "name": "adom/test-board", "item_count": 2, "active": False, "created_at": "2026-10-17 02:00:00", "items": ["adom/u1-page", "adom/l1-page"]}]
        self.write_state({"libraries": libs, "pages": PAGES})
        v = self.run_page()
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["cloudLibrary"], "lib_b")
        self.assertEqual(self.creates(), [])
        self.assertTrue(any("2 libraries are named" in n for n in v["adds"]["notes"]))

    def test_header_is_normalised_and_kicad_loads_it(self):
        v = self.run_page()
        self.assertEqual(v["libraryCheck"]["symVersion"], 20241209)
        self.assertEqual(v["libraryCheck"]["kicadCli"], "pass")
        with zipfile.ZipFile(v["zip"]) as z:
            sym = z.read("symbols/test-board.kicad_sym").decode()
        self.assertTrue(sym.startswith("(kicad_symbol_lib (version 20241209)"))
        self.assertIn('"Footprint" "PCM_test-board:TPS54202DDCR"', sym)

    def test_a_library_kicad_cannot_load_is_not_published(self):
        (self.d / "bin" / "kicad-cli").write_text("#!/bin/sh\necho 'Unable to load library'\nexit 1\n")
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("KiCad cannot load", v["error"])

    def project_lib(self):
        d = self.d / "plib"; (d / "lib.pretty").mkdir(parents=True); (d / "3dmodels").mkdir()
        (d / "lib.kicad_sym").write_text('(kicad_symbol_lib (version 20241209) (generator "kicad_symbol_editor")\n  (symbol "ABC-123" (property "Reference" "U") (property "Footprint" "lib:QFN-16"))\n)\n')
        (d / "lib.pretty" / "QFN-16.kicad_mod").write_text('(footprint "QFN-16" (layer "F.Cu") (model "${KIPRJMOD}/3dmodels/ABC-123.step"))\n')
        (d / "3dmodels" / "ABC-123.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n")
        return {"refs": ["U9"], "mpn": "ABC-123", "manufacturer": "", "value": "", "wiki": None, "source": "project-lib",
                "projectLib": {"dir": str(d), "symbol": "ABC-123", "symbolFile": str(d / "lib.kicad_sym"), "footprint": "QFN-16",
                               "footprintFile": str(d / "lib.pretty" / "QFN-16.kicad_mod"), "model": str(d / "3dmodels" / "ABC-123.step"), "source": "datasheet"}}

    def test_project_library_parts_are_packaged(self):
        v = self.run_page(parts=self.parts() + [self.project_lib()])
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["projectLibParts"], ["ABC-123"])
        self.assertEqual(v["counts"]["distinctParts"], 4)
        with zipfile.ZipFile(v["zip"]) as z:
            names = z.namelist()
            sym = z.read("symbols/test-board.kicad_sym").decode()
            fp = z.read("footprints/test-board.pretty/QFN-16.kicad_mod").decode()
        self.assertIn("3dmodels/test-board.3dshapes/ABC-123.step", names)
        self.assertIn('(property "Footprint" "PCM_test-board:QFN-16")', sym)
        self.assertIn("/test-board.3dshapes/ABC-123.step", fp)
        readme = (self.run_dir / "project-page" / "README.md").read_text()
        self.assertIn("project library (no public page yet)", readme)

    def test_a_project_library_part_on_several_references_is_packaged_once(self):
        a = self.project_lib(); b = dict(a)
        a["refs"], b["refs"] = ["C2"], ["C28", "C29"]
        v = self.run_page(parts=self.parts() + [a, b])
        self.assertTrue(v["ok"], v)
        with zipfile.ZipFile(v["zip"]) as z:
            sym = z.read("symbols/test-board.kicad_sym").decode()
        self.assertEqual(sym.count('(symbol "ABC-123"'), 1)

    def test_only_project_library_parts_need_no_cloud_library(self):
        v = self.run_page(parts=[self.project_lib()])
        self.assertTrue(v["ok"], v)
        self.assertIsNone(v["cloudLibrary"])
        self.assertEqual(self.read_state().get("calls", []), [])

    def test_local_only_makes_no_wiki_call(self):
        # issue #157: --no-push still adds the wiki parts to the Cloud Library and exports it; --local-only
        # (localOnly) makes no adom-wiki call at all, packages the project-library parts, names the wiki parts left out
        v = self.run_page(parts=self.parts() + [self.project_lib()], localOnly=True, push=True)
        self.assertTrue(v["ok"], v)
        self.assertTrue(v["localOnly"])
        self.assertIsNone(v["cloudLibrary"])
        self.assertEqual(self.read_state().get("calls", []), [])
        self.assertEqual(len(v["localOnlyLeftOut"]), 3)
        self.assertIn("TPS54202DDCR (adom/u1-page)", v["localOnlyLeftOut"])
        self.assertEqual(v["counts"]["distinctParts"], 1)
        with zipfile.ZipFile(v["zip"]) as z:
            self.assertIn("footprints/test-board.pretty/QFN-16.kicad_mod", z.namelist())
        # wiki parts only: a README and no package, still no call
        v = self.run_page(localOnly=True)
        self.assertTrue(v["ok"], v)
        self.assertIsNone(v["zip"])
        self.assertEqual(self.read_state().get("calls", []), [])
        # a plain --no-push dry run does use the Cloud Library (what the run now says)
        self.assertTrue(self.run_page()["cloudLibrary"].startswith("lib_"))

    def test_a_preferred_project_library_part_replaces_its_wiki_page(self):
        # issue #157: `parts` gave the row wiki=null, wikiOverridden=<page>: the page is never added or packaged
        pl = self.project_lib(); pl["wikiOverridden"] = "adom/abc-123"
        v = self.run_page(parts=self.parts() + [pl])
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["projectLibParts"], ["ABC-123"])
        lib = self.read_state()["libraries"][0]
        self.assertNotIn("adom/abc-123", lib["items"])

    def test_altium_page_lists_project_library_parts(self):
        # the Altium lane: README revision 1 only (no Cloud Library, no PCM package); the project-library
        # part is in the component table, in projectLibParts and named in the Altium section
        v = self.run_page(parts=self.parts() + [self.project_lib()], eda="altium")
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["eda"], "altium")
        self.assertEqual(v["projectLibParts"], ["ABC-123"])
        self.assertEqual(self.read_state().get("calls", []), [])
        readme = (self.run_dir / "project-page" / "README.md").read_text()
        self.assertIn("| U9 | 1 |  | ABC-123 |  | project library (no public page yet) |", readme)
        self.assertIn("every one has its Adom wiki page or comes from the board's own project library", readme)
        self.assertIn("for ABC-123 (no public page yet), from the board's own project library", readme)
        self.assertNotIn("Install every part in KiCad", readme)
        v = self.run_page(eda="altium")
        readme = (self.run_dir / "project-page" / "README.md").read_text()
        self.assertIn("AI Flow builds this board's Altium libraries from the parts' wiki pages above:", readme)
        self.assertNotIn("project library", readme)

    def test_page_dir_writes_into_a_folder_of_an_existing_page(self):
        # issue #85: README and PCM repository under the folder, URLs carry it, the page is never created
        s = self.read_state(); s["repo"] = {"page.json": "{}"}; self.write_state(s)
        v = self.run_page(push=True, create=False, pageDir="rig/boards/rig-carrier")
        self.assertTrue(v["ok"], v)
        self.assertFalse(v["created"])
        self.assertEqual(v["pageDir"], "rig/boards/rig-carrier")
        self.assertEqual(v["repositoryUrl"], "https://wiki.adom.inc/api/pages/adom/test-board/files/rig/boards/rig-carrier/pcm/repository.json")
        st = self.read_state()
        self.assertNotIn("created", st)
        push = st["pushes"][-1]
        self.assertEqual(push["missing"], [])
        self.assertTrue(all(f.startswith("rig/boards/rig-carrier/") for f in push["files"]), push["files"])
        self.assertIn("rig/boards/rig-carrier/README.md", push["files"], "the folder had no README: it gets one")
        self.assertTrue(push["repository"]["packages"]["url"].endswith("/files/rig/boards/rig-carrier/pcm/packages.json"), push["repository"])
        pkgs = json.loads((self.run_dir / "project-page" / "pcm" / "packages.json").read_text())
        self.assertIn("/files/rig/boards/rig-carrier/pcm/", pkgs["packages"][0]["versions"][0]["download_url"])
        self.assertEqual(st["libraries"][0]["name"], "adom/test-board/rig/boards/rig-carrier")
        self.assertEqual(v["libName"], "test-board-rig-carrier")
        self.assertIn("rig/boards/rig-carrier/pcm/repository.json", (self.run_dir / "project-page" / "README.md").read_text())
        # the folder's README now exists: never replaced unasked
        s = self.read_state(); s["repo"]["rig/boards/rig-carrier/README.md"] = "# mine"; self.write_state(s)
        self.assertTrue(self.run_page(push=True, create=False, pageDir="rig/boards/rig-carrier")["ok"])
        self.assertNotIn("rig/boards/rig-carrier/README.md", self.read_state()["pushes"][-1]["files"])

    def test_a_rebuild_with_new_content_gets_a_new_pcm_version(self):
        # issue #169: KiCad's PCM updates only to a higher version, so changed content bumps it
        v1 = self.run_page()
        self.assertEqual(v1["pcmVersion"], "1.0.0", v1)
        again = self.run_page()
        self.assertEqual(again["pcmVersion"], "1.0.0", "the same content keeps its version")
        pages = dict(PAGES); pages.pop("adom/c1-page")
        v2 = self.run_page(parts=self.parts(pages))
        self.assertEqual(v2["pcmVersion"], "1.0.1", v2)
        self.assertTrue(v2["zip"].endswith("-pcm-1.0.1.zip"))
        pkgs = json.loads((self.run_dir / "project-page" / "pcm" / "packages.json").read_text())
        self.assertEqual(pkgs["packages"][0]["versions"][0]["version"], "1.0.1")
        with zipfile.ZipFile(v2["zip"]) as z:
            self.assertEqual(json.loads(z.read("metadata.json"))["versions"][0]["version"], "1.0.1")
        build = json.loads((self.run_dir / "project-page" / "pcm" / "build.json").read_text())
        self.assertEqual((build["version"], build["identifier"]), ("1.0.1", v2["identifier"]))

    def test_a_page_published_before_versions_followed_content_is_bumped(self):
        # the page serves 1.0.0 (no build.json): a new run's build is 1.0.1, and build.json is pushed
        pkgid = "inc.adom.wiki.adom.test-board"
        s = self.read_state(); s["repo"] = {"page.json": "{}", "pcm/packages.json": json.dumps({"packages": [{"identifier": pkgid, "versions": [{"version": "1.0.0"}]}, {"identifier": "other", "versions": [{"version": "7.0.0"}]}]})}
        self.write_state(s)
        v = self.run_page(push=True, create=False, existing=True)
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["identifier"], pkgid)
        self.assertEqual(v["pcmVersion"], "1.0.1", v)
        self.assertIn("pcm/build.json", self.read_state()["pushes"][-1]["files"])

    def test_page_dir_never_creates_a_page(self):
        v = self.run_page(push=True, create=False, pageDir="rig/boards/a")
        self.assertFalse(v["ok"]); self.assertIn("never creates one", v["error"])
        v = self.run_page(push=True, create=True, pageDir="rig/boards/a")
        self.assertFalse(v["ok"]); self.assertIn("never creates one", v["error"])
        self.assertNotIn("created", self.read_state())
        v = self.run_page(push=False, pageDir="../up")
        self.assertFalse(v["ok"]); self.assertIn("relative to its root", v["error"])

    def test_a_molecule_gets_a_page_of_kind_molecule(self):
        # issue #137: the spec has "molecule": the page is kind molecule (the registry builds its model), never board
        v = self.run_page(push=True, create=True, molecule=True)
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["kind"], "molecule")
        made = [c for c in self.read_state()["calls"] if c[:2] == ["page", "create"]]
        self.assertEqual(len(made), 1)
        self.assertEqual(made[0][made[0].index("--kind") + 1], "molecule")
        self.assertFalse(any(f.endswith(".glb") for p in self.read_state()["pushes"] for f in p["files"]))
        self.assertIn("molecule-publish", (self.run_dir / "project-page" / "README.md").read_text())

    def test_a_plain_board_gets_a_page_of_kind_board(self):
        v = self.run_page(push=True, create=True)
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["kind"], "board")
        made = [c for c in self.read_state()["calls"] if c[:2] == ["page", "create"]][0]
        self.assertEqual(made[made.index("--kind") + 1], "board")
        self.assertNotIn("molecule-publish", (self.run_dir / "project-page" / "README.md").read_text())

    def test_brief_short_description_skips_the_heading(self):
        v = self.run_page(brief="# Test board\n\nA 12 V to 5 V buck.\n")
        meta = json.loads(zipfile.ZipFile(v["zip"]).read("metadata.json"))
        self.assertEqual(meta["description"], "A 12 V to 5 V buck.")


# --- a wiki part whose footprint names no 3D model takes its own page's STEP ---

import hashlib, importlib.util

STEP_C1 = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('CL21A226MPQNNNE, the page EDA model'),'2;1');\nENDSEC;\nEND-ISO-10303-21;\n"
STEP_PLAIN = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('unetched'),'2;1');\nENDSEC;\nEND-ISO-10303-21;\n"
H = lambda s: hashlib.sha256(s.encode()).hexdigest()


def component_page(mpn, step=True, plain=True, eda_sha=None, extra_eda=None):
    """page.json as the Adom basic part pages carry it (adom/cl21a226mpqnnne, 2026-11-04): the part's STEP in
    component.parts.step and model_3d_step, its unetched plain variant beside it, models_3d records on some pages."""
    parts = {"symbol": f"{mpn}.kicad_sym", "footprint": f"{mpn}.kicad_mod", "lbr": f"{mpn}.lbr", "model_3d": f"{mpn.lower()}-hero.glb"}
    if step:
        parts.update({"step": f"{mpn}.step", "model_3d_step": f"{mpn}.step"})
    if plain:
        parts["model_3d_step_plain"] = f"{mpn}-unetched.step"
    c = {"mpn": mpn, "parts": parts}
    if eda_sha is not None:
        c["models_3d"] = [{"file": f"{mpn}-unetched.step", "role": "plain", "sha256": H(STEP_PLAIN)},
                          {"file": f"{mpn}.step", "role": "eda", "sha256": eda_sha, "footprint_binding": "identity"}]
    if extra_eda:
        c.setdefault("models_3d", []).append({"file": extra_eda, "role": "eda"})
    return json.dumps({"type": "component", "slug": mpn.lower(), "component": c})


class PageModelFallback(unittest.TestCase):
    setUp = ProjectPageLibrary.setUp
    tearDown = ProjectPageLibrary.tearDown
    write_state = ProjectPageLibrary.write_state
    read_state = ProjectPageLibrary.read_state
    parts = ProjectPageLibrary.parts
    run_page = ProjectPageLibrary.run_page

    def no_model(self, files, pages=("adom/c1-page",), **extra):
        s = self.read_state(); s["noModelLine"] = list(pages); s["pageFiles"] = files; s.update(extra); self.write_state(s)

    def page_calls(self):
        return [c for c in self.read_state()["calls"] if c[:2] == ["repo", "show"]]

    def test_footprint_with_no_model_takes_its_pages_step(self):
        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", eda_sha=H(STEP_C1)),
                                        "CL21A226MPQNNNE.step": STEP_C1, "CL21A226MPQNNNE-unetched.step": STEP_PLAIN}})
        v = self.run_page()
        self.assertTrue(v["ok"], v)
        b = v["pageModels"]["bound"]
        self.assertEqual([(x["mpn"], x["footprint"], x["model"], x["pageFile"], x["shaRecordedOnPage"]) for x in b],
                         [("CL21A226MPQNNNE", "CL21A226MPQNNNE", "CL21A226MPQNNNE.step", "CL21A226MPQNNNE.step", True)])
        self.assertEqual(b[0]["sha256"], H(STEP_C1))
        self.assertEqual(b[0]["exportCopy"], "replaced", "the export's copy differs from the page's file: the page's own file is packaged")
        self.assertEqual(v["pageModels"]["notBound"], {})
        self.assertEqual(v["counts"], {"distinctParts": 3, "symbols": 3, "footprints": 3, "models": 3})
        with zipfile.ZipFile(v["zip"]) as z:
            fp = z.read("footprints/test-board.pretty/CL21A226MPQNNNE.kicad_mod").decode()
            model = z.read("3dmodels/test-board.3dshapes/CL21A226MPQNNNE.step").decode()
            other = z.read("footprints/test-board.pretty/TPS54202DDCR.kicad_mod").decode()
        self.assertIn('(model "${KICAD10_3RD_PARTY}/3dmodels/inc_adom_wiki_adom_test-board/test-board.3dshapes/CL21A226MPQNNNE.step")', fp)
        self.assertTrue(fp.rstrip().endswith(")") and fp.count("(") == fp.count(")"), fp)
        self.assertEqual(model, STEP_C1)
        self.assertEqual(other.count("(model"), 1, "a footprint that names its model is left as it is")
        # only that part's page was read, and never its plain (unetched) file
        shown = [c[2:4] for c in self.page_calls()]
        self.assertEqual(shown, [["adom/c1-page", "page.json"], ["adom/c1-page", "CL21A226MPQNNNE.step"]])

    def test_a_page_without_a_recorded_hash_still_binds(self):
        # three of the five basic part pages carry no models_3d block: the named STEP is bound, unrecorded
        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE"), "CL21A226MPQNNNE.step": STEP_C1}})
        v = self.run_page()
        self.assertTrue(v["ok"], v)
        self.assertFalse(v["pageModels"]["bound"][0]["shaRecordedOnPage"])

    def test_a_file_that_is_not_the_recorded_one_is_not_bound(self):
        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", eda_sha=H("something else")), "CL21A226MPQNNNE.step": STEP_C1}})
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("CL21A226MPQNNNE (X2): footprint CL21A226MPQNNNE names no 3D model (not bound from its page: its page's CL21A226MPQNNNE.step", v["error"])
        self.assertIn("is not the file its page.json records", v["error"])
        self.assertEqual(v["pageModels"]["bound"], [])

    def test_two_named_steps_are_never_guessed(self):
        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", extra_eda="CL21A226MPQNNNE-v2.step"),
                                        "CL21A226MPQNNNE.step": STEP_C1, "CL21A226MPQNNNE-v2.step": STEP_C1}})
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("its page names 2 STEP models for the footprint (CL21A226MPQNNNE.step, CL21A226MPQNNNE-v2.step): which one is this part's is not certain", v["error"])
        self.assertEqual([c[3] for c in self.page_calls()], ["page.json"], "nothing is fetched once the model is uncertain")

    def test_a_page_naming_no_step_keeps_the_finding(self):
        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", step=False)}})
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("names no 3D model (not bound from its page: its page names no STEP model for the footprint", v["error"])
        # the plain variant alone is not the part's EDA model
        self.assertNotIn("unetched", json.dumps(v["pageModels"]["bound"]))

    def test_a_named_step_the_page_does_not_carry_is_reported(self):
        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE")}})
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("its page names CL21A226MPQNNNE.step, but that file could not be read from the page", v["error"])

    def test_a_file_that_is_not_a_step_is_not_bound(self):
        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE"), "CL21A226MPQNNNE.step": "<html>not found</html>\n"}})
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("its page's CL21A226MPQNNNE.step is not a STEP file", v["error"])

    def test_page_json_that_cannot_be_read_is_reported(self):
        self.no_model({})
        v = self.run_page()
        self.assertFalse(v["ok"])
        self.assertIn("its page.json could not be read", v["error"])

    def test_a_shared_footprint_binds_one_model_only_when_both_pages_agree(self):
        pages = dict(PAGES, **{"adom/c2-page": "GRM21BR61E106KA73L"})
        both = {"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE"), "CL21A226MPQNNNE.step": STEP_C1},
                "adom/c2-page": {"page.json": component_page("GRM21BR61E106KA73L"), "GRM21BR61E106KA73L.step": STEP_C1}}
        self.no_model(both, pages=("adom/c1-page", "adom/c2-page"))
        s = self.read_state(); s["pages"] = pages; s["fpOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805"}
        s["modelOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805"}; self.write_state(s)
        v = self.run_page(parts=self.parts(pages))
        self.assertTrue(v["ok"], v)
        self.assertEqual(sorted(x["mpn"] for x in v["pageModels"]["bound"]), ["CL21A226MPQNNNE", "GRM21BR61E106KA73L"])
        self.assertEqual({x["footprint"] for x in v["pageModels"]["bound"]}, {"C_0805"})
        with zipfile.ZipFile(v["zip"]) as z:
            self.assertEqual(z.read("footprints/test-board.pretty/C_0805.kicad_mod").decode().count("(model"), 1)
        # the same shared footprint with two different page models: not bound, both parts named
        both["adom/c2-page"]["GRM21BR61E106KA73L.step"] = STEP_PLAIN
        s = self.read_state(); s["pageFiles"] = both; self.write_state(s)
        v = self.run_page(parts=self.parts(pages))
        self.assertFalse(v["ok"])
        self.assertIn("footprint C_0805 is shared by parts whose pages name different STEP models", v["error"])
        self.assertEqual(v["pageModels"]["bound"], [])

    def test_footprints_that_name_their_model_never_read_the_pages(self):
        v = self.run_page()
        self.assertTrue(v["ok"], v)
        self.assertEqual(v["pageModels"], {"bound": [], "notBound": {}})
        self.assertEqual(self.page_calls(), [])

    def test_a_part_with_no_model_expected_is_left_alone(self):
        self.no_model({})
        parts = self.parts(); parts[2]["noModelExpected"] = True
        v = self.run_page(parts=parts)
        self.assertTrue(v["ok"], v)
        self.assertEqual(self.page_calls(), [])


def load_script():
    spec = importlib.util.spec_from_file_location("project_page_under_test", SCRIPT)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
    return mod


class PcmVersion(unittest.TestCase):
    """pcm_version: the PCM package version follows its content (issue #169)."""
    pp = load_script()

    def test_versions(self):
        v = self.pp.pcm_version
        self.assertEqual(v([], "a")[0], "1.0.0")
        self.assertEqual(v([{"version": "1.0.0", "contentSha256": "a"}], "a")[0], "1.0.0")
        self.assertEqual(v([{"version": "1.0.0", "contentSha256": "a"}], "b")[0], "1.0.1")
        self.assertEqual(v([{"version": "1.0.9"}], "a")[0], "1.0.10", "no recorded hash counts as changed")
        self.assertEqual(v([{"version": "1.0.2"}, {"version": "1.0.2", "contentSha256": "a"}], "a")[0], "1.0.2")
        self.assertEqual(v([{"version": "1.0.3", "contentSha256": "x"}, {"version": "1.0.1", "contentSha256": "a"}], "a")[0], "1.0.4", "never back to a lower version")
        self.assertEqual(v([{"version": "0.1"}], "a", "1.0.0")[0], "1.0.0")


class PageStepName(unittest.TestCase):
    """page_step_name on the page.json shapes of the five basic part pages (read 2026-11-04)."""
    pp = load_script()

    def test_a_page_with_models_3d_records(self):
        # adom/cr0402-fx-2201glf: an "eda" STEP with its sha256 and a "plain" unetched one
        page = {"component": {"parts": {"step": "CR0402-FX-2201GLF.step", "model_3d_step": "CR0402-FX-2201GLF.step",
                                        "model_3d_step_plain": "CR0402-FX-2201GLF-unetched.step"},
                              "models_3d": [{"file": "CR0402-FX-2201GLF-unetched.step", "role": "plain", "sha256": "C13761D0"},
                                            {"file": "CR0402-FX-2201GLF.step", "role": "eda", "sha256": "B4A4B5E2"}]}}
        self.assertEqual(self.pp.page_step_name(page), ("CR0402-FX-2201GLF.step", "b4a4b5e2", None))

    def test_a_page_without_records(self):
        # adom/0402x105k250ct: parts only
        page = {"component": {"parts": {"step": "0402X105K250CT.step", "model_3d_step": "0402X105K250CT.step",
                                        "model_3d_step_plain": "0402X105K250CT-unetched.step"}}}
        self.assertEqual(self.pp.page_step_name(page), ("0402X105K250CT.step", None, None))

    def test_only_a_plain_model_or_nothing(self):
        f, _, why = self.pp.page_step_name({"component": {"parts": {"model_3d_step_plain": "X-unetched.step"}}})
        self.assertIsNone(f); self.assertIn("names no STEP model", why)
        self.assertIsNone(self.pp.page_step_name({})[0])
        self.assertIsNone(self.pp.page_step_name(None)[0])
        f, _, why = self.pp.page_step_name({"component": {"parts": {"step": "X.glb"}}})
        self.assertIsNone(f)

    def test_add_model_line_keeps_the_footprint_one_expression(self):
        t = '(footprint "R_0402" (layer "F.Cu") (pad "1" smd rect (at -0.5 0) (size 0.6 0.6) (layers "F.Cu")))\n'
        out = self.pp.add_model_line(t, "${KICAD10_3RD_PARTY}/3dmodels/x/y.3dshapes/R.step")
        root = self.pp.sx_parse(out)
        self.assertEqual(root[:2], ["footprint", "R_0402"])
        self.assertEqual([l for l in root if isinstance(l, list) and l[:1] == ["model"]], [["model", "${KICAD10_3RD_PARTY}/3dmodels/x/y.3dshapes/R.step"]])
        multi = '(footprint "C_0402"\n  (layer "F.Cu")\n  (pad "1" smd rect (at -0.5 0) (size 0.6 0.6) (layers "F.Cu"))\n)\n'
        out = self.pp.add_model_line(multi, "m.step")
        self.assertTrue(out.endswith('  (model "m.step")\n)\n'), out)
        self.assertEqual(self.pp.sx_parse(out)[-1], ["model", "m.step"])

if __name__ == "__main__":
    unittest.main()