app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
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()