Keep validate.py and the finding engine as-is. AGPL LICENSE stays at the repo root. Docker overlays dependency then src. Do not delete the inherited tree.
92 lines
3.5 KiB
Python
92 lines
3.5 KiB
Python
"""Cost estimator smoke-tests against the simple_project fixture."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from tests.paths import SIMPLE_PROJECT
|
|
|
|
import pytest
|
|
|
|
from backend.services import projects as proj_svc
|
|
from backend.services.cost_estimator import (
|
|
CostEstimate,
|
|
estimate_pipeline_cost,
|
|
estimate_stage_cost_usd,
|
|
)
|
|
|
|
|
|
def _seed_project(storage, user_id: str, project_id: str) -> None:
|
|
"""Create a minimal project with the simple_project BOM uploaded."""
|
|
proj_svc.create_project(storage, user_id, "Fixture")
|
|
# create_project returns a new id; overwrite with a known one
|
|
meta = proj_svc.create_project(storage, user_id, "Fixture2")
|
|
|
|
# Use any CSV from simple_project as the BOM
|
|
bom_candidates = list(SIMPLE_PROJECT.glob("*.csv"))
|
|
if not bom_candidates:
|
|
pytest.skip("simple_project/ has no BOM CSV")
|
|
bom_data = bom_candidates[0].read_bytes()
|
|
proj_svc.save_bom(storage, user_id, meta.id, bom_data)
|
|
|
|
|
|
def test_estimator_returns_credit_range(storage):
|
|
"""For a real BOM, estimator returns bounded credit range."""
|
|
if not SIMPLE_PROJECT.is_dir():
|
|
pytest.skip("simple_project/ not present in repo")
|
|
meta = proj_svc.create_project(storage, "u1", "Fixture")
|
|
bom_candidates = list(SIMPLE_PROJECT.glob("*.csv"))
|
|
if not bom_candidates:
|
|
pytest.skip("simple_project/ has no BOM CSV")
|
|
proj_svc.save_bom(storage, "u1", meta.id, bom_candidates[0].read_bytes())
|
|
|
|
est = estimate_pipeline_cost(storage, "u1", meta.id)
|
|
|
|
assert isinstance(est, CostEstimate)
|
|
assert est.api_cost_low <= est.api_cost_mid <= est.api_cost_high
|
|
assert est.credits_low <= est.credits_high
|
|
assert est.api_cost_mid > 0
|
|
# Breakdown should have an IC extraction entry for the project's ICs.
|
|
# (Review entries only land here when each IC has a datasheet uploaded;
|
|
# this fixture doesn't upload PDFs, so they're skipped.)
|
|
kinds = {item.kind for item in est.breakdown}
|
|
assert "ic_extraction" in kinds
|
|
|
|
|
|
def test_estimator_counts_library_cache_hits(storage, tmp_path):
|
|
"""When an IC is in the library, its extraction cost becomes $0."""
|
|
if not SIMPLE_PROJECT.is_dir():
|
|
pytest.skip("simple_project/ not present")
|
|
|
|
# Seed a project with the BOM
|
|
meta = proj_svc.create_project(storage, "u1", "Fixture")
|
|
bom_candidates = list(SIMPLE_PROJECT.glob("*.csv"))
|
|
if not bom_candidates:
|
|
pytest.skip("no BOM")
|
|
proj_svc.save_bom(storage, "u1", meta.id, bom_candidates[0].read_bytes())
|
|
|
|
before = estimate_pipeline_cost(storage, "u1", meta.id)
|
|
|
|
# Plant a library extraction for the first IC MPN the estimator saw
|
|
ic_items = [it for it in before.breakdown if it.kind == "ic_extraction"
|
|
and it.source == "api_call_estimated"]
|
|
if not ic_items:
|
|
pytest.skip("fixture has no uncached IC")
|
|
target_mpn = ic_items[0].identifier
|
|
from backend.periscopex.utils import safe_mpn
|
|
|
|
storage.write_json(
|
|
f"library/extracted/{safe_mpn(target_mpn)}.json",
|
|
{"mpn": target_mpn, "pintable": [{"number": 1, "name": "VCC"}]},
|
|
)
|
|
|
|
after = estimate_pipeline_cost(storage, "u1", meta.id)
|
|
assert after.cached_ic_count == before.cached_ic_count + 1
|
|
assert after.api_cost_mid <= before.api_cost_mid
|
|
# Savings should be at least the baseline IC cost
|
|
assert before.api_cost_mid - after.api_cost_mid >= estimate_stage_cost_usd("ic_extraction") - 0.001
|
|
|
|
|
|
def test_estimator_errors_without_bom(storage):
|
|
meta = proj_svc.create_project(storage, "u1", "No BOM")
|
|
with pytest.raises(FileNotFoundError):
|
|
estimate_pipeline_cost(storage, "u1", meta.id)
|