Files
FPandA-Engine/testing/tests/test_fpa.py
2026-03-20 21:53:55 +01:00

709 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
FP&A Test Suite — stdlib only, no pytest
Run: python tests/test_fpa.py
python tests/test_fpa.py --api # include live API tests
python tests/test_fpa.py --url http://localhost:9000
"""
import csv
import json
import os
import sys
import traceback
import urllib.request
import urllib.error
from dataclasses import dataclass, field
from typing import Callable, List, Optional
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "csv")
API_BASE = os.getenv("FPA_API_URL", "http://localhost:8080")
# ── Tiny test runner ──────────────────────────────────────────────────────────
@dataclass
class Result:
name: str
passed: bool
skipped: bool = False
message: str = ""
class Suite:
def __init__(self, name: str):
self.name = name
self.results: List[Result] = []
def run(self, label: str, fn: Callable):
try:
fn()
self.results.append(Result(label, passed=True))
except SkipTest as e:
self.results.append(Result(label, passed=False, skipped=True, message=str(e)))
except AssertionError as e:
self.results.append(Result(label, passed=False, message=str(e)))
except Exception as e:
self.results.append(Result(label, passed=False,
message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}"))
class SkipTest(Exception):
pass
def skip(reason: str):
raise SkipTest(reason)
# ── CSV helper ────────────────────────────────────────────────────────────────
def read_csv(filename: str) -> List[dict]:
path = os.path.join(DATA_DIR, filename)
if not os.path.exists(path):
skip(f"{filename} not found — run generators/generate_data.py first")
with open(path, newline="") as f:
return list(csv.DictReader(f))
# ── HTTP helpers (stdlib only) ────────────────────────────────────────────────
def http_get(url: str, timeout: int = 5):
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
body = json.loads(resp.read().decode())
return resp.status, body
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode())
except Exception:
body = {}
return e.code, body
except Exception:
return 0, {}
def http_post(url: str, payload: dict, timeout: int = 10):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
body = json.loads(raw.decode()) if raw else {}
return resp.status, body
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode())
except Exception:
body = {}
return e.code, body
except Exception:
return 0, {}
def http_put(url: str, payload: dict, timeout: int = 10):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"},
method="PUT")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
body = json.loads(raw.decode()) if raw else {}
return resp.status, body
except urllib.error.HTTPError as e:
return e.code, {}
except Exception:
return 0, {}
def api_available() -> bool:
status, _ = http_get(f"{API_BASE}/api/v1/health", timeout=2)
return status == 200
# ── Assertion helpers ─────────────────────────────────────────────────────────
def assert_true(condition, message: str = ""):
if not condition:
raise AssertionError(message or "Expected True, got False")
def assert_eq(actual, expected, message: str = ""):
if actual != expected:
raise AssertionError(message or f"Expected {expected!r}, got {actual!r}")
# ── Reference data ───────────────────────────────────────────────────────────
#
# Departments and GL accounts are CREATED via the API before any budget/actual.
# seed_reference_data() POSTs them and stores returned auto-increment IDs into
# DEPARTMENT_IDS and GL_ACCOUNTS at runtime — nothing is hardcoded.
# Uses ON CONFLICT upsert on the Go side so re-runs are safe.
BUDGET_VERSION = "v1" # matches BudgetVersion enum in your Go service
# Runtime ID maps — populated by seed_reference_data()
DEPARTMENT_IDS: dict = {}
GL_ACCOUNTS: dict = {} # category name → {"id": int, "code": str}
_DEPARTMENT_DEFS = [
# code, name, cost_center — matches departments table exactly
{"code": "REV", "name": "Revenue", "cost_center": "CC-100", "active": True},
{"code": "ENG", "name": "Engineering", "cost_center": "CC-200", "active": True},
{"code": "SAL", "name": "Sales", "cost_center": "CC-300", "active": True},
{"code": "MKT", "name": "Marketing", "cost_center": "CC-400", "active": True},
{"code": "OPS", "name": "Operations", "cost_center": "CC-500", "active": True},
{"code": "FIN", "name": "Finance", "cost_center": "CC-600", "active": True},
]
_GL_ACCOUNT_DEFS = [
# code, description, type (revenue|cogs|opex|capex|headcount), favour_high
# favour_high=True means over-budget is good (i.e. revenue beating target)
# Revenue
{"code": "4000", "description": "SaaS Subscription Revenue", "type": "revenue", "favour_high": True},
{"code": "4100", "description": "Professional Services Revenue","type": "revenue", "favour_high": True},
# COGS
{"code": "5000", "description": "Cost of Goods — Product", "type": "cogs", "favour_high": False},
{"code": "5100", "description": "Cost of Goods — Service", "type": "cogs", "favour_high": False},
# Opex
{"code": "6000", "description": "Salaries and Wages", "type": "opex", "favour_high": False},
{"code": "6100", "description": "Software and SaaS Tools", "type": "opex", "favour_high": False},
{"code": "6200", "description": "Travel and Expenses", "type": "opex", "favour_high": False},
{"code": "6300", "description": "Marketing and Paid Media", "type": "opex", "favour_high": False},
{"code": "6400", "description": "Cloud Infrastructure", "type": "opex", "favour_high": False},
{"code": "6500", "description": "Contractors and Freelancers","type": "opex", "favour_high": False},
{"code": "6600", "description": "Office and Facilities", "type": "opex", "favour_high": False},
# Capex
{"code": "7000", "description": "Capital Expenditure", "type": "capex", "favour_high": False},
# Headcount
{"code": "9200", "description": "Headcount Cost", "type": "headcount", "favour_high": False},
]
# Maps CSV category names → GL codes so the seeder can find the right account
_CSV_CATEGORY_TO_GL_CODE = {
"Product": "4000",
"Service": "4100",
"Salaries": "6000",
"Software & Tools": "6100",
"Travel": "6200",
"Marketing Spend": "6300",
"Cloud Infrastructure": "6400",
"Contractors": "6500",
"Office & Facilities": "6600",
"P&L Summary": "4000", # roll up to revenue GL for summary lines
"Cash Flow": "7000",
"Headcount": "9200",
}
def seed_reference_data() -> Optional[str]:
"""
POST all departments and GL accounts to the API and store returned IDs.
Returns an error string on first failure, or None on success.
Safe to call multiple times — Go side uses ON CONFLICT(code) upsert.
Populates:
DEPARTMENT_IDS — {"Engineering": 3, ...} keyed by department name
GL_ACCOUNTS — {"4000": {"id": 1, "code": "4000"}, ...} keyed by GL code
"""
for defn in _DEPARTMENT_DEFS:
status, body = http_post(f"{API_BASE}/api/v1/departments", defn)
if status not in (200, 201):
return f"POST /departments '{defn['name']}' failed ({status}): {body}"
DEPARTMENT_IDS[defn["name"]] = body["id"]
for defn in _GL_ACCOUNT_DEFS:
status, body = http_post(f"{API_BASE}/api/v1/gl-accounts", defn)
if status not in (200, 201):
return f"POST /gl-accounts '{defn['code']}' failed ({status}): {body}"
GL_ACCOUNTS[defn["code"]] = {"id": body["id"], "code": defn["code"]}
return None # success
def gl(category: str) -> dict:
"""
Resolve a CSV category name to a seeded GL account dict {"id": int, "code": str}.
Looks up via _CSV_CATEGORY_TO_GL_CODE then into the runtime GL_ACCOUNTS map.
"""
code = _CSV_CATEGORY_TO_GL_CODE.get(category)
if code is None:
raise AssertionError(
f"No GL code mapping for CSV category '{category}'. "
f"Add it to _CSV_CATEGORY_TO_GL_CODE."
)
acct = GL_ACCOUNTS.get(code)
if acct is None:
raise AssertionError(
f"GL code '{code}' (for '{category}') not in runtime map — "
f"was seed_reference_data() called? Known codes: {list(GL_ACCOUNTS.keys())}"
)
return acct
def period_to_fiscal(period: str) -> tuple:
"""'2023-04' → (2023, 4)"""
year, month = period.split("-")
return int(year), int(month)
# ── suite_revenue — full API round-trip ───────────────────────────────────────
#
# Flow:
# 1. Load CSV rows (local data validation)
# 2. POST each row as a CreateBudgetRequest → /api/v1/budgets
# 3. POST actuals for the same rows → /api/v1/actuals/ingest
# 4. GET /api/v1/variance → verify amounts round-trip correctly
def suite_revenue() -> Suite:
"""
Maps to Go structs:
CreateBudgetRequest:
fiscal_year, fiscal_period, version, department_id,
gl_account_id, amount, currency, notes, created_by
Actual (ingest):
fiscal_year, fiscal_period, department_id,
gl_account_id, gl_code, amount, currency, source
"""
s = Suite("Revenue — CSV + API round-trip")
if not api_available():
s.run("API reachable", lambda: skip(f"Go API not running at {API_BASE}"))
return s
rows = []
# ── Step 1: load and validate CSV data ────────────────────────────────────
def load():
rows.extend(read_csv("revenue_budget_vs_actuals.csv"))
s.run("CSV loads without error", load)
s.run("CSV has 48 rows (24 months × 2 revenue types)", lambda:
assert_eq(len(rows), 48))
s.run("CSV revenue types are Product and Service only", lambda:
assert_eq({r["revenue_type"] for r in rows}, {"Product", "Service"}))
def check_csv_variance():
for r in rows:
diff = float(r["actual_amount"]) - float(r["budget_amount"])
assert_true(abs(diff - float(r["variance"])) < 0.01,
f"CSV variance mismatch in {r['period']}")
s.run("CSV variance = actual budget", check_csv_variance)
# ── Step 2: seed departments + GL accounts ────────────────────────────────
# Must succeed before any budget POST — these rows are the FK parents.
def seed_refs():
err = seed_reference_data()
assert_true(err is None, err or "seed_reference_data failed")
s.run("POST /api/v1/departments + /api/v1/gl-accounts (seed FK parents)", seed_refs)
# ── Step 3: POST budgets ──────────────────────────────────────────────────
# One CreateBudgetRequest per CSV row.
# We only post the first period (2023-01) for both types to keep the
# test focused; the loader handles the full dataset.
budget_ids: dict[str, int] = {} # key: "revenue_type:period" → returned id
def post_product_budget():
row = next(r for r in rows
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
fy, fp = period_to_fiscal(row["period"])
payload = {
"fiscal_year": fy,
"fiscal_period": fp,
"version": BUDGET_VERSION,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": gl("Product")["id"],
"amount": float(row["budget_amount"]),
"currency": "USD",
"notes": "Product revenue budget — csv import",
"created_by": "test_suite",
}
status, body = http_post(f"{API_BASE}/api/v1/budgets", payload)
assert_true(status in (200, 201),
f"POST /budgets failed ({status}): {body}")
budget_id = body.get("id")
assert_true(budget_id is not None, "Response missing 'id' field")
budget_ids["Product:2023-01"] = budget_id
s.run("POST /api/v1/budgets — Product revenue 2023-01", post_product_budget)
def post_service_budget():
row = next(r for r in rows
if r["revenue_type"] == "Service" and r["period"] == "2023-01")
fy, fp = period_to_fiscal(row["period"])
payload = {
"fiscal_year": fy,
"fiscal_period": fp,
"version": BUDGET_VERSION,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": gl("Service")["id"],
"amount": float(row["budget_amount"]),
"currency": "USD",
"notes": "Service revenue budget — csv import",
"created_by": "test_suite",
}
status, body = http_post(f"{API_BASE}/api/v1/budgets", payload)
assert_true(status in (200, 201),
f"POST /budgets failed ({status}): {body}")
budget_ids["Service:2023-01"] = body.get("id")
s.run("POST /api/v1/budgets — Service revenue 2023-01", post_service_budget)
# ── Step 4: POST actuals ──────────────────────────────────────────────────
# Ingest actual amounts for the same period so variance can be computed.
ingested_actuals: list[dict] = []
def post_actuals():
period_rows = [r for r in rows if r["period"] == "2023-01"]
records = []
for r in period_rows:
fy, fp = period_to_fiscal(r["period"])
account = gl(r["revenue_type"])
records.append({
"fiscal_year": fy,
"fiscal_period": fp,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": account["id"],
"gl_code": account["code"],
"amount": float(r["actual_amount"]),
"currency": "USD",
"source": "test_suite_csv",
})
status, body = http_post(f"{API_BASE}/api/v1/actuals/ingest",
{"records": records})
assert_true(status in (200, 201),
f"POST /actuals/ingest failed ({status}): {body}")
# Store returned actuals for later assertions
returned = body.get("actuals") or body.get("records") or []
ingested_actuals.extend(returned)
s.run("POST /api/v1/actuals/ingest — Product + Service 2023-01", post_actuals)
# ── Step 5: GET variance and verify numbers ───────────────────────────────
variance_data: list[dict] = []
def fetch_variance():
url = f"{API_BASE}/api/v1/variance?fiscal_year=2023&fiscal_period=1"
status, body = http_get(url)
assert_true(status == 200, f"GET /variance failed ({status}): {body}")
# API may return a list directly or wrapped in a key
items = body if isinstance(body, list) else body.get("variance") or body.get("data") or []
assert_true(len(items) > 0, "Variance response is empty — data may not have landed")
variance_data.extend(items)
s.run("GET /api/v1/variance returns entries for 2023-01", fetch_variance)
def verify_product_variance():
csv_row = next(r for r in rows
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
expected_budget = float(csv_row["budget_amount"])
expected_actual = float(csv_row["actual_amount"])
expected_variance = float(csv_row["variance"])
# Find the matching variance entry by GL account id
entry = next(
(v for v in variance_data
if v.get("gl_account_id") == gl("Product")["id"]
or v.get("gl_code") == gl("Product")["code"]),
None
)
assert_true(entry is not None,
f"No variance entry found for Product (GL {gl('Product')['id']})")
api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0)
api_actual = float(entry.get("actual_amount") or entry.get("actual") or 0)
api_variance = float(entry.get("variance") or entry.get("variance_amount") or 0)
assert_true(abs(api_budget - expected_budget) < 1.0,
f"Budget mismatch: API={api_budget} CSV={expected_budget}")
assert_true(abs(api_actual - expected_actual) < 1.0,
f"Actual mismatch: API={api_actual} CSV={expected_actual}")
assert_true(abs(api_variance - expected_variance) < 1.0,
f"Variance mismatch: API={api_variance} CSV={expected_variance}")
s.run("Variance amounts match CSV for Product 2023-01", verify_product_variance)
def verify_service_variance():
csv_row = next(r for r in rows
if r["revenue_type"] == "Service" and r["period"] == "2023-01")
expected_budget = float(csv_row["budget_amount"])
expected_actual = float(csv_row["actual_amount"])
expected_variance = float(csv_row["variance"])
entry = next(
(v for v in variance_data
if v.get("gl_account_id") == gl("Service")["id"]
or v.get("gl_code") == gl("Service")["code"]),
None
)
assert_true(entry is not None,
f"No variance entry found for Service (GL {gl('Service')['id']})")
api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0)
api_actual = float(entry.get("actual_amount") or entry.get("actual") or 0)
api_variance = float(entry.get("variance") or entry.get("variance_amount") or 0)
assert_true(abs(api_budget - expected_budget) < 1.0,
f"Budget mismatch: API={api_budget} CSV={expected_budget}")
assert_true(abs(api_actual - expected_actual) < 1.0,
f"Actual mismatch: API={api_actual} CSV={expected_actual}")
assert_true(abs(api_variance - expected_variance) < 1.0,
f"Variance mismatch: API={api_variance} CSV={expected_variance}")
s.run("Variance amounts match CSV for Service 2023-01", verify_service_variance)
# ── Step 6: update a budget and verify variance shifts ────────────────────
def update_and_reverify():
budget_id = budget_ids.get("Product:2023-01")
if not budget_id:
skip("Product budget id not captured — skipping update test")
csv_row = next(r for r in rows
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
revised_amount = float(csv_row["budget_amount"]) * 1.10 # +10% revision
fy, fp = period_to_fiscal("2023-01")
status, body = http_put(
f"{API_BASE}/api/v1/budgets/{budget_id}",
{
"fiscal_year": fy,
"fiscal_period": fp,
"version": BUDGET_VERSION,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": gl("Product")["id"],
"amount": revised_amount,
"currency": "USD",
"notes": "revised +10% mid-cycle",
"created_by": "test_suite",
}
)
assert_true(status in (200, 204),
f"PUT /budgets/{budget_id} failed ({status}): {body}")
# Re-fetch variance — the variance amount should now reflect the new budget
url = f"{API_BASE}/api/v1/variance?fiscal_year=2023&fiscal_period=1"
status2, body2 = http_get(url)
assert_true(status2 == 200, f"Re-fetch variance failed ({status2})")
items = body2 if isinstance(body2, list) else body2.get("variance") or body2.get("data") or []
entry = next(
(v for v in items if v.get("gl_account_id") == gl("Product")["id"]
or v.get("gl_code") == gl("Product")["code"]),
None
)
assert_true(entry is not None, "Product variance entry missing after budget update")
api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0)
assert_true(abs(api_budget - revised_amount) < 1.0,
f"Updated budget not reflected: API={api_budget} expected={revised_amount:.2f}")
s.run("PUT /api/v1/budgets/{id} — variance reflects revised budget", update_and_reverify)
return s
# ── Remaining CSV-only suites (unchanged) ─────────────────────────────────────
def suite_opex() -> Suite:
s = Suite("Opex CSV")
rows = []
def load():
rows.extend(read_csv("opex_budget_vs_actuals.csv"))
s.run("loads without error", load)
s.run("has rows", lambda: assert_true(len(rows) > 0))
s.run("all departments present", lambda:
assert_true(
{"Engineering", "Sales", "Marketing", "Operations"}.issubset(
{r["department"] for r in rows}
)))
def check_no_zero():
for r in rows:
assert_true(float(r["budget_amount"]) > 0,
f"Zero budget: {r['department']} / {r['category']}")
s.run("no zero budget amounts", check_no_zero)
def check_sign():
for r in rows:
actual = float(r["actual_amount"])
budget = float(r["budget_amount"])
variance = float(r["variance"])
exp = 1 if actual > budget else (-1 if actual < budget else 0)
got = 1 if variance > 0 else (-1 if variance < 0 else 0)
assert_eq(got, exp, f"Variance sign wrong in {r['period']} {r['category']}")
s.run("variance sign matches actual vs budget", check_sign)
return s
def suite_pl() -> Suite:
s = Suite("P&L CSV")
rows = []
def load():
rows.extend(read_csv("pl_income_statement.csv"))
s.run("loads without error", load)
s.run("exactly 24 rows", lambda: assert_eq(len(rows), 24))
def check_rev_sum():
for r in rows:
total = float(r["product_revenue"]) + float(r["service_revenue"])
assert_true(abs(total - float(r["total_revenue"])) < 0.05,
f"Revenue sum mismatch in {r['period']}")
s.run("total_revenue = product + service", check_rev_sum)
def check_gross():
for r in rows:
gp = float(r["total_revenue"]) - float(r["total_cogs"])
assert_true(abs(gp - float(r["gross_profit"])) < 0.05,
f"Gross profit mismatch in {r['period']}")
s.run("gross_profit = revenue cogs", check_gross)
def check_margin():
for r in rows:
gm = float(r["gross_margin_pct"])
assert_true(30 <= gm <= 90, f"Gross margin {gm}% out of range in {r['period']}")
s.run("gross margin between 30% and 90%", check_margin)
def check_ebitda():
for r in rows:
ebitda = float(r["gross_profit"]) - float(r["total_opex"])
assert_true(abs(ebitda - float(r["ebitda"])) < 0.05,
f"EBITDA mismatch in {r['period']}")
s.run("ebitda = gross_profit opex", check_ebitda)
return s
def suite_cashflow() -> Suite:
s = Suite("Cash Flow CSV")
rows = []
def load():
rows.extend(read_csv("cash_flow.csv"))
s.run("loads without error", load)
s.run("exactly 24 rows", lambda: assert_eq(len(rows), 24))
def check_net():
for r in rows:
total = (float(r["net_operating_cash_flow"]) +
float(r["net_investing_cash_flow"]) +
float(r["net_financing_cash_flow"]))
assert_true(abs(total - float(r["net_change_in_cash"])) < 0.05,
f"Cash flow sum mismatch in {r['period']}")
s.run("net_change = operating + investing + financing", check_net)
def check_pre_series_a():
for r in rows:
if r["period"] <= "2023-05":
assert_true(float(r["closing_cash_balance"]) > 0,
f"Negative cash too early: {r['period']}")
s.run("cash balance positive before Series A (pre 2023-06)", check_pre_series_a)
def check_series_a():
june = next((r for r in rows if r["period"] == "2023-06"), None)
assert_true(june is not None, "2023-06 row missing")
assert_true(float(june["equity_raised"]) > 0, "Series A not recorded in 2023-06")
s.run("Series A visible in 2023-06", check_series_a)
return s
def suite_headcount() -> Suite:
s = Suite("Headcount CSV")
rows = []
def load():
rows.extend(read_csv("headcount_workforce.csv"))
s.run("loads without error", load)
s.run("has rows", lambda: assert_true(len(rows) > 0))
s.run("status only Active or Terminated", lambda:
assert_true({r["status"] for r in rows}.issubset({"Active", "Terminated"})))
def check_fte():
for r in rows:
fte = float(r["headcount_fte"])
assert_true(0 < fte <= 1.0,
f"FTE {fte} out of range for {r['employee_id']}")
s.run("FTE between 0 and 1.0", check_fte)
def check_salary():
for r in rows:
assert_true(float(r["annual_salary_budget"]) > 0)
s.run("all salaries positive", check_salary)
def check_growth():
jan = [r for r in rows if r["period"] == "2023-01" and r["status"] == "Active"]
dec = [r for r in rows if r["period"] == "2024-12" and r["status"] == "Active"]
assert_true(len(dec) >= len(jan),
f"Headcount shrank: {len(jan)} in Jan 2023 → {len(dec)} in Dec 2024")
s.run("headcount grows from 2023-01 to 2024-12", check_growth)
return s
# ── Reporter ──────────────────────────────────────────────────────────────────
PASS = "\033[32m✓\033[0m"
FAIL = "\033[31m✗\033[0m"
SKIP = "\033[33m⊘\033[0m"
BOLD = "\033[1m"
RESET = "\033[0m"
def run_suites(suites: List[Suite]) -> bool:
total = passed = failed = skipped = 0
for suite in suites:
print(f"\n{BOLD}{suite.name}{RESET}")
for r in suite.results:
total += 1
if r.skipped:
skipped += 1
print(f" {SKIP} {r.name}")
print(f" {r.message}")
elif r.passed:
passed += 1
print(f" {PASS} {r.name}")
else:
failed += 1
print(f" {FAIL} {r.name}")
for line in r.message.strip().splitlines():
print(f" {line}")
print(f"\n{'' * 48}")
color = "\033[32m" if failed == 0 else "\033[31m"
print(f"{color}{BOLD}{passed}/{total} passed{RESET}"
+ (f" {SKIP} {skipped} skipped" if skipped else "")
+ (f" {FAIL} {failed} failed" if failed else ""))
print()
return failed == 0
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="FP&A test suite — no dependencies needed")
parser.add_argument("--api", action="store_true",
help="Include revenue round-trip API test (requires Go API running)")
parser.add_argument("--url", default=None, help="Override API base URL")
args = parser.parse_args()
if args.url:
API_BASE = args.url
# suite_revenue always runs — it skips the API steps gracefully if not available
suites = [
suite_revenue(),
suite_opex(),
suite_pl(),
suite_cashflow(),
suite_headcount(),
]
ok = run_suites(suites)
sys.exit(0 if ok else 1)