From 76568672d7bf3fed3cd8c297f8bd0752bc33ce3d Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Wed, 15 Apr 2026 19:45:13 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20core=20migration=20=E2=80=94=20Adobe=20?= =?UTF-8?q?Sign=20to=20DocuSign=20field=20mapping=20and=20composition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose_docusign_template.py — converts a downloaded template folder into a DocuSign envelopeTemplate JSON ready for the Templates API. Key behaviours: - Full field type mapping: TEXT_FIELD, SIGNATURE, CHECKBOX, RADIO, DROP_DOWN, BLOCK, FILE_CHOOSER (with warning), INLINE_IMAGE (skipped with warning) - contentType dispatch: SIGNER_NAME → fullNameTabs, SIGNER_EMAIL → emailAddressTabs, SIGNATURE_DATE → dateSignedTabs, COMPANY/SIGNER_COMPANY → companyTabs, TITLE/SIGNER_TITLE → titleTabs, DATA+NUMBER → numberTabs, DATA+DATE → dateTabs, SIGNER_INITIALS → initialHereTabs - Multi-location (cloned) fields: emits one tab per location with the same tabLabel so DocuSign tab merging replicates Adobe Sign's sync behaviour - Width/height passed through from Adobe Sign locations; MIN_TEXT_WIDTH=120pt ensures text fields render as visible boxes rather than vertical lines - Coordinate system: both platforms use top-left origin — no inversion needed test_mapping.py — unit test harness validating tab grouping and field mapping. field-mapping.md — full Adobe Sign → DocuSign tab type reference table with edge cases, known gaps, and decision log. Co-Authored-By: Claude Sonnet 4.6 --- field-mapping.md | 54 +++-- src/compose_docusign_template.py | 353 +++++++++++++++++++++++++++++++ src/test_mapping.py | 104 +++++++++ 3 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 src/compose_docusign_template.py create mode 100644 src/test_mapping.py diff --git a/field-mapping.md b/field-mapping.md index dbce607..693311e 100644 --- a/field-mapping.md +++ b/field-mapping.md @@ -1,18 +1,30 @@ # Field Mapping: Adobe Sign → DocuSign -This doc will be used to track direct mappings and required transforms between Adobe Sign library document (template) properties and DocuSign template properties. +This doc tracks direct mappings and required transforms between Adobe Sign library document (template) properties and DocuSign template properties. -## Simple Field Type Mapping -| Adobe Sign Type | DocuSign Tab Type | Notes | -|---------------------|-------------------------|------------------------| -| TEXT_FIELD | text | Valid for plain text | -| SIGNATURE | signHere | | -| CHECKBOX | checkbox | | -| DATE | dateSigned | May need transform | -| RADIO | radio | Group mapping required | -| DROPDOWN | list | Data mapping | -| APPROVER | signer/approver role | DocuSign roles more flexible | -| ... | ... | ... | +## Field Type Mapping (inputType + contentType + validation → DocuSign tab) + +Adobe Sign requires `inputType`, `contentType`, and sometimes `validation` to determine the correct DocuSign tab. +Source: Adobe Sign UI "Change field type" dropdown (all 15 types) + API field data. + +| Adobe UI Label | inputType | contentType | validation | DocuSign Tab | Notes | +|---------------------|-------------------|------------------|------------|----------------------|--------------------------------------------| +| Signature | SIGNATURE | SIGNATURE | — | signHereTabs | | +| Initials | SIGNATURE | SIGNER_INITIALS | — | initialHereTabs | NOT a full signature | +| Recipient name | TEXT_FIELD | SIGNER_NAME | — | fullNameTabs | Auto-populated from signer profile | +| Recipient email | TEXT_FIELD | SIGNER_EMAIL | — | emailAddressTabs | Auto-populated from signer profile | +| Date of signing | TEXT_FIELD | SIGNATURE_DATE | — | dateSignedTabs | Auto-populated on signing | +| Text | TEXT_FIELD | DATA | STRING | textTabs | | +| Date | TEXT_FIELD | DATA | DATE | dateTabs | User-entered date (not auto-signed date) | +| Number | TEXT_FIELD | DATA | NUMBER | numberTabs | | +| Drop-down menu | DROP_DOWN | DATA | — | listTabs | Options from hiddenOptions array | +| Attachments | FILE_CHOOSER | DATA | — | signerAttachmentTabs | Manual review recommended | +| Participation stamp | PARTICIPATION_STAMP | — | — | (skipped) | No DocuSign equivalent | +| Image | INLINE_IMAGE | DATA | — | (skipped) | No DocuSign equivalent | +| Company | TEXT_FIELD | COMPANY or SIGNER_COMPANY | — | companyTabs | Auto-populated from signer profile. API returns `SIGNER_COMPANY` when set via UI. | +| Title | TEXT_FIELD | TITLE or SIGNER_TITLE | — | titleTabs | Auto-populated from signer profile. API returns `SIGNER_TITLE` when set via UI. | +| Stamp | STAMP | — | — | (skipped) | No DocuSign equivalent | +| Signature block | BLOCK | SIGNATURE_BLOCK | — | signHereTabs | Composite block — mapped to sign-here | ## Role/Recipient Mapping | Adobe Field | DocuSign Field | Notes | @@ -20,12 +32,30 @@ This doc will be used to track direct mappings and required transforms between A | recipientSetRole | role (signer, etc.) | Matching by role name | | recipientSetMemberInfos.email | role.email | | +## Known Edge Cases & Decision Log +- [2026-04-14] DocuSign checkboxes must be uniquely tab-labeled and mapped to a recipient; Adobe Sign sometimes groups these differently. +- [2026-04-14] Date fields on Adobe may include validation Adobe-only, which needs stripping or custom mapping for DocuSign’s `dateSigned`. +- [2026-04-14] Conditional logic for showing/hiding fields in Adobe is not always supported in DocuSign (needs review for each case). +- [2026-04-15] `numberTabs` API bug: DocuSign API accepts `numberTabs` in the template JSON, but the created template displays as a Text field with "Numbers" validation in the editor. Functionally equivalent at signing time; visual/semantic discrepancy only. No API workaround known. +- [2026-04-15] Multi-location fields: Adobe Sign fields can have multiple `locations` (cloned/synced instances). DocuSign equivalent is tab merging — multiple tabs with the same `tabLabel` sync their value. Our compose script now emits one tab per location for all data-entry types. See `PLATFORM-QUIRKS.md` for full details. +- [2026-04-15] Tab width required: DocuSign text-entry tabs render as a vertical line if `width` is omitted. Always pass `width` (and `height`) from the Adobe Sign location. Minimum 120pt enforced. + ## Workflow Feature Mapping (Rough) - Sequential routing → Recipient order - Parallel routing → Recipient routing order logic (sequential/parallel in DocuSign) - Conditional logic → Needs review, possible via DocuSign conditional tabs/logic +## Transform Formulas & Known Mapping Gaps + +- **Coordinate translation:** If Adobe origin differs from DocuSign, map as: + `docusign_left = adobe_left // or apply offset, scale, etc.` +- **Radio group flattening:** Merge Adobe radios with `radioGroup` into DocuSign `radio` tab, setting all options explicitly. +- **Missing/ambiguous features:** + - DocuSign formulas (no mapping in Adobe Sign) — flag for manual rewrite + - Adobe advanced field validations (regex, custom scripts) — usually skipped or mapped to best-effort validation in DocuSign + ## To Do - Add table for conditional logic/rule mapping - Add validation/transforms needed for field masks, validation, default values +- Document more edge cases as they are discovered in real samples - Collect pain points/edge cases for high-fidelity migration diff --git a/src/compose_docusign_template.py b/src/compose_docusign_template.py new file mode 100644 index 0000000..0f66d95 --- /dev/null +++ b/src/compose_docusign_template.py @@ -0,0 +1,353 @@ +""" +compose_docusign_template.py +---------------------------- +Converts a downloaded Adobe Sign template folder into a DocuSign +envelopeTemplate JSON that can be posted directly to the DocuSign +Templates API via: + + node packages/esign-direct/build/cli.js templates create --file + +Key rules applied: + - Tabs grouped by type: textTabs, signHereTabs, dateSignedTabs, + listTabs, checkboxTabs, radioGroupTabs, signerAttachmentTabs + - required / locked must be strings "true" / "false", not booleans + - listTabs need listItems: [{text, value}] not a plain string array + - radioGroupTabs use groupName + radios[] with per-option page/x/y + - Coordinates: Adobe top is from TOP of page; DocuSign yPosition is + from BOTTOM. For US Letter (792pt): y = PAGE_HEIGHT - top - height + - Signers in templates are role-placeholders (no email/name) + - No top-level "status" field (belongs on envelope sends, not templates) + +Field type coverage: + Mapped: TEXT_FIELD, SIGNATURE, CHECKBOX, DATE, DROP_DOWN, RADIO, BLOCK + Partial: FILE_CHOOSER → signerAttachmentTabs (with warning) + Skipped: INLINE_IMAGE (no DocuSign equivalent — warning logged) +""" + +import base64 +import json +import os +from pathlib import Path + +DOCUMENT_ID = "1" + + +# --------------------------------------------------------------------------- +# Coordinate translation +# --------------------------------------------------------------------------- + +# Minimum width for text-entry tabs so they render as a visible box rather +# than a vertical line. ~120pt ≈ 15 average characters at 8pt/char. +MIN_TEXT_WIDTH = 120 + + +def loc_to_docusign(loc: dict) -> tuple[str, str, str, str, str]: + """ + Convert an Adobe Sign location dict to DocuSign (page, x, y, width, height) strings. + Both Adobe Sign and DocuSign measure from the top-left corner of the page, + with y increasing downward — no coordinate inversion needed. + Width is clamped to MIN_TEXT_WIDTH so text fields render as visible boxes. + """ + page = str(loc["pageNumber"]) + x = str(int(loc["left"])) + y = str(int(loc["top"])) + width = str(max(int(loc.get("width", MIN_TEXT_WIDTH)), MIN_TEXT_WIDTH)) + height = str(int(loc.get("height", 24))) + return page, x, y, width, height + + +# --------------------------------------------------------------------------- +# Recipients: derive from form field assignees +# --------------------------------------------------------------------------- + +def derive_recipients(fields: list) -> list[dict]: + """ + Build an ordered list of recipient role placeholders from the assignee + values on each field (e.g. "recipient0", "recipient1"). + Returns [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}, ...] + """ + seen: dict[str, int] = {} + for f in fields: + assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}" + if assignee not in seen: + try: + idx = int(assignee.replace("recipient", "")) + except ValueError: + idx = len(seen) + seen[assignee] = idx + + recipients = sorted( + [{"assignee": k, "index": v, "roleName": f"Signer {v + 1}"} for k, v in seen.items()], + key=lambda r: r["index"], + ) + return recipients if recipients else [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}] + + +def assignee_to_index(assignee: str | None, recipients: list[dict]) -> int: + if not assignee: + return 0 + for r in recipients: + if r["assignee"] == assignee: + return r["index"] + return 0 + + +# --------------------------------------------------------------------------- +# Tab builder helpers +# --------------------------------------------------------------------------- + +def _make_sized_tab(loc: dict, label: str, extra: dict | None = None) -> dict: + """Build one sized DocuSign tab from a single Adobe Sign location.""" + page, x, y, width, height = loc_to_docusign(loc) + tab = { + "tabLabel": label, + "documentId": DOCUMENT_ID, + "pageNumber": page, + "xPosition": x, + "yPosition": y, + "width": width, + "height": height, + } + if extra: + tab.update(extra) + return tab + + +def _make_base_tab(loc: dict, label: str, extra: dict | None = None) -> dict: + """Build one unsized DocuSign tab (for signature/checkbox fields).""" + page, x, y, _w, _h = loc_to_docusign(loc) + tab = { + "tabLabel": label, + "documentId": DOCUMENT_ID, + "pageNumber": page, + "xPosition": x, + "yPosition": y, + } + if extra: + tab.update(extra) + return tab + + +def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list: + """ + Emit one sized tab per location with the same tabLabel. + + Adobe Sign allows a single field to have multiple locations (cloned/linked + instances). DocuSign replicates this via tab merging: tabs that share a + tabLabel auto-sync their value at signing time. Applies to all data-entry + tab types: textTabs, numberTabs, dateTabs, dateSignedTabs, fullNameTabs, + emailAddressTabs, companyTabs, titleTabs, listTabs, checkboxTabs. + """ + return [_make_sized_tab(loc, label, extra) for loc in locations] + + +# --------------------------------------------------------------------------- +# Tab builder +# --------------------------------------------------------------------------- + +def build_tabs_for_field(field: dict, warnings: list) -> dict: + """ + Convert one Adobe Sign field into the correct DocuSign tabs structure. + Returns a dict of tab-group keys, e.g. {"textTabs": [...]}. + Unmappable fields are skipped and a warning is appended. + """ + input_type = field.get("inputType", "") + label = field.get("name", "unnamed") + locations = field.get("locations", []) + required_str = "true" if field.get("required", False) else "false" + locked_str = "true" if field.get("readOnly", False) else "false" + + if not locations: + return {} + + content_type = field.get("contentType", "") + validation = field.get("validation", "") + + if input_type == "TEXT_FIELD": + if content_type == "SIGNATURE_DATE": + # Auto-populated with the signing date + return {"dateSignedTabs": _sized_tabs(locations, label)} + elif content_type == "SIGNER_NAME": + # Auto-populated with the signer's full name + return {"fullNameTabs": _sized_tabs(locations, label)} + elif content_type == "SIGNER_EMAIL": + # Auto-populated with the signer's email address + return {"emailAddressTabs": _sized_tabs(locations, label)} + elif content_type in ("COMPANY", "SIGNER_COMPANY"): + # Auto-populated with the signer's company + return {"companyTabs": _sized_tabs(locations, label)} + elif content_type in ("TITLE", "SIGNER_TITLE"): + # Auto-populated with the signer's title + return {"titleTabs": _sized_tabs(locations, label)} + elif content_type == "DATA" and validation == "DATE": + # User-entered date field (not auto-signed date) + return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} + elif content_type == "DATA" and validation == "NUMBER": + return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} + else: + return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} + + elif input_type == "SIGNATURE": + # Each signature/initials location is an independent signing action — + # emit one tab per location but do not size them (DocuSign controls size) + if content_type == "SIGNER_INITIALS": + return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]} + else: + return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]} + + elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK": + # Composite signature block — map to signHere at block's location + return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]} + + elif input_type == "DATE": + return {"dateSignedTabs": _sized_tabs(locations, label)} + + elif input_type == "CHECKBOX": + return {"checkboxTabs": _sized_tabs(locations, label, {"required": required_str})} + + elif input_type == "DROP_DOWN": + options = field.get("hiddenOptions") or field.get("visibleOptions") or [] + list_items = [{"text": str(v), "value": str(v)} for v in options if v] + return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})} + + elif input_type == "RADIO": + # Each location is one radio button within the group — not tab merging + options = field.get("hiddenOptions") or [] + radios = [] + for i, loc in enumerate(locations): + pg, rx, ry, _rw, _rh = loc_to_docusign(loc) + value = options[i] if i < len(options) else str(i + 1) + radios.append({"pageNumber": pg, "xPosition": rx, "yPosition": ry, "value": value}) + return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]} + + elif input_type == "FILE_CHOOSER": + warnings.append(f"FILE_CHOOSER '{label}' → mapped to signerAttachmentTabs (manual review recommended)") + tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"}) + return {"signerAttachmentTabs": [tab]} + + elif input_type == "INLINE_IMAGE": + warnings.append(f"INLINE_IMAGE '{label}' → skipped (no DocuSign equivalent)") + return {} + + elif input_type in ("STAMP", "PARTICIPATION_STAMP"): + warnings.append(f"{input_type} '{label}' → skipped (no DocuSign equivalent)") + return {} + + else: + warnings.append(f"Unknown field type '{input_type}' (contentType='{content_type}') for field '{label}' → skipped") + return {} + + +def merge_tabs(acc: dict, new: dict) -> dict: + for key, tabs in new.items(): + acc.setdefault(key, []).extend(tabs) + return acc + + +# --------------------------------------------------------------------------- +# Main compose function +# --------------------------------------------------------------------------- + +def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str]]: + """ + Build a DocuSign template JSON from a downloaded Adobe Sign template folder. + + Args: + template_dir: path to a downloads// folder containing + metadata.json, form_fields.json, documents.json, and a PDF + output_path: where to write the resulting DocuSign template JSON + + Returns: + (template_dict, warnings_list) + """ + template_dir = Path(template_dir) + warnings: list[str] = [] + + # Load source files + metadata = json.loads((template_dir / "metadata.json").read_text()) + fields_data = json.loads((template_dir / "form_fields.json").read_text()) + documents_data = json.loads((template_dir / "documents.json").read_text()) + fields: list[dict] = fields_data.get("fields", []) + + # Find the PDF file + pdf_files = [f for f in template_dir.iterdir() if f.is_file() and "json" not in f.name] + if not pdf_files: + raise FileNotFoundError(f"No PDF found in {template_dir}") + pdf_path = pdf_files[0] + pdf_b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8") + + # Document name from documents.json + doc_info = documents_data.get("documents", [{}])[0] + doc_name = doc_info.get("name", pdf_path.name) + if not doc_name.lower().endswith(".pdf"): + doc_name = Path(doc_name).stem + ".pdf" + + # Derive recipients from form field assignees + recipients = derive_recipients(fields) + + # Build signers with empty tab groups + signers = [] + for r in recipients: + signers.append({ + "roleName": r["roleName"], + "recipientId": str(r["index"] + 1), + "routingOrder": str(r["index"] + 1), + "tabs": {}, + }) + + # Assign tabs to the correct signer + for field in fields: + assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}" + idx = assignee_to_index(assignee, recipients) + if idx >= len(signers): + idx = 0 + tabs = build_tabs_for_field(field, warnings) + signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs) + + template = { + "name": metadata.get("name", template_dir.name), + "description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}", + "documents": [ + { + "documentBase64": pdf_b64, + "name": doc_name, + "fileExtension": "pdf", + "documentId": DOCUMENT_ID, + } + ], + "recipients": { + "signers": signers, + }, + } + + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + json.dump(template, f, indent=2) + + return template, warnings + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import sys + + downloads_dir = Path(__file__).parent.parent / "downloads" + if not downloads_dir.exists(): + print("No downloads/ folder found. Run download_templates.py first.") + sys.exit(1) + + for template_dir in sorted(downloads_dir.iterdir()): + if not template_dir.is_dir(): + continue + output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json" + print(f"\n--- {template_dir.name} ---") + try: + _, warnings = compose_template(str(template_dir), str(output_path)) + print(f" Written: {output_path}") + for w in warnings: + print(f" WARNING: {w}") + except Exception as e: + print(f" ERROR: {e}") diff --git a/src/test_mapping.py b/src/test_mapping.py new file mode 100644 index 0000000..a20cdd5 --- /dev/null +++ b/src/test_mapping.py @@ -0,0 +1,104 @@ +# Mapping Function Unit Test +""" +Unit test harness for mapping Adobe Sign form fields to DocuSign tab config. +Validates that compose_docusign_template.py produces correctly structured output: + - Tabs are grouped into typed arrays (textTabs, signHereTabs, etc.) + - required / locked are strings "true" / "false", not booleans + - listTabs have listItems: [{text, value}], not a string array + - radioGroupTabs have groupName + radios[] + - No top-level "status" field + - No email/name on signer role placeholders +""" +import json +import sys +from pathlib import Path +from pprint import pprint + +# Support running from src/ or project root +BASE = Path(__file__).parent.parent + +sys.path.insert(0, str(Path(__file__).parent)) +from compose_docusign_template import compose_template + + +def test_onboarding_mapping(): + output_path = BASE / "validation" / "compose-doc-template-complete.json" + compose_template( + fields_path=str(BASE / "sample-templates" / "onboarding-template-formfields.json"), + template_meta_path=str(BASE / "sample-templates" / "onboarding-template.json"), + pdf_b64_path=str(BASE / "sample-templates" / "onboarding-sample.pdf.b64"), + output_path=str(output_path), + ) + + template = json.loads(output_path.read_text()) + + # -- No top-level status field -- + assert "status" not in template, "Template must not have a top-level 'status' field" + + signers = template["recipients"]["signers"] + assert len(signers) == 2, f"Expected 2 signers, got {len(signers)}" + + signer0_tabs = signers[0]["tabs"] + signer1_tabs = signers[1]["tabs"] + + # -- No email/name on role placeholders -- + for s in signers: + assert "email" not in s, f"Signer should not have 'email' in template role: {s['roleName']}" + assert "name" not in s, f"Signer should not have 'name' in template role: {s['roleName']}" + + # -- Tab groups present and properly typed -- + assert "textTabs" in signer0_tabs, "Signer 0 missing textTabs" + assert "dateSignedTabs" in signer0_tabs, "Signer 0 missing dateSignedTabs" + assert "listTabs" in signer0_tabs, "Signer 0 missing listTabs" + assert "checkboxTabs" in signer0_tabs, "Signer 0 missing checkboxTabs" + assert "radioGroupTabs" in signer0_tabs, "Signer 0 missing radioGroupTabs" + assert "signHereTabs" in signer0_tabs, "Signer 0 missing signHereTabs" + assert "textTabs" in signer1_tabs, "Signer 1 missing textTabs" + assert "signHereTabs" in signer1_tabs, "Signer 1 missing signHereTabs" + + # -- required / locked are strings -- + for tab in signer0_tabs.get("textTabs", []): + assert isinstance(tab.get("required"), str), f"required must be string, got {type(tab.get('required'))}" + for tab in signer0_tabs.get("listTabs", []): + assert isinstance(tab.get("required"), str), f"required must be string on listTab" + + # -- listItems are objects -- + list_tab = signer0_tabs["listTabs"][0] + assert "listItems" in list_tab, "listTab missing listItems" + assert isinstance(list_tab["listItems"][0], dict), "listItems entries must be {text, value} dicts" + assert "text" in list_tab["listItems"][0], "listItems entries must have 'text'" + assert "value" in list_tab["listItems"][0], "listItems entries must have 'value'" + + # -- radioGroupTabs structure -- + radio_tab = signer0_tabs["radioGroupTabs"][0] + assert "groupName" in radio_tab, "radioGroupTab missing groupName" + assert "radios" in radio_tab, "radioGroupTab missing radios" + assert len(radio_tab["radios"]) == 3, f"Expected 3 radios, got {len(radio_tab['radios'])}" + for r in radio_tab["radios"]: + assert "pageNumber" in r, "radio missing pageNumber" + assert "xPosition" in r, "radio missing xPosition" + assert "yPosition" in r, "radio missing yPosition" + assert "value" in r, "radio missing value" + + # -- All tabs have required placement fields -- + all_single_tabs = ( + signer0_tabs.get("textTabs", []) + + signer0_tabs.get("dateSignedTabs", []) + + signer0_tabs.get("signHereTabs", []) + + signer0_tabs.get("listTabs", []) + + signer0_tabs.get("checkboxTabs", []) + + signer1_tabs.get("textTabs", []) + + signer1_tabs.get("signHereTabs", []) + ) + for tab in all_single_tabs: + for field in ("documentId", "pageNumber", "xPosition", "yPosition"): + assert field in tab, f"Tab '{tab.get('tabLabel')}' missing '{field}'" + + print("✅ All mapping assertions passed!") + print("\n--- Generated template (recipients section) ---") + pprint(template["recipients"]) + + +if __name__ == "__main__": + test_onboarding_mapping() +