#!/usr/bin/env python3 """ Phase 6 smoke driver — runs the lib/letter.ts → scripts/subst.py → soffice pipeline on a mocked LetterContext with no dev server, no PB, no himalaya. What it verifies: 1. subst.py produces a docx with all placeholders substituted (lib/letter.ts uses a separate `unfilled_placeholders` scan; we re-do it here with a simple regex grep over both docx XML AND the rendered PDF). 2. Signature PNGs are embedded and visible on each attendee's signature line. 3. soffice renders the docx → PDF successfully (file size > 5KB suggests signatures actually wrote to the PDF; < 2KB means it was empty / broken). Output: - out.docx - out.pdf - printout of (a) unfilled placeholders, (b) PDF page count, (c) whether signature PNG bytes appeared in the rendered PDF. Run: /Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow/scripts/.venv/bin/python3 \ /Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow/scripts/smoke_phase6.py """ import json import os import re import shutil import subprocess import sys import tempfile import zipfile from pathlib import Path import docx # python-docx, must already be installed in the venv ROOT = Path("/Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow") PROFILE_ROOT = Path("/Volumes/KIOXIA/.hermes/profiles/ysterbaard") TEMPLATE = PROFILE_ROOT / "assets" / "templates" / "director_change_resolution_master.docx" SUBST = ROOT / "scripts" / "subst.py" VENV_PY = ROOT / "scripts" / ".venv" / "bin" / "python3" SOFFICE = "/opt/homebrew/bin/soffice" # Placeholders the master docx BEGINS with (per ysterbaard-resolutions skill # "Letter template" + "Phase 6 master docx fixes" memo). After substitution # we should see NONE of these still wrapped in [ ] in the output body, with # the notable exception of the preserved legal typos (POSSITIONS, RESIGEND, # ADDITIOAN, SURANEM) which are PART of the legal artefact and are not # placeholders — they get filled in by SDLB-specific logic. Our substitution # script should NOT touch those (per the skill: "Preserve the docx typos # verbatim"). LEGAL_PRESERVED_TYPOS = {"POSSITIONS", "RESIGEND", "ADDITIOAN", "SURANEM"} def make_signature_png(path: Path, label: str) -> Path: """Generate a tiny black-on-white signature PNG using PIL — no scanner needed.""" from PIL import Image, ImageDraw, ImageFont img = Image.new("RGB", (300, 100), "white") draw = ImageDraw.Draw(img) try: font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 36) except Exception: font = ImageFont.load_default() draw.text((20, 30), f"~ signed: {label} ~", fill="black", font=font) img.save(path, "PNG") return path def make_letter_context(workdir: Path, scenario: str = "full") -> dict: """LetterContext shape that lib/letter-runner.ts produces. Scenarios (Phase 6 extension): "full" — Alice (existing), Bob (resigning), Carol (newly appointed). "no_resign" — Alice (existing), Carol (newly appointed). No resignations. "no_appoint" — Alice (existing), Bob (resigning). No appointments. "no_either" — Alice + Diana (both existing). No resignations, no appointments. """ base_attendees = [ # Alice — existing director (in every scenario) { "id": "att-alice", "name": "Alice", "surname": "Anderson", "position": "Director", "email": "alice@example.com", "attended": True, "must_sign": True, "director_role": "EXISTING", "sign_order": 1, "director_id_number": "8001010001081", "effective_date": None, }, ] if scenario in ("full", "no_appoint"): base_attendees.append({ "id": "att-bob", "name": "Bob", "surname": "Brown", "position": "Director", "email": "bob@example.com", "attended": True, "must_sign": True, "director_role": "RESIGNED DIRECTOR", "sign_order": 2, "director_id_number": "7505050002082", "effective_date": "2026-08-22", }) if scenario in ("full", "no_resign"): base_attendees.append({ "id": "att-carol", "name": "Carol", "surname": "Cohen", "position": "Director", "email": "carol@example.com", "attended": True, "must_sign": True, "director_role": "NEW DIRECTOR", "sign_order": 3, "director_id_number": "9009090003083", "effective_date": "2026-08-22", }) if scenario == "no_either": base_attendees.append({ "id": "att-diana", "name": "Diana", "surname": "Davis", "position": "Director", "email": "diana@example.com", "attended": True, "must_sign": True, "director_role": "EXISTING", "sign_order": 2, "director_id_number": "8504040004084", "effective_date": None, }) # Build signer list — every attendee who has signed. The signature # generator only generates PNGs for Bob and Carol; other scenarios # therefore expect pdfimages count == 0 (no signatures in the # rendered PDF — the meeting only has existing directors who have # nothing to sign on this resolution). signers = [] for att in base_attendees: if att["director_role"] == "RESIGNED DIRECTOR": sig_path = str(workdir / "sig_bob.png") signer = { "id": f"signer-{att['id']}", "attendee_id": att["id"], "status": "signed", "signature_path": sig_path, "signed_at": "2026-08-22T11:00:00.000Z", "sent_at": "2026-08-22T09:00:00.000Z", "email_to": att["email"], "attendee": att, } signers.append(signer) elif att["director_role"] == "NEW DIRECTOR": sig_path = str(workdir / "sig_carol.png") signer = { "id": f"signer-{att['id']}", "attendee_id": att["id"], "status": "signed", "signature_path": sig_path, "signed_at": "2026-08-22T11:05:00.000Z", "sent_at": "2026-08-22T09:00:00.000Z", "email_to": att["email"], "attendee": att, } signers.append(signer) # Resolve director labels per letter-runner.ts logic. resigners = [a for a in base_attendees if a["director_role"] == "RESIGNED DIRECTOR"] newly_appointed = [a for a in base_attendees if a["director_role"] == "NEW DIRECTOR"] def _label(people): if not people: return None parts = [] for p in people: base = f"{p['name']} {p['surname']}" id_num = p.get("director_id_number") if id_num: base = f"{base}, ID {id_num}" parts.append(base) return " and ".join(parts) resigning_director_label = _label(resigners) newly_appointed_director_label = _label(newly_appointed) return { "letter_type": "director_change", "resolution_id": "smoke-test-id", "meeting_date": "2026-08-22", "company": { "name": "SMOKE TEST COMPANY", "enterprise_no": "K2099999999", "address": "123 Test Street, Testville, Gauteng, 2000", "address_city": "Testville", }, "attendees": base_attendees, "signers": signers, "resigning_director_label": resigning_director_label, "newly_appointed_director_label": newly_appointed_director_label, } SCENARIOS = ["full", "no_resign", "no_appoint", "no_either"] def find_unfilled_placeholders(docx_path: Path) -> list[str]: """Replicates lib/letter.ts `unfilled_placeholders()` logic.""" out: list[str] = [] with zipfile.ZipFile(docx_path) as z: for name in z.namelist(): if not name.endswith(".xml"): continue data = z.read(name).decode("utf-8", errors="ignore") for m in re.finditer(r"\[([A-Z][A-Z _/,-]{2,40})\]", data): placeholder = m.group(0) inner = m.group(1) # Skip preserved legal typos if inner in LEGAL_PRESERVED_TYPOS: continue if placeholder not in out: out.append(placeholder) return out def main() -> int: if not TEMPLATE.exists(): print(f"FAIL: template not found at {TEMPLATE}") return 1 if not SUBST.exists(): print(f"FAIL: subst.py not found at {SUBST}") return 1 if not VENV_PY.exists(): print(f"FAIL: venv python missing at {VENV_PY}") return 1 if not os.path.exists(SOFFICE): print(f"FAIL: soffice missing at {SOFFICE}") return 1 # Argument parsing — `--scenario X` runs a single case; otherwise all 4. args = sys.argv[1:] scenarios_to_run = SCENARIOS if "--scenario" in args: idx = args.index("--scenario") arg_value = args[idx + 1] if idx + 1 < len(args) else "full" if arg_value not in SCENARIOS: print(f"FAIL: unknown scenario '{arg_value}'. Valid: {SCENARIOS}") return 1 scenarios_to_run = [arg_value] overall_ok = True for scenario in scenarios_to_run: ok = run_scenario(scenario) if not ok: overall_ok = False return 0 if overall_ok else 4 def run_scenario(scenario: str) -> bool: workdir = Path(tempfile.mkdtemp(prefix=f"ybl-smoke-{scenario}-")) print(f"\n========== scenario: {scenario} ==========") print(f"workdir: {workdir}") # Generate signature PNGs only for scenarios that need them. sig_bob = workdir / "sig_bob.png" sig_carol = workdir / "sig_carol.png" if scenario in ("full", "no_appoint"): make_signature_png(sig_bob, "Bob Brown") if scenario in ("full", "no_resign"): make_signature_png(sig_carol, "Carol Cohen") print(f"signature PNGs: " f"bob={sig_bob.stat().st_size if sig_bob.exists() else 0}B " f"carol={sig_carol.stat().st_size if sig_carol.exists() else 0}B") # LetterContext JSON ctx_path = workdir / "context.json" ctx = make_letter_context(workdir, scenario) ctx_path.write_text(json.dumps(ctx, indent=2)) expected_sigs = sum(1 for s in ctx["signers"] if s["status"] == "signed") # Run subst.py out_docx = workdir / "out.docx" res = subprocess.run( [str(VENV_PY), str(SUBST), str(TEMPLATE), str(out_docx), str(ctx_path), str(workdir)], capture_output=True, text=True, timeout=120, ) print(f"\nsubst.py exit: {res.returncode}") if res.stdout.strip(): print("stdout:", res.stdout.strip()[:500]) if res.stderr.strip(): print("stderr:", res.stderr.strip()[:500]) if res.returncode != 0: print("FAIL: subst.py non-zero exit") return False print(f"out.docx size: {out_docx.stat().st_size} B") # Render to PDF out_pdf = workdir / "out.pdf" soff = subprocess.run( [SOFFICE, "--headless", "--convert-to", "pdf", "--outdir", str(workdir), str(out_docx)], capture_output=True, text=True, timeout=120, ) print(f"\nsoffice exit: {soff.returncode}") if soff.returncode != 0: print("FAIL: soffice convert failed") print(soff.stderr[:500]) return False print(f"out.pdf size: {out_pdf.stat().st_size} B") # Verify (1) no unfilled placeholders unfilled = find_unfilled_placeholders(out_docx) print(f"\nunfilled placeholders (after subst): {unfilled if unfilled else 'NONE ✓'}") # Verify (2) signature PNGs landed in the rendered PDF. pdfimages_count = 0 if shutil.which("pdfimages"): img_list = subprocess.run( ["pdfimages", "-list", str(out_pdf)], capture_output=True, text=True, timeout=30, ) for line in img_list.stdout.splitlines(): line = line.strip() if line and line[0].isdigit(): pdfimages_count += 1 print(f"pdfimages count: {pdfimages_count} (need ≥ {expected_sigs})") else: print("WARNING: pdfimages not installed — skipping image check") pdfimages_ok = pdfimages_count >= expected_sigs # Quick sanity: number of pages pdf_bytes = out_pdf.read_bytes() page_count_match = re.search(rb"/Count\s+(\d+)", pdf_bytes) page_count = page_count_match.group(1).decode() if page_count_match else "?" print(f"page count: {page_count}") # Per-scenario content sanity: extract the joined paragraph text from # out.docx and check the expected sub-sections rendered correctly. docx_text = "" with zipfile.ZipFile(out_docx) as z: doc_xml = z.read("word/document.xml").decode("utf-8", errors="ignore") # Strip XML tags to get joined text docx_text = re.sub(r"<[^>]+>", " ", doc_xml) docx_text = re.sub(r"\s+", " ", docx_text) has_none_notice = "(None this meeting.)" in docx_text if scenario in ("no_resign", "no_either"): if not has_none_notice: print(f"FAIL: scenario {scenario!r} expects at least one " f"'(None this meeting.)' notice, found 0") else: print(f"'(None this meeting.)' notice: present ✓") if scenario in ("no_appoint", "no_either"): if not has_none_notice: print(f"FAIL: scenario {scenario!r} expects at least one " f"'(None this meeting.)' notice, found 0") else: print(f"'(None this meeting.)' notice: present ✓") if scenario in ("no_resign", "no_either"): # Should NOT contain Bob Brown as the resigning director # (Bob is only added to attendees when scenario in {full, no_appoint}). if scenario == "no_resign" and "Bob Brown" in docx_text: print(f"FAIL: scenario 'no_resign' should not contain 'Bob Brown' " f"in the minute body") return False ok = ( res.returncode == 0 and soff.returncode == 0 and not unfilled and pdfimages_ok and out_pdf.stat().st_size > 2000 ) if scenario in ("no_resign", "no_appoint", "no_either") and not has_none_notice: ok = False print(f"\nRESULT ({scenario}): {'PASS ✓' if ok else 'FAIL ✗'}") return ok if __name__ == "__main__": sys.exit(main())