feat: core migration — Adobe Sign to DocuSign field mapping and composition
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 <noreply@anthropic.com>
This commit is contained in:
parent
e655d8b4f5
commit
76568672d7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <output.json>
|
||||
|
||||
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/<template-name>/ 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}")
|
||||
|
|
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue