Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console Skills, netlist/BOM design graph, per-IC direct datasheet review with page citations, capacitor derating, Next.js report UI. Extracted from the Pinscope cloud codebase. Auth and billing live in the private gateway repo behind stable seams (billing_hook.py, adapter files listed in CLAUDE.md).
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Shared pytest fixtures — temp-dir storage backend + clean import path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the repo root is on sys.path so `import backend.*` works whether
|
||||
# pytest is invoked from the repo root or a subdirectory.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_llm_post_passes(monkeypatch):
|
||||
"""Keep the LLM normalize + cross-IC dedup passes off by default in tests.
|
||||
|
||||
Both ``normalize_findings_async`` and ``dedupe_cross_ic_findings_async``
|
||||
import ``call_with_fallback`` into their own module namespace, so a test
|
||||
that does ``monkeypatch.setattr(validation, "call_with_fallback", fake)``
|
||||
does NOT intercept them — they would reach the real provider and make a
|
||||
live API call mid-test. The dedicated unit tests exercise these passes via
|
||||
their pure builder functions (``_build_normalized`` / ``_build_deduped``)
|
||||
directly, so nothing needs them enabled through ``validate_design_async``.
|
||||
A test that genuinely wants them on can re-enable with its own
|
||||
``monkeypatch.setattr`` (which runs after this autouse fixture)."""
|
||||
from backend.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "normalize_findings_enabled", False, raising=False)
|
||||
monkeypatch.setattr(settings, "cross_ic_dedup_enabled", False, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
"""Return a LocalStorageBackend rooted at a fresh temp directory."""
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
return LocalStorageBackend(tmp_path)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for the Anthropic cache_control breakpoint cap.
|
||||
|
||||
Anthropic rejects requests with >4 cache_control blocks. The review path adds
|
||||
one per cacheable PDF excerpt, so a hub IC fetching two interface excerpts hit
|
||||
5 (system + initial PDF + initial context + 2 excerpts) and the API returned
|
||||
"A maximum of 4 blocks with cache_control may be provided. Found 5." The
|
||||
provider caps message-block breakpoints so system(1) + messages never exceed 4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.services.llm.anthropic_provider import _enforce_cache_breakpoint_limit
|
||||
|
||||
|
||||
def _cc(block: dict) -> bool:
|
||||
return "cache_control" in block
|
||||
|
||||
|
||||
def _count(messages: list[dict]) -> int:
|
||||
return sum(_cc(b) for m in messages for b in m["content"])
|
||||
|
||||
|
||||
def _ephemeral() -> dict:
|
||||
return {"cache_control": {"type": "ephemeral"}}
|
||||
|
||||
|
||||
def test_u2_two_excerpts_capped_to_three():
|
||||
"""The exact failure: initial PDF + context + 2 excerpts = 4 message
|
||||
breakpoints (5 with system). Cap to 3 so the total lands at 4."""
|
||||
initial_pdf = {"type": "document", "source": {}, **_ephemeral()}
|
||||
initial_text = {"type": "text", "text": "ctx", **_ephemeral()}
|
||||
excerpt1 = {"type": "document", "source": {}, **_ephemeral()}
|
||||
excerpt2 = {"type": "document", "source": {}, **_ephemeral()}
|
||||
messages = [
|
||||
{"role": "user", "content": [initial_pdf, initial_text]},
|
||||
{"role": "assistant", "content": [{"type": "tool_use", "id": "a", "name": "x", "input": {}}]},
|
||||
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "."}, excerpt1]},
|
||||
{"role": "assistant", "content": [{"type": "tool_use", "id": "b", "name": "x", "input": {}}]},
|
||||
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "b", "content": "."}, excerpt2]},
|
||||
]
|
||||
|
||||
_enforce_cache_breakpoint_limit(messages)
|
||||
|
||||
assert _count(messages) == 3 # +1 system = 4 total, within the limit
|
||||
# The stable full-datasheet anchor and the two most recent excerpts survive;
|
||||
# the initial context block (cheap to reprocess) loses its breakpoint.
|
||||
assert _cc(initial_pdf)
|
||||
assert not _cc(initial_text)
|
||||
assert _cc(excerpt1)
|
||||
assert _cc(excerpt2)
|
||||
|
||||
|
||||
def test_heavy_fanout_keeps_anchor_and_tail():
|
||||
"""A heavy-fanout review (many excerpts) still caps to 3 and always keeps
|
||||
the first cacheable block (the full-datasheet anchor)."""
|
||||
anchor = {"type": "document", "source": {}, **_ephemeral()}
|
||||
messages = [{"role": "user", "content": [anchor, {"type": "text", "text": "x", **_ephemeral()}]}]
|
||||
excerpts = []
|
||||
for _ in range(6):
|
||||
ex = {"type": "document", "source": {}, **_ephemeral()}
|
||||
excerpts.append(ex)
|
||||
messages.append({"role": "user", "content": [ex]})
|
||||
|
||||
_enforce_cache_breakpoint_limit(messages)
|
||||
|
||||
assert _count(messages) == 3
|
||||
assert _cc(anchor)
|
||||
# Two most-recent excerpts retained for incremental tail caching.
|
||||
assert _cc(excerpts[-1]) and _cc(excerpts[-2])
|
||||
|
||||
|
||||
def test_under_limit_untouched():
|
||||
"""Three or fewer message breakpoints are left exactly as-is."""
|
||||
pdf = {"type": "document", "source": {}, **_ephemeral()}
|
||||
text = {"type": "text", "text": "x", **_ephemeral()}
|
||||
messages = [{"role": "user", "content": [pdf, text]}]
|
||||
|
||||
_enforce_cache_breakpoint_limit(messages)
|
||||
|
||||
assert _cc(pdf) and _cc(text)
|
||||
assert _count(messages) == 2
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Cost estimator smoke-tests against the simple_project fixture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.cost_estimator import (
|
||||
CostEstimate,
|
||||
estimate_pipeline_cost,
|
||||
estimate_stage_cost_usd,
|
||||
)
|
||||
|
||||
|
||||
SIMPLE_PROJECT = Path(__file__).resolve().parents[1] / "simple_project"
|
||||
|
||||
|
||||
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.pinscopex.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)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Verify the cost estimator + credit gate auto-update when model
|
||||
routing env vars change.
|
||||
|
||||
Before this refactor, ``cost_estimator`` exposed flat ``DEFAULT_*_USD``
|
||||
constants that had to be hand-bumped every time someone changed a
|
||||
``MODEL_*`` / ``PROVIDER_*`` env var. Now ``estimate_stage_cost_usd``
|
||||
resolves the runtime provider+model from settings and prices against
|
||||
``services.llm.pricing.PRICING`` — the same table that real billing
|
||||
reads.
|
||||
|
||||
These tests pin that contract: same stage, two different models, two
|
||||
different costs (in the direction the rate table predicts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.cost_estimator import (
|
||||
STAGE_TOKEN_BASELINES,
|
||||
estimate_stage_cost_usd,
|
||||
)
|
||||
from backend.services.llm.pricing import PRICING
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_settings():
|
||||
"""Snapshot every per-stage routing field; restore after the test."""
|
||||
fields = [
|
||||
"anthropic_model", "gemini_model",
|
||||
"provider_default", "provider_validation",
|
||||
"provider_pintable", "provider_pattern", "provider_specs",
|
||||
"provider_auto_resolve",
|
||||
"model_validation", "model_validation_gemini",
|
||||
"model_pintable", "model_pintable_gemini",
|
||||
"model_pattern", "model_pattern_gemini",
|
||||
"model_specs", "model_specs_gemini",
|
||||
"model_auto_resolve", "model_auto_resolve_gemini",
|
||||
]
|
||||
snapshot = {f: getattr(settings, f) for f in fields if hasattr(settings, f)}
|
||||
yield
|
||||
for f, v in snapshot.items():
|
||||
setattr(settings, f, v)
|
||||
|
||||
|
||||
def test_review_cost_changes_with_validation_model(restore_settings):
|
||||
"""Routing validation to Sonnet vs Haiku should produce different
|
||||
per-IC review costs — and Haiku should be cheaper than Sonnet."""
|
||||
settings.provider_validation = "anthropic"
|
||||
|
||||
settings.model_validation = "claude-sonnet-4-6"
|
||||
sonnet_cost = estimate_stage_cost_usd("review")
|
||||
|
||||
settings.model_validation = "claude-haiku-4-5"
|
||||
haiku_cost = estimate_stage_cost_usd("review")
|
||||
|
||||
assert sonnet_cost > 0
|
||||
assert haiku_cost > 0
|
||||
# Haiku is ~3× cheaper than Sonnet on input ($1 vs $3) and 3× on
|
||||
# output ($5 vs $15). The blended ratio with cache_read should
|
||||
# land Haiku at <50% of Sonnet's cost — wide enough margin to be
|
||||
# robust to baseline tweaks.
|
||||
assert haiku_cost < sonnet_cost * 0.6
|
||||
|
||||
|
||||
def test_review_cost_changes_with_validation_provider(restore_settings):
|
||||
"""Flipping PROVIDER_VALIDATION between anthropic and gemini must
|
||||
swap the rate table the estimator pulls from."""
|
||||
settings.provider_validation = "anthropic"
|
||||
settings.model_validation = "claude-sonnet-4-6"
|
||||
anthropic_cost = estimate_stage_cost_usd("review")
|
||||
|
||||
settings.provider_validation = "gemini"
|
||||
settings.gemini_model = "gemini-3.1-pro-preview"
|
||||
settings.model_validation_gemini = "" # fall back to gemini_model
|
||||
gemini_cost = estimate_stage_cost_usd("review")
|
||||
|
||||
# Both > 0 and they're different — the test doesn't lock direction
|
||||
# because the cache-read multiplier asymmetry between providers
|
||||
# could legitimately swing it either way as the rate tables evolve.
|
||||
assert anthropic_cost > 0
|
||||
assert gemini_cost > 0
|
||||
assert abs(anthropic_cost - gemini_cost) > 0.01, (
|
||||
f"expected materially different costs, got "
|
||||
f"anthropic={anthropic_cost!r} gemini={gemini_cost!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_model_falls_back_to_default_rate(restore_settings):
|
||||
"""A model not in PRICING[provider] should price against
|
||||
PRICING[provider]['default'], not crash."""
|
||||
settings.provider_validation = "anthropic"
|
||||
settings.model_validation = "claude-totally-made-up-2099"
|
||||
cost = estimate_stage_cost_usd("review")
|
||||
|
||||
# Same baseline against PRICING['anthropic']['default']
|
||||
settings.model_validation = "" # forces anthropic_model fallback
|
||||
settings.anthropic_model = "claude-totally-made-up-2099"
|
||||
cost_via_global_default = estimate_stage_cost_usd("review")
|
||||
|
||||
assert cost > 0
|
||||
assert cost == pytest.approx(cost_via_global_default, rel=1e-9)
|
||||
|
||||
|
||||
def test_baselines_cover_every_estimator_stage_kind():
|
||||
"""STAGE_TOKEN_BASELINES must have an entry for every CostItem.kind
|
||||
the estimator emits — otherwise estimate_stage_cost_usd crashes
|
||||
with a KeyError mid-estimate."""
|
||||
expected = {
|
||||
"ic_extraction", "simple_extraction", "passive_pattern",
|
||||
"digikey_resolve", "review",
|
||||
}
|
||||
assert expected.issubset(STAGE_TOKEN_BASELINES.keys()), (
|
||||
f"missing baselines: {expected - set(STAGE_TOKEN_BASELINES.keys())}"
|
||||
)
|
||||
|
||||
|
||||
def test_settings_stages_are_known_to_config(restore_settings):
|
||||
"""The 'settings_stage' field of every baseline must be a key
|
||||
accepted by Settings.model_for_stage / provider_for_stage."""
|
||||
for stage, base in STAGE_TOKEN_BASELINES.items():
|
||||
s = str(base["settings_stage"])
|
||||
# Should not raise; should return non-empty strings for
|
||||
# provider+model.
|
||||
provider = settings.provider_for_stage(s)
|
||||
model = settings.model_for_stage(s)
|
||||
assert provider, f"empty provider for stage {stage!r} -> {s!r}"
|
||||
assert model, f"empty model for stage {stage!r} -> {s!r}"
|
||||
|
||||
|
||||
def test_pricing_table_has_all_default_entries():
|
||||
"""estimate_stage_cost_usd's safety-net fall-through assumes every
|
||||
provider has a 'default' row. Pin that contract."""
|
||||
for provider, table in PRICING.items():
|
||||
assert "default" in table, f"PRICING[{provider!r}] missing 'default'"
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Tests for get_datasheet_excerpt: safety guards, budget cap, cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from backend.pinscopex.models import DesignGraph
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.validation_tools import (
|
||||
EXCERPT_TOPICS,
|
||||
ExcerptState,
|
||||
execute_tool,
|
||||
get_datasheet_excerpt,
|
||||
)
|
||||
from backend.services.llm.types import PdfBlock
|
||||
from backend.services.validation import _signal_neighbors
|
||||
|
||||
GRAPH = Path(__file__).resolve().parent.parent / "simple_project" / "design_graph.json"
|
||||
IC_MPNS = {
|
||||
"U1": "SPX3819M5-L-3-3/TR",
|
||||
"U2": "CH340E",
|
||||
"U3": "MSPM0G3507SPTR",
|
||||
}
|
||||
|
||||
|
||||
def _blank_pdf(path: Path, pages: int = 3) -> None:
|
||||
w = PdfWriter()
|
||||
for _ in range(pages):
|
||||
w.add_blank_page(width=200, height=200)
|
||||
with path.open("wb") as fh:
|
||||
w.write(fh)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_and_pdfs(tmp_path):
|
||||
graph = DesignGraph.model_validate(json.loads(GRAPH.read_text()))
|
||||
pdf_dir = tmp_path / "datasheets"
|
||||
pdf_dir.mkdir()
|
||||
for mpn in IC_MPNS.values():
|
||||
_blank_pdf(pdf_dir / f"{safe_mpn(mpn)}.pdf")
|
||||
return graph, pdf_dir
|
||||
|
||||
|
||||
def _state(graph, current_ic, pdf_dir, **kwargs):
|
||||
return ExcerptState(
|
||||
current_ic=current_ic,
|
||||
connected_designators=_signal_neighbors(graph, current_ic),
|
||||
graph=graph,
|
||||
pdf_dir=pdf_dir,
|
||||
storage=None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_non_neighbor(graph_and_pdfs):
|
||||
"""U1's review cannot fetch U2/U3 — they're not signal neighbors."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
state = _state(graph, "U1", pdf_dir)
|
||||
text, attachment = get_datasheet_excerpt(
|
||||
graph, {}, "U2", "absolute_max", state,
|
||||
)
|
||||
assert attachment is None
|
||||
assert "not a signal neighbor" in text
|
||||
assert state.fetch_count == 0
|
||||
|
||||
|
||||
def test_rejects_self(graph_and_pdfs):
|
||||
"""The IC under review can't excerpt its own datasheet — it's already in
|
||||
context. Steer the model to use the existing PDF."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
state = _state(graph, "U2", pdf_dir)
|
||||
text, attachment = get_datasheet_excerpt(
|
||||
graph, {}, "U2", "absolute_max", state,
|
||||
)
|
||||
assert attachment is None
|
||||
assert "already reviewing" in text
|
||||
|
||||
|
||||
def test_rejects_unknown_topic(graph_and_pdfs):
|
||||
"""Bad topic enum returns a clean error listing valid topics."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
state = _state(graph, "U2", pdf_dir)
|
||||
text, attachment = get_datasheet_excerpt(
|
||||
graph, {}, "U3", "bogus_topic", state,
|
||||
)
|
||||
assert attachment is None
|
||||
assert "Unknown topic" in text
|
||||
assert "absolute_max" in text
|
||||
|
||||
|
||||
def test_returns_pdfblock_for_valid_neighbor(graph_and_pdfs):
|
||||
"""U2's review can fetch U3 (a signal neighbor) — returns PdfBlock."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
state = _state(graph, "U2", pdf_dir)
|
||||
text, attachment = get_datasheet_excerpt(
|
||||
graph, {}, "U3", "pin_voltage_levels", state,
|
||||
)
|
||||
assert isinstance(attachment, PdfBlock)
|
||||
assert attachment.cacheable is True
|
||||
assert "U3" in text
|
||||
assert "pin_voltage_levels" in text
|
||||
assert state.fetch_count == 1
|
||||
assert state.page_count > 0
|
||||
|
||||
|
||||
def test_budget_cap_fetch_count(graph_and_pdfs):
|
||||
"""After fetch_budget hits, further fetches are rejected with budget message."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
# Tight budget so the test is fast
|
||||
state = _state(graph, "U3", pdf_dir, fetch_budget=2, page_budget=100)
|
||||
# Two valid fetches succeed
|
||||
for topic in ("absolute_max", "pin_voltage_levels"):
|
||||
_, att = get_datasheet_excerpt(graph, {}, "U2", topic, state)
|
||||
assert att is not None, f"topic {topic} should have succeeded"
|
||||
# Third fetch should be budget-blocked
|
||||
text, attachment = get_datasheet_excerpt(
|
||||
graph, {}, "U2", "electrical_characteristics", state,
|
||||
)
|
||||
assert attachment is None
|
||||
assert "budget exhausted" in text
|
||||
|
||||
|
||||
def test_per_neighbor_page_budget_blocks_third_topic_on_same_neighbor(graph_and_pdfs):
|
||||
"""A single neighbor can be fetched up to its per-neighbor page budget,
|
||||
then further topics on it are blocked — bounds one interface's cost
|
||||
without touching the global budget."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
# Blank PDFs fall back to 3 pages/fetch; budget of 4 admits the first two
|
||||
# fetches (0→3, 3→6) and blocks the third once the neighbor is over.
|
||||
state = _state(
|
||||
graph, "U3", pdf_dir,
|
||||
fetch_budget=10, page_budget=1000, per_neighbor_page_budget=4,
|
||||
)
|
||||
_, a1 = get_datasheet_excerpt(graph, {}, "U2", "absolute_max", state)
|
||||
assert a1 is not None
|
||||
_, a2 = get_datasheet_excerpt(graph, {}, "U2", "pin_voltage_levels", state)
|
||||
assert a2 is not None
|
||||
text, a3 = get_datasheet_excerpt(graph, {}, "U2", "electrical_characteristics", state)
|
||||
assert a3 is None
|
||||
assert "Per-neighbor" in text and "U2" in text
|
||||
|
||||
|
||||
def test_global_page_budget_still_bounds_total_fanout(graph_and_pdfs):
|
||||
"""The global page budget is a hard ceiling even when a neighbor is under
|
||||
its per-neighbor sub-budget — bounds hub-IC fan-out."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
state = _state(
|
||||
graph, "U3", pdf_dir,
|
||||
fetch_budget=10, page_budget=2, per_neighbor_page_budget=100,
|
||||
)
|
||||
_, a1 = get_datasheet_excerpt(graph, {}, "U2", "absolute_max", state)
|
||||
assert a1 is not None # capped to 2 pages → page_count hits global budget
|
||||
text, a2 = get_datasheet_excerpt(graph, {}, "U2", "pin_voltage_levels", state)
|
||||
assert a2 is None
|
||||
assert "page budget exhausted" in text
|
||||
|
||||
|
||||
def test_cache_shared_across_states(graph_and_pdfs):
|
||||
"""Same (designator, topic, ds_md5) → second fetch reuses trimmed PDF.
|
||||
|
||||
Mirrors the cross-IC case: U2's review fetches U3@abs_max, then U3's
|
||||
review fetches U3@abs_max from a separate ExcerptState sharing the same
|
||||
cache dict.
|
||||
"""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
shared_cache: dict = {}
|
||||
s1 = _state(graph, "U2", pdf_dir, cache=shared_cache)
|
||||
s2 = _state(graph, "U3", pdf_dir, cache=shared_cache)
|
||||
_, att1 = get_datasheet_excerpt(graph, {}, "U3", "absolute_max", s1)
|
||||
assert att1 is not None
|
||||
path1 = att1.path
|
||||
# U3's review reusing the same neighbor (loopback: U3 fetching itself is
|
||||
# blocked, so use a different IC). We assert path identity for the U2
|
||||
# case from s1's perspective is preserved across a fresh state with the
|
||||
# same cache by re-fetching from s1's neighbor U3 with state s_other.
|
||||
s_other = _state(graph, "U2", pdf_dir, cache=shared_cache)
|
||||
_, att2 = get_datasheet_excerpt(graph, {}, "U3", "absolute_max", s_other)
|
||||
assert att2 is not None
|
||||
assert att2.path == path1 # cache hit — same trimmed file
|
||||
|
||||
|
||||
def test_no_state_returns_clean_error(graph_and_pdfs):
|
||||
"""Calling without state (regression guard) returns an explicit message."""
|
||||
graph, _ = graph_and_pdfs
|
||||
text, attachment = get_datasheet_excerpt(graph, {}, "U3", "absolute_max", None)
|
||||
assert attachment is None
|
||||
assert "without per-review state" in text
|
||||
|
||||
|
||||
def test_execute_tool_dispatcher_routes_correctly(graph_and_pdfs):
|
||||
"""The dispatcher recognises the new tool name and forwards state."""
|
||||
graph, pdf_dir = graph_and_pdfs
|
||||
state = _state(graph, "U2", pdf_dir)
|
||||
text, attachment = execute_tool(
|
||||
graph, {}, "get_datasheet_excerpt",
|
||||
{"designator": "U3", "topic": "absolute_max"},
|
||||
state=state,
|
||||
)
|
||||
assert isinstance(attachment, PdfBlock)
|
||||
assert state.fetch_count == 1
|
||||
|
||||
|
||||
def test_existing_tools_return_tuple_with_none_attachment(graph_and_pdfs):
|
||||
"""Backward compat: existing tools now return (text, None)."""
|
||||
graph, _ = graph_and_pdfs
|
||||
text, att = execute_tool(graph, {}, "get_pintable", {"designator": "U2"})
|
||||
assert isinstance(text, str) and text
|
||||
assert att is None
|
||||
|
||||
|
||||
def test_topics_cover_u2_001_use_case():
|
||||
"""The U2-001 false positive needs to verify MSPM0G3507 PA1's 5V-tolerance
|
||||
— assert that the pin_voltage_levels topic regex matches typical
|
||||
datasheet phrasings for this."""
|
||||
pat = EXCERPT_TOPICS["pin_voltage_levels"]
|
||||
assert pat.search("This pin is 5V-tolerant under normal operating conditions.")
|
||||
assert pat.search("VIH = 0.7 × VDD")
|
||||
assert pat.search("Input voltage range: -0.3V to VDD+0.3V")
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Cross-IC dedup pass — schema + validator behavior.
|
||||
|
||||
Locks in the rules for collapsing one physical interface defect reported from
|
||||
both ICs (the U2-001 / U3-001 duplication) into a single finding:
|
||||
|
||||
1. Singletons pass through unchanged (no laundering).
|
||||
2. A merge uses the `primary_index` member for component attribution + source,
|
||||
and its severity is clamped to the strongest member (never upgraded).
|
||||
3. `Unverified:` members cap the merge at WARNING and keep the prefix.
|
||||
4. Full index coverage is required; a missing/invalid `primary_index` un-merges
|
||||
rather than dropping findings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.services.dedupe_findings import (
|
||||
SUBMIT_DEDUPED_SCHEMA,
|
||||
_build_deduped,
|
||||
_serialize_findings_for_prompt,
|
||||
)
|
||||
|
||||
|
||||
def _f(idx: int, designator: str = "U2", status: str = "ERROR",
|
||||
why: str = "w", source_page: int | None = None) -> Finding:
|
||||
return Finding(
|
||||
designator=designator,
|
||||
mpn=f"MPN{idx}",
|
||||
finding=f"finding {idx}",
|
||||
why=why,
|
||||
status=status,
|
||||
recommendation="",
|
||||
source_page=source_page if source_page is not None else idx,
|
||||
source_quote=f"quote {idx}",
|
||||
reference=f"ref {idx}",
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_includes_designator_and_severity():
|
||||
"""The IC + reviewer severity are the primary signals for spotting that
|
||||
two findings are the two ends of one interface."""
|
||||
out = _serialize_findings_for_prompt([_f(1, "U2", "ERROR"), _f(2, "U3", "WARNING")])
|
||||
parsed = json.loads(out)
|
||||
assert [r["ic"] for r in parsed] == ["U2", "U3"]
|
||||
assert [r["reviewer_severity"] for r in parsed] == ["ERROR", "WARNING"]
|
||||
|
||||
|
||||
def test_schema_requires_member_indices():
|
||||
item = SUBMIT_DEDUPED_SCHEMA.input_schema["properties"]["groups"]["items"]
|
||||
assert "member_indices" in item["required"]
|
||||
assert "primary_index" in item["properties"]
|
||||
|
||||
|
||||
def test_singletons_pass_through_unchanged():
|
||||
originals = [_f(1, "U2"), _f(2, "U3")]
|
||||
groups = [
|
||||
{"member_indices": [1], "change_rationale": "passthrough"},
|
||||
{"member_indices": [2], "change_rationale": "passthrough"},
|
||||
]
|
||||
built = _build_deduped(groups, originals)
|
||||
assert built is not None
|
||||
assert [f.finding for f in built] == ["finding 1", "finding 2"]
|
||||
assert [f.designator for f in built] == ["U2", "U3"]
|
||||
|
||||
|
||||
def test_merge_collapses_interface_and_uses_primary_source():
|
||||
"""U2-001 + U3-001 → one finding. primary_index picks which side supplies
|
||||
the canonical designator + datasheet citation."""
|
||||
originals = [
|
||||
_f(1, "U2", "ERROR", source_page=11),
|
||||
_f(2, "U3", "ERROR", source_page=25),
|
||||
]
|
||||
groups = [{
|
||||
"member_indices": [1, 2],
|
||||
"primary_index": 2,
|
||||
"finding": "CH340E 5V output into MCU non-5V-tolerant pin",
|
||||
"why": "abs-max exceeded on the UART interface",
|
||||
"status": "ERROR",
|
||||
"recommendation": "level shift",
|
||||
"change_rationale": "merged 1+2: same UART interface",
|
||||
}]
|
||||
built = _build_deduped(groups, originals)
|
||||
assert built is not None
|
||||
assert len(built) == 1
|
||||
f = built[0]
|
||||
assert f.designator == "U3" # from primary_index=2
|
||||
assert f.source_page == 25 # primary's citation
|
||||
assert f.source_quote == "quote 2" # primary's quote retained
|
||||
assert f.status == "ERROR"
|
||||
assert "CH340E" in f.finding
|
||||
|
||||
|
||||
def test_merge_severity_clamped_to_strongest_member():
|
||||
originals = [_f(1, "U2", "WARNING"), _f(2, "U3", "INFO")]
|
||||
groups = [{
|
||||
"member_indices": [1, 2],
|
||||
"primary_index": 1,
|
||||
"finding": "merged",
|
||||
"why": "combined",
|
||||
"status": "ERROR", # over-graded — clamp to WARNING
|
||||
"recommendation": "",
|
||||
"change_rationale": "merged",
|
||||
}]
|
||||
built = _build_deduped(groups, originals)
|
||||
assert built is not None
|
||||
assert built[0].status == "WARNING"
|
||||
|
||||
|
||||
def test_merge_with_unverified_member_caps_at_warning_and_keeps_prefix():
|
||||
originals = [
|
||||
_f(1, "U2", "WARNING", why="Unverified: abs-max for PA14 not confirmed"),
|
||||
_f(2, "U3", "ERROR", why="will damage the MCU"),
|
||||
]
|
||||
groups = [{
|
||||
"member_indices": [1, 2],
|
||||
"primary_index": 2,
|
||||
"finding": "merged overvoltage",
|
||||
"why": "5V into a 3.6V pin",
|
||||
"status": "ERROR",
|
||||
"recommendation": "",
|
||||
"change_rationale": "merged",
|
||||
}]
|
||||
built = _build_deduped(groups, originals)
|
||||
assert built is not None
|
||||
assert built[0].status == "WARNING" # unverified caps the merge
|
||||
assert built[0].why.lower().startswith("unverified:")
|
||||
|
||||
|
||||
def test_invalid_primary_index_unmerges_to_originals():
|
||||
originals = [_f(1, "U2", "ERROR"), _f(2, "U3", "WARNING")]
|
||||
groups = [{
|
||||
"member_indices": [1, 2],
|
||||
"primary_index": 5, # not a member → un-merge
|
||||
"finding": "merged",
|
||||
"why": "x",
|
||||
"status": "ERROR",
|
||||
"recommendation": "",
|
||||
"change_rationale": "merged",
|
||||
}]
|
||||
built = _build_deduped(groups, originals)
|
||||
assert built is not None
|
||||
assert len(built) == 2
|
||||
assert [f.status for f in built] == ["ERROR", "WARNING"] # originals intact
|
||||
|
||||
|
||||
def test_missing_primary_index_on_merge_unmerges():
|
||||
originals = [_f(1, "U2"), _f(2, "U3")]
|
||||
groups = [{
|
||||
"member_indices": [1, 2],
|
||||
"finding": "merged", "why": "x", "status": "ERROR",
|
||||
"recommendation": "", "change_rationale": "merged",
|
||||
}]
|
||||
built = _build_deduped(groups, originals)
|
||||
assert built is not None
|
||||
assert len(built) == 2
|
||||
|
||||
|
||||
def test_coverage_gap_is_rejected():
|
||||
originals = [_f(1), _f(2)]
|
||||
groups = [{"member_indices": [1], "change_rationale": "passthrough"}]
|
||||
assert _build_deduped(groups, originals) is None
|
||||
|
||||
|
||||
def test_duplicate_index_is_rejected():
|
||||
originals = [_f(1), _f(2)]
|
||||
groups = [
|
||||
{"member_indices": [1], "change_rationale": "p"},
|
||||
{"member_indices": [1, 2], "primary_index": 2, "finding": "m",
|
||||
"why": "x", "status": "INFO", "recommendation": "", "change_rationale": "m"},
|
||||
]
|
||||
assert _build_deduped(groups, originals) is None
|
||||
@@ -0,0 +1,179 @@
|
||||
"""EDIF 2.0.0 netlist parser — Siemens xDX Designer flavor.
|
||||
|
||||
Verified against the client-supplied file ``edif-files/144040 (1).edn``
|
||||
(128 KB, two sub-designs merged, 42 instances, 20 nets). The BOM at
|
||||
``edif-files/BOM (1).xlsx`` covers 19 of those 42 designators; the rest are
|
||||
orphan parts in the second sub-design and are expected to land in the graph
|
||||
unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.pinscopex.parsers import (
|
||||
detect_netlist_format,
|
||||
parse_netlist_any,
|
||||
validate_netlist,
|
||||
)
|
||||
from backend.pinscopex.parsers_edif import (
|
||||
list_edif_subdesigns,
|
||||
parse_edif_netlist,
|
||||
)
|
||||
|
||||
|
||||
EDIF_FIXTURE = Path(__file__).resolve().parent.parent / "edif-files" / "144040 (1).edn"
|
||||
|
||||
# The client-supplied EDIF sample is confidential and never committed —
|
||||
# these tests only run on machines that have it locally.
|
||||
if not EDIF_FIXTURE.exists():
|
||||
pytest.skip(
|
||||
"edif-files/ sample netlist not present (local-only, untracked)",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
# Designators the client's BOM lists. The parser MUST surface every one of
|
||||
# these — anything else is a regression.
|
||||
BOM_DESIGNATORS = frozenset({
|
||||
"C1", "C2", "C3", "C4", "C5", "C6",
|
||||
"C22", "C23", "C24", "C25",
|
||||
"R1", "R6", "R7",
|
||||
"L1", "L5",
|
||||
"FB1", "FB2",
|
||||
"U1", "U3",
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parsed():
|
||||
parts, nets = parse_edif_netlist(EDIF_FIXTURE)
|
||||
return parts, nets
|
||||
|
||||
|
||||
def test_format_detection():
|
||||
assert detect_netlist_format(EDIF_FIXTURE.read_bytes()) == "edif"
|
||||
|
||||
|
||||
def test_dispatcher_routes_to_edif():
|
||||
parts, nets, fmt = parse_netlist_any(EDIF_FIXTURE)
|
||||
assert fmt == "edif"
|
||||
assert parts and nets
|
||||
|
||||
|
||||
def test_all_bom_designators_present(parsed):
|
||||
parts, _ = parsed
|
||||
missing = BOM_DESIGNATORS - set(parts)
|
||||
assert not missing, f"BOM designators missing from parser output: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_u3_pin_count_matches_ldo(parsed):
|
||||
"""U3 is the MIC5305YMLTR LDO — datasheet has 7 pins (6 + thermal EPAD)."""
|
||||
_, nets = parsed
|
||||
u3_pins = {pin for conns in nets.values() for ref, pin in conns if ref == "U3"}
|
||||
assert len(u3_pins) == 7, f"expected 7 distinct pins for U3, got {sorted(u3_pins)}"
|
||||
|
||||
|
||||
def test_u1_pin_count_matches_rf_amp(parsed):
|
||||
"""U1 is the CMD263P3 RF amp — datasheet has 17 pins (16 + thermal EPAD)."""
|
||||
_, nets = parsed
|
||||
u1_pins = {pin for conns in nets.values() for ref, pin in conns if ref == "U1"}
|
||||
assert len(u1_pins) == 17, f"expected 17 distinct pins for U1, got {sorted(u1_pins)}"
|
||||
|
||||
|
||||
def test_ground_net_renamed(parsed):
|
||||
"""The parser must rename Pin_Type=GROUND nets to ``GND`` so the existing
|
||||
validate_netlist() ground check passes."""
|
||||
_, nets = parsed
|
||||
assert "GND" in nets, f"expected a 'GND' net, found: {sorted(nets)}"
|
||||
assert len(nets["GND"]) > 5, "GND should have many endpoints in a real design"
|
||||
|
||||
|
||||
def test_validate_netlist_accepts_edif_output(parsed):
|
||||
parts, nets = parsed
|
||||
issues = validate_netlist(parts, nets)
|
||||
assert issues == [], f"unexpected validation issues: {issues}"
|
||||
|
||||
|
||||
def test_every_net_endpoint_resolves(parsed):
|
||||
"""No dangling (ref, pin) tuples — every connection should reference a
|
||||
designator that's also in the parts dict."""
|
||||
parts, nets = parsed
|
||||
refs = set(parts)
|
||||
bad = [
|
||||
(net, ref, pin)
|
||||
for net, conns in nets.items()
|
||||
for ref, pin in conns
|
||||
if ref not in refs
|
||||
]
|
||||
assert not bad, f"net endpoints reference unknown designators: {bad[:5]}"
|
||||
|
||||
|
||||
def test_template_designators_skipped(parsed):
|
||||
"""Instances whose designator is still a template (``R?`` / ``C?`` / ``U?``)
|
||||
should not leak into parts — they're unconfigured library symbols."""
|
||||
parts, _ = parsed
|
||||
leaked = [ref for ref in parts if ref.endswith("?")]
|
||||
assert not leaked, f"template designators leaked: {leaked}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-design listing + filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_subdesigns_finds_two():
|
||||
"""The client's file has two sub-designs (&0441, &0442). The lister must
|
||||
surface both with their instance counts and full designator lists."""
|
||||
subs = list_edif_subdesigns(EDIF_FIXTURE)
|
||||
ids = sorted(s["id"] for s in subs if s["id"])
|
||||
assert ids == ["&0441", "&0442"], f"unexpected sub-design ids: {ids}"
|
||||
# Each sub-design carries 21 resolved-designator instances.
|
||||
for s in subs:
|
||||
assert s["instance_count"] == len(s["designators"])
|
||||
assert s["instance_count"] > 0
|
||||
|
||||
|
||||
def test_subdesign_filter_keeps_only_selected():
|
||||
"""Filtering to ``&0441`` must yield exactly that sub-design's parts."""
|
||||
parts_a, nets_a = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns={"&0441"})
|
||||
parts_b, nets_b = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns={"&0442"})
|
||||
|
||||
bom = BOM_DESIGNATORS
|
||||
in_a = set(parts_a) & bom
|
||||
in_b = set(parts_b) & bom
|
||||
assert in_a == bom, f"sub-design A should hold every BOM ref; missing: {bom - in_a}"
|
||||
assert in_b == set(), f"sub-design B has 0 BOM refs in the fixture; got: {in_b}"
|
||||
# Designators must not overlap across sub-designs (xDX gives each its own
|
||||
# ref namespace per board).
|
||||
assert set(parts_a).isdisjoint(parts_b)
|
||||
|
||||
|
||||
def test_subdesign_filter_preserves_shared_ground():
|
||||
"""``GND`` (renamed via Pin_Type detection) spans both sub-designs in the
|
||||
raw EDIF. After filtering to one sub-design the net survives, but only
|
||||
with endpoints from instances that survived."""
|
||||
_, nets_a = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns={"&0441"})
|
||||
_, nets_all = parse_edif_netlist(EDIF_FIXTURE)
|
||||
assert "GND" in nets_a
|
||||
refs_a = {ref for ref, _ in nets_a["GND"]}
|
||||
refs_all = {ref for ref, _ in nets_all["GND"]}
|
||||
# Filtered GND is a strict subset of unfiltered GND.
|
||||
assert refs_a < refs_all, "filtered GND should drop the excluded sub-design's endpoints"
|
||||
|
||||
|
||||
def test_subdesign_filter_empty_set_returns_empty():
|
||||
"""Empty selection yields no parts (and no nets that referenced them)."""
|
||||
parts, nets = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns=set())
|
||||
assert parts == {}
|
||||
assert nets == {}
|
||||
|
||||
|
||||
def test_subdesign_filter_none_matches_unfiltered():
|
||||
"""``include_subdesigns=None`` (the default) is the pre-flag behavior."""
|
||||
parts1, nets1 = parse_edif_netlist(EDIF_FIXTURE)
|
||||
parts2, nets2 = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns=None)
|
||||
assert parts1 == parts2
|
||||
assert nets1 == nets2
|
||||
@@ -0,0 +1,122 @@
|
||||
"""LED forward-current check — Ohm's-law over the graph against the LED's rating.
|
||||
|
||||
Locks in:
|
||||
1. An undersized resistor (over-current) is an ERROR with source set.
|
||||
2. A properly sized resistor produces nothing.
|
||||
3. Resistance strings like "5.6K" parse correctly (not 5.6 ohm).
|
||||
4. Unknown rail voltage is not guessed into an ERROR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Net,
|
||||
NetType,
|
||||
ResistorSpecs,
|
||||
SimpleComponentSpecs,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.pinscopex.led_current_check import check_led_current, _parse_resistance
|
||||
|
||||
|
||||
def _led(values, pins, subtype="discrete.led.rgb"):
|
||||
return Component(
|
||||
reference="D1", value="RGB", footprint="",
|
||||
component_type=ComponentType.DISCRETE, component_subtype=subtype,
|
||||
mpn="LEDX",
|
||||
pins=pins,
|
||||
specs=SimpleComponentSpecs(specs_type="discrete", component_subtype=subtype, values=values),
|
||||
)
|
||||
|
||||
|
||||
def _res(ref, ohms_str, pins, value_ohms=None):
|
||||
specs = ResistorSpecs(value_ohms=value_ohms, value_formatted=ohms_str) if value_ohms is not None else None
|
||||
return Component(reference=ref, value=ohms_str, footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn=ref, pins=pins, specs=specs)
|
||||
|
||||
|
||||
def _driver(ref, pins):
|
||||
return Component(reference=ref, value="", footprint="",
|
||||
component_type=ComponentType.DISCRETE, mpn=ref, pins=pins)
|
||||
|
||||
|
||||
def _graph(components, nets):
|
||||
"""nets: {name: (net_type, voltage, [(ref, pin)])}"""
|
||||
net_objs = {}
|
||||
for name, (ntype, volt, conns) in nets.items():
|
||||
net_objs[name] = Net(
|
||||
name=name, net_type=ntype, voltage=volt,
|
||||
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
|
||||
)
|
||||
return DesignGraph(components=components, nets=net_objs)
|
||||
|
||||
|
||||
def _rgb_graph(green_resistor):
|
||||
led = _led(
|
||||
{"forward_voltage_green_v": "2.8V", "forward_current_per_channel_a": "13mA",
|
||||
"common_polarity": 1.0},
|
||||
{"A": "+5V", "G": "NetD1_G"},
|
||||
)
|
||||
q = _driver("Q1", {"3": "NetQ_D"})
|
||||
comps = {"D1": led, "R1": green_resistor, "Q1": q}
|
||||
nets = {
|
||||
"+5V": (NetType.POWER, 5.0, [("D1", "A")]),
|
||||
"NetD1_G": (NetType.SIGNAL, None, [("D1", "G"), ("R1", "2")]),
|
||||
"NetQ_D": (NetType.SIGNAL, None, [("R1", "1"), ("Q1", "3")]),
|
||||
}
|
||||
return _graph(comps, nets)
|
||||
|
||||
|
||||
def test_over_current_is_error():
|
||||
# 100 ohm from 5 V, Vf 2.8 -> 22 mA > 13 mA rating.
|
||||
g = _rgb_graph(_res("R1", "100R", {"2": "NetD1_G", "1": "NetQ_D"}, value_ohms=100.0))
|
||||
findings = check_led_current(g)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f.status == "ERROR" and f.source == "led_current_check" and f.source_page is None
|
||||
assert f.designator == "D1" and "green channel" in f.finding
|
||||
|
||||
|
||||
def test_proper_resistor_no_finding():
|
||||
# 5.6K (string only, no typed value_ohms) -> ~0.4 mA, safe.
|
||||
g = _rgb_graph(_res("R1", "5.6K", {"2": "NetD1_G", "1": "NetQ_D"}))
|
||||
assert check_led_current(g) == []
|
||||
|
||||
|
||||
def test_unknown_rail_no_error():
|
||||
# Anode net has no voltage tag and the resistor far net is untagged -> skip.
|
||||
led = _led(
|
||||
{"forward_voltage_green_v": "2.8V", "forward_current_per_channel_a": "13mA"},
|
||||
{"A": "NetD1_A", "G": "NetD1_G"},
|
||||
)
|
||||
r = _res("R1", "100R", {"2": "NetD1_G", "1": "NetQ_D"}, value_ohms=100.0)
|
||||
q = _driver("Q1", {"3": "NetQ_D"})
|
||||
g = _graph(
|
||||
{"D1": led, "R1": r, "Q1": q},
|
||||
{
|
||||
"NetD1_A": (NetType.SIGNAL, None, [("D1", "A")]),
|
||||
"NetD1_G": (NetType.SIGNAL, None, [("D1", "G"), ("R1", "2")]),
|
||||
"NetQ_D": (NetType.SIGNAL, None, [("R1", "1"), ("Q1", "3")]),
|
||||
},
|
||||
)
|
||||
assert check_led_current(g) == []
|
||||
|
||||
|
||||
def test_no_rating_skipped():
|
||||
g = _rgb_graph(_res("R1", "100R", {"2": "NetD1_G", "1": "NetQ_D"}, value_ohms=100.0))
|
||||
# Strip the rating off the LED specs.
|
||||
g.components["D1"].specs.values = {"forward_voltage_green_v": "2.8V"}
|
||||
assert check_led_current(g) == []
|
||||
|
||||
|
||||
def test_parse_resistance():
|
||||
assert _parse_resistance("5.6K") == 5600.0
|
||||
assert _parse_resistance("5K6") == 5600.0
|
||||
assert _parse_resistance("150R") == 150.0
|
||||
assert _parse_resistance("4R7") == 4.7
|
||||
assert _parse_resistance("1M") == 1_000_000.0
|
||||
assert _parse_resistance("0") == 0.0
|
||||
assert _parse_resistance("100") == 100.0
|
||||
@@ -0,0 +1,113 @@
|
||||
"""PADS-PCB netlist parser — section-marker robustness.
|
||||
|
||||
Regression coverage for EasyEDA Pro exports, which decorate section headers
|
||||
with trailing labels (``*PART* ITEMS``) and append a ``*MISC*
|
||||
ATTRIBUTE VALUES`` block after the connectivity. Earlier versions did
|
||||
exact-string matching on section markers, so:
|
||||
|
||||
1. The decorated ``*PART*`` header was not recognised — no real parts
|
||||
were collected.
|
||||
2. The unrecognised ``*MISC*`` block leaked into the net section, where
|
||||
``"Datasheet" https://...`` and ``"Footprint" C0603_L1.6-W0.8-H0.8``
|
||||
tokens were misparsed as ``ref.pin`` pin connections, polluting the
|
||||
graph with phantom components.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.parsers import parse_netlist
|
||||
|
||||
|
||||
EASYEDA_PRO_NETLIST = """\
|
||||
!PADS-POWERPCB-V9.0-MILS-CP936! Created by EasyEDA Pro V2.2.47.7
|
||||
*REMARK* Smart Gas Cap_1 -- 2026-04-25 14:52:03
|
||||
*REMARK*
|
||||
|
||||
*PART* ITEMS
|
||||
U1 RP2040@LQFN-56_L7.0-W7.0-P0.4-EP
|
||||
C1 CAI0603X7R105K250JT@C0603
|
||||
R1 FRC0603J105 TS@R0603
|
||||
*NET*
|
||||
*SIGNAL* GND
|
||||
U1.19 C1.1 R1.1
|
||||
*SIGNAL* +3V3
|
||||
U1.1 C1.2 R1.2
|
||||
|
||||
*MISC* MISCELLANEOUS PARAMETERS
|
||||
|
||||
ATTRIBUTE VALUES
|
||||
{
|
||||
PART U1
|
||||
{
|
||||
"Manufacturer Part" RP2040
|
||||
"Datasheet" https://item.szlcsc.com/datasheet/RP2040/2392.html
|
||||
"Footprint" LQFN-56_L7.0-W7.0-P0.4-EP
|
||||
"3D Model Title" LQFN-56_L7.0-W7.0-P0.4-EP
|
||||
"Description" Voltage Range:2.7V~3.6V Current:100mA
|
||||
}
|
||||
PART C1
|
||||
{
|
||||
"Datasheet" https://item.szlcsc.com/datasheet/CAI0603X7R105K250JT/51675802.html
|
||||
"3D Model Title" C0603_L1.6-W0.8-H0.8
|
||||
}
|
||||
}
|
||||
*END* OF ASCII OUTPUT FILE
|
||||
"""
|
||||
|
||||
|
||||
def test_easyeda_pro_misc_section_does_not_pollute_nets(tmp_path):
|
||||
"""*MISC* attribute block must not leak into net connectivity."""
|
||||
netlist_path = tmp_path / "netlist.asc"
|
||||
netlist_path.write_text(EASYEDA_PRO_NETLIST)
|
||||
|
||||
known_refs = {"U1", "C1", "R1"}
|
||||
parts, nets = parse_netlist(netlist_path, known_refs=known_refs)
|
||||
|
||||
assert set(parts.keys()) == {"U1", "C1", "R1"}, (
|
||||
f"phantom parts leaked from *MISC* block: "
|
||||
f"{set(parts.keys()) - {'U1', 'C1', 'R1'}}"
|
||||
)
|
||||
assert set(nets.keys()) == {"GND", "+3V3"}
|
||||
|
||||
all_refs = {ref for pin_list in nets.values() for ref, _ in pin_list}
|
||||
assert all_refs <= known_refs, (
|
||||
f"phantom refs in nets from *MISC* misparse: {all_refs - known_refs}"
|
||||
)
|
||||
|
||||
|
||||
def test_decorated_part_header_is_recognised(tmp_path):
|
||||
"""``*PART* ITEMS`` (with trailing label) must enter the part section."""
|
||||
netlist_path = tmp_path / "netlist.asc"
|
||||
netlist_path.write_text(
|
||||
"*PART* ITEMS\n"
|
||||
"U1 RP2040@LQFN-56\n"
|
||||
"C1 CAP@C0603\n"
|
||||
"*NET*\n"
|
||||
"*SIGNAL* GND\n"
|
||||
"U1.19 C1.1\n"
|
||||
"*END*\n"
|
||||
)
|
||||
|
||||
parts, nets = parse_netlist(netlist_path)
|
||||
|
||||
assert parts == {"U1": "RP2040@LQFN-56", "C1": "CAP@C0603"}
|
||||
assert nets == {"GND": [("U1", "19"), ("C1", "1")]}
|
||||
|
||||
|
||||
def test_part_section_without_trailing_label_still_parses(tmp_path):
|
||||
"""Plain ``*PART*`` header (no trailing label) must still be recognised."""
|
||||
netlist_path = tmp_path / "netlist.asc"
|
||||
netlist_path.write_text(
|
||||
"*PADS-PCB*\n"
|
||||
"*PART*\n"
|
||||
"U1 RP2040@LQFN-56\n"
|
||||
"*NET*\n"
|
||||
"*SIGNAL* GND\n"
|
||||
"U1.1\n"
|
||||
"*END*\n"
|
||||
)
|
||||
|
||||
parts, nets = parse_netlist(netlist_path)
|
||||
|
||||
assert parts == {"U1": "RP2040@LQFN-56"}
|
||||
assert nets == {"GND": [("U1", "1")]}
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Per-IC normalize pass — schema + validator behavior.
|
||||
|
||||
Locks in the rules added after the staging-project audit:
|
||||
|
||||
1. The reviewer's `status` is shown to the normalize LLM, which may only
|
||||
re-grade *downward*: a deterministic clamp forbids raising any finding
|
||||
above the reviewer's calibrated severity, caps `Unverified:` findings
|
||||
at WARNING, and preserves the `Unverified:` prefix. (This is the fix
|
||||
for the U2-001 false positive, where normalize laundered a hedged
|
||||
WARNING into a confident ERROR.)
|
||||
2. Self-cancelling findings can be dropped via a `dropped` array
|
||||
(index + reason) and are then removed from the report entirely.
|
||||
3. Merges (`len(merged_from) > 1`) require a non-empty `single_fix`
|
||||
describing the one atomic component/net change that resolves all
|
||||
members; missing `single_fix` un-merges back to per-index originals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.services.normalize_findings import (
|
||||
SUBMIT_NORMALIZED_SCHEMA,
|
||||
_build_normalized,
|
||||
_serialize_findings_for_prompt,
|
||||
)
|
||||
|
||||
|
||||
def _f(idx: int, status: str = "WARNING", why: str = "w") -> Finding:
|
||||
return Finding(
|
||||
designator="U3",
|
||||
mpn="X",
|
||||
finding=f"finding {idx}",
|
||||
why=why,
|
||||
status=status,
|
||||
recommendation="",
|
||||
source_page=idx,
|
||||
source_quote="",
|
||||
reference="",
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_for_prompt_shows_reviewer_severity():
|
||||
"""Normalize IS shown the reviewer's severity so it can re-grade
|
||||
downward from it — the deterministic clamp enforces downgrade-only."""
|
||||
findings = [_f(1, status="ERROR"), _f(2, status="INFO")]
|
||||
out = _serialize_findings_for_prompt(findings)
|
||||
parsed = json.loads(out)
|
||||
assert [row["reviewer_severity"] for row in parsed] == ["ERROR", "INFO"]
|
||||
|
||||
|
||||
def test_schema_exposes_dropped_array_and_single_fix_field():
|
||||
props = SUBMIT_NORMALIZED_SCHEMA.input_schema["properties"]
|
||||
assert "dropped" in props
|
||||
dropped_item = props["dropped"]["items"]
|
||||
assert dropped_item["required"] == ["index", "reason"]
|
||||
|
||||
finding_props = props["findings"]["items"]["properties"]
|
||||
assert "single_fix" in finding_props
|
||||
|
||||
|
||||
def test_drop_self_cancelling_finding_removes_from_kept_list():
|
||||
originals = [_f(1), _f(2, why="satisfies the spec via C24")]
|
||||
raw_findings = [{
|
||||
"merged_from": [1], "finding": "f1", "why": "w",
|
||||
"status": "WARNING", "recommendation": "", "change_rationale": "unchanged",
|
||||
}]
|
||||
raw_dropped = [{"index": 2, "reason": "self-cancelling: C24 satisfies spec"}]
|
||||
built = _build_normalized(raw_findings, raw_dropped, originals)
|
||||
assert built is not None
|
||||
kept, dropped = built
|
||||
assert len(kept) == 1
|
||||
assert kept[0].finding == "f1"
|
||||
assert len(dropped) == 1
|
||||
assert dropped[0]["index"] == 2
|
||||
assert "C24" in dropped[0]["reason"]
|
||||
# Original finding is preserved in the dropped record for forensics.
|
||||
assert dropped[0]["original_finding"]["finding"] == "finding 2"
|
||||
|
||||
|
||||
def test_drop_with_empty_reason_is_rejected():
|
||||
originals = [_f(1), _f(2)]
|
||||
raw_findings = [{
|
||||
"merged_from": [1], "finding": "f1", "why": "w",
|
||||
"status": "WARNING", "recommendation": "", "change_rationale": "unchanged",
|
||||
}]
|
||||
raw_dropped = [{"index": 2, "reason": ""}]
|
||||
assert _build_normalized(raw_findings, raw_dropped, originals) is None
|
||||
|
||||
|
||||
def test_drop_and_keep_cannot_cover_same_index():
|
||||
"""Double-coverage (drop + keep both name index 1) must be rejected."""
|
||||
originals = [_f(1), _f(2)]
|
||||
raw_findings = [
|
||||
{"merged_from": [1], "finding": "f1", "why": "w", "status": "INFO",
|
||||
"recommendation": "", "change_rationale": "unchanged"},
|
||||
{"merged_from": [2], "finding": "f2", "why": "w", "status": "INFO",
|
||||
"recommendation": "", "change_rationale": "unchanged"},
|
||||
]
|
||||
raw_dropped = [{"index": 1, "reason": "shouldn't also be kept"}]
|
||||
assert _build_normalized(raw_findings, raw_dropped, originals) is None
|
||||
|
||||
|
||||
def test_coverage_gap_is_rejected():
|
||||
"""Every original index must end up somewhere (kept, merged, or dropped)."""
|
||||
originals = [_f(1), _f(2)]
|
||||
raw_findings = [{
|
||||
"merged_from": [1], "finding": "f1", "why": "w",
|
||||
"status": "INFO", "recommendation": "", "change_rationale": "unchanged",
|
||||
}]
|
||||
# Index 2 is uncovered.
|
||||
assert _build_normalized(raw_findings, [], originals) is None
|
||||
|
||||
|
||||
def test_merge_without_single_fix_unmerges_to_originals():
|
||||
"""A merge whose model omitted `single_fix` is not valid — break it
|
||||
apart and surface the per-index originals (severity preserved)."""
|
||||
originals = [_f(1, status="WARNING"), _f(2, status="INFO")]
|
||||
raw_findings = [{
|
||||
"merged_from": [1, 2],
|
||||
"finding": "merged into ERROR",
|
||||
"why": "combined harm",
|
||||
"status": "ERROR",
|
||||
"recommendation": "",
|
||||
"change_rationale": "merged",
|
||||
# single_fix intentionally omitted
|
||||
}]
|
||||
built = _build_normalized(raw_findings, [], originals)
|
||||
assert built is not None
|
||||
kept, dropped = built
|
||||
assert len(dropped) == 0
|
||||
assert len(kept) == 2
|
||||
# Originals are preserved verbatim — severity not laundered up.
|
||||
assert kept[0].status == "WARNING"
|
||||
assert kept[1].status == "INFO"
|
||||
|
||||
|
||||
def test_merge_severity_clamped_to_strongest_member():
|
||||
"""A merge cannot exceed the highest original severity among members.
|
||||
Merging WARNING + INFO and asking for ERROR clamps to WARNING."""
|
||||
originals = [_f(1, status="WARNING"), _f(2, status="INFO")]
|
||||
raw_findings = [{
|
||||
"merged_from": [1, 2],
|
||||
"finding": "single root cause",
|
||||
"why": "combined harm",
|
||||
"status": "ERROR", # over-graded — must clamp to WARNING
|
||||
"recommendation": "remove R1",
|
||||
"change_rationale": "merged (atomic)",
|
||||
"single_fix": "remove R1 from the VIN path",
|
||||
}]
|
||||
built = _build_normalized(raw_findings, [], originals)
|
||||
assert built is not None
|
||||
kept, _ = built
|
||||
assert len(kept) == 1
|
||||
assert kept[0].status == "WARNING" # clamped down from the proposed ERROR
|
||||
assert kept[0].finding == "single root cause"
|
||||
|
||||
|
||||
def test_merge_keeps_error_when_a_member_was_error():
|
||||
"""The clamp is a ceiling, not a cap-to-WARNING: an ERROR member lets
|
||||
the merged finding stay ERROR."""
|
||||
originals = [_f(1, status="ERROR"), _f(2, status="WARNING")]
|
||||
raw_findings = [{
|
||||
"merged_from": [1, 2],
|
||||
"finding": "single root cause",
|
||||
"why": "combined harm",
|
||||
"status": "ERROR",
|
||||
"recommendation": "fix",
|
||||
"change_rationale": "merged",
|
||||
"single_fix": "rewire X to Z",
|
||||
}]
|
||||
built = _build_normalized(raw_findings, [], originals)
|
||||
assert built is not None
|
||||
kept, _ = built
|
||||
assert kept[0].status == "ERROR"
|
||||
|
||||
|
||||
def test_normalize_cannot_upgrade_single_finding():
|
||||
"""The U2-001 bug: reviewer graded WARNING, normalize must not promote
|
||||
it to ERROR even on a passthrough (len-1 group)."""
|
||||
originals = [_f(1, status="WARNING")]
|
||||
raw_findings = [{
|
||||
"merged_from": [1],
|
||||
"finding": "f1",
|
||||
"why": "w",
|
||||
"status": "ERROR", # attempted upgrade
|
||||
"recommendation": "",
|
||||
"change_rationale": "graded ERROR per rubric",
|
||||
}]
|
||||
built = _build_normalized(raw_findings, [], originals)
|
||||
assert built is not None
|
||||
kept, _ = built
|
||||
assert kept[0].status == "WARNING" # upgrade rejected
|
||||
|
||||
|
||||
def test_unverified_finding_capped_at_warning_and_prefix_preserved():
|
||||
"""A finding whose `why` starts with 'Unverified:' can never be ERROR,
|
||||
and the prefix survives even if the model rewrote `why` without it."""
|
||||
originals = [_f(1, status="WARNING", why="Unverified: abs-max for PA14 not confirmed")]
|
||||
raw_findings = [{
|
||||
"merged_from": [1],
|
||||
"finding": "PA14 overvoltage",
|
||||
"why": "The 5V output exceeds the 3.6V abs-max and will damage the MCU",
|
||||
"status": "ERROR", # confident upgrade + dropped the Unverified prefix
|
||||
"recommendation": "level shift",
|
||||
"change_rationale": "graded ERROR",
|
||||
}]
|
||||
built = _build_normalized(raw_findings, [], originals)
|
||||
assert built is not None
|
||||
kept, _ = built
|
||||
assert kept[0].status == "WARNING"
|
||||
assert kept[0].why.lower().startswith("unverified:")
|
||||
|
||||
|
||||
def test_unchanged_passthrough_with_single_index_does_not_require_single_fix():
|
||||
"""`single_fix` is only required for true merges (len > 1)."""
|
||||
originals = [_f(1, status="ERROR")]
|
||||
raw_findings = [{
|
||||
"merged_from": [1],
|
||||
"finding": "passthrough",
|
||||
"why": "w",
|
||||
"status": "WARNING", # normalize re-graded down
|
||||
"recommendation": "",
|
||||
"change_rationale": "downgraded per rubric",
|
||||
}]
|
||||
built = _build_normalized(raw_findings, [], originals)
|
||||
assert built is not None
|
||||
kept, _ = built
|
||||
assert len(kept) == 1
|
||||
assert kept[0].status == "WARNING"
|
||||
|
||||
|
||||
def test_all_findings_dropped_is_valid():
|
||||
"""An IC where every finding was self-cancelling produces an empty
|
||||
report — that is a valid outcome, not a coverage failure."""
|
||||
originals = [_f(1, why="X satisfies spec"), _f(2, why="Y is in the right place")]
|
||||
raw_findings = []
|
||||
raw_dropped = [
|
||||
{"index": 1, "reason": "X meets spec"},
|
||||
{"index": 2, "reason": "Y is the input cap"},
|
||||
]
|
||||
built = _build_normalized(raw_findings, raw_dropped, originals)
|
||||
assert built is not None
|
||||
kept, dropped = built
|
||||
assert kept == []
|
||||
assert len(dropped) == 2
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Pin-mux feasibility check — net-asserted peripheral function vs. the pin's
|
||||
silicon alternate-function table.
|
||||
|
||||
Locks in:
|
||||
1. A net asserting a function the pin can't be muxed to (UART5_TX on an RX-only
|
||||
pin) is a hard ERROR.
|
||||
2. A correct assignment produces nothing.
|
||||
3. DIRECTION is never flagged: an inter-device same-peripheral link (crossover /
|
||||
transceiver) is skipped, not flagged.
|
||||
4. Empty functions / opaque nets are skipped.
|
||||
5. Deterministic findings carry source="pin_mux_check" and never source_page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
ValidationReport,
|
||||
)
|
||||
from backend.pinscopex.pin_function_tokens import normalize_functions, parse_net_token
|
||||
from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility
|
||||
|
||||
|
||||
def _constraints(mpn, pintable):
|
||||
return ComponentConstraints(mpn=mpn, pintable=pintable,
|
||||
absolute_maximum_ratings=[], rules=[])
|
||||
|
||||
|
||||
def _ic(ref, mpn, pins):
|
||||
return Component(reference=ref, value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins)
|
||||
|
||||
|
||||
def _graph(components, nets):
|
||||
"""nets: {net_name: [(ref, pin_num), ...]}"""
|
||||
net_objs = {
|
||||
name: Net(name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns])
|
||||
for name, conns in nets.items()
|
||||
}
|
||||
return DesignGraph(components=components, nets=net_objs)
|
||||
|
||||
|
||||
# STM32-style: PD2 (pin 54) does UART5_RX only; PC12 (pin 53) does UART5_TX only.
|
||||
_PD2 = Pin(number=54, name="PD2", functions=["TIM3_ETR", "UART5_RX", "EVENTOUT"])
|
||||
_PC12 = Pin(number=53, name="PC12", functions=["SPI3_MOSI/I2S3_SDO", "UART5_TX"])
|
||||
|
||||
|
||||
def test_real_defect_uart5_swapped_is_error():
|
||||
# Net labels assert TX on the RX-only pin and RX on the TX-only pin.
|
||||
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX", "53": "MCU-UART5-RX"})
|
||||
g = _graph({"U3": u3},
|
||||
{"MCU-UART5-TX": [("U3", 54)], "MCU-UART5-RX": [("U3", 53)]})
|
||||
cmap = {"MCUX": _constraints("MCUX", [_PD2, _PC12])}
|
||||
|
||||
findings = check_pin_mux_feasibility(g, cmap)
|
||||
assert len(findings) == 2
|
||||
assert all(f.status == "ERROR" for f in findings)
|
||||
assert all(f.source == "pin_mux_check" for f in findings)
|
||||
assert all(f.source_page is None for f in findings)
|
||||
assert {f.designator for f in findings} == {"U3"}
|
||||
tx = next(f for f in findings if "MCU-UART5-TX" in f.finding)
|
||||
assert "cannot be muxed as UART5_TX" in tx.finding
|
||||
|
||||
|
||||
def test_correct_assignment_no_finding():
|
||||
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-RX", "53": "MCU-UART5-TX"})
|
||||
g = _graph({"U3": u3},
|
||||
{"MCU-UART5-RX": [("U3", 54)], "MCU-UART5-TX": [("U3", 53)]})
|
||||
cmap = {"MCUX": _constraints("MCUX", [_PD2, _PC12])}
|
||||
assert check_pin_mux_feasibility(g, cmap) == []
|
||||
|
||||
|
||||
def test_inter_device_same_peripheral_link_is_skipped():
|
||||
# A correct crossover: the net named from U3's TX perspective also lands on a
|
||||
# peer IC pin that exposes UART5. Direction is the reviewer's call -> skip.
|
||||
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX"})
|
||||
peer = _ic("U7", "PEER", {"5": "MCU-UART5-TX"})
|
||||
g = _graph({"U3": u3, "U7": peer},
|
||||
{"MCU-UART5-TX": [("U3", 54), ("U7", 5)]})
|
||||
cmap = {
|
||||
"MCUX": _constraints("MCUX", [_PD2]),
|
||||
"PEER": _constraints("PEER", [Pin(number=5, name="RXD", functions=["UART5_TX"])]),
|
||||
}
|
||||
assert check_pin_mux_feasibility(g, cmap) == []
|
||||
|
||||
|
||||
def test_transceiver_peer_without_peripheral_still_fires():
|
||||
# Peer pin is a transceiver "DI" with no UART peripheral -> gate does NOT
|
||||
# apply; the MCU pin is still genuinely infeasible -> ERROR.
|
||||
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX"})
|
||||
xcvr = _ic("U9", "XCVR", {"1": "MCU-UART5-TX"})
|
||||
g = _graph({"U3": u3, "U9": xcvr},
|
||||
{"MCU-UART5-TX": [("U3", 54), ("U9", 1)]})
|
||||
cmap = {
|
||||
"MCUX": _constraints("MCUX", [_PD2]),
|
||||
"XCVR": _constraints("XCVR", [Pin(number=1, name="DI", functions=["DI"])]),
|
||||
}
|
||||
findings = check_pin_mux_feasibility(g, cmap)
|
||||
assert len(findings) == 1 and findings[0].status == "ERROR"
|
||||
|
||||
|
||||
def test_empty_functions_skipped():
|
||||
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX"})
|
||||
g = _graph({"U3": u3}, {"MCU-UART5-TX": [("U3", 54)]})
|
||||
cmap = {"MCUX": _constraints("MCUX", [Pin(number=54, name="PD2", functions=None)])}
|
||||
assert check_pin_mux_feasibility(g, cmap) == []
|
||||
|
||||
|
||||
def test_pin_exposes_peripheral_but_not_signal_no_complement():
|
||||
# Net asserts I2C1_SDA on a pin that exposes I2C1 only as SCL -> infeasible.
|
||||
u3 = _ic("U3", "MCUX", {"20": "I2C1-SDA-3V3"})
|
||||
g = _graph({"U3": u3}, {"I2C1-SDA-3V3": [("U3", 20)]})
|
||||
cmap = {"MCUX": _constraints("MCUX", [Pin(number=20, name="PB8", functions=["I2C1_SCL"])])}
|
||||
findings = check_pin_mux_feasibility(g, cmap)
|
||||
assert len(findings) == 1 and findings[0].status == "ERROR"
|
||||
|
||||
|
||||
def test_opaque_net_not_flagged():
|
||||
u3 = _ic("U3", "MCUX", {"54": "NetC7_1"})
|
||||
g = _graph({"U3": u3}, {"NetC7_1": [("U3", 54)]})
|
||||
cmap = {"MCUX": _constraints("MCUX", [_PD2])}
|
||||
assert check_pin_mux_feasibility(g, cmap) == []
|
||||
|
||||
|
||||
def test_token_parser_and_normalizer():
|
||||
assert parse_net_token("MCU-UART5-TX") == ("UART5", "TX")
|
||||
assert parse_net_token("I2C1-SDA-3V3") == ("I2C1", "SDA")
|
||||
assert parse_net_token("/UART0.TX") == ("UART0", "TX")
|
||||
assert parse_net_token("SPI2-CS") == ("SPI2", "NSS") # CS canonicalises to NSS
|
||||
assert parse_net_token("NetC7_1") is None
|
||||
assert parse_net_token("+5V") is None
|
||||
assert ("UART5", "RX") in normalize_functions(["TIM3_ETR", "UART5_RX"])
|
||||
assert normalize_functions(["SPI3_MOSI/I2S3_SDO"]) >= {("SPI3", "MOSI")}
|
||||
|
||||
|
||||
# TI MSPM0-style pintable: modern controller/peripheral SPI nomenclature.
|
||||
# PB17 (pin 36) exposes SPI0 as PICO (== MOSI); PB19 (pin 38) as POCI (== MISO).
|
||||
_PB17 = Pin(number=36, name="PB17", functions=["UART2_TX", "SPI0_PICO", "SPI1_CS1"])
|
||||
_PB19 = Pin(number=38, name="PB19", functions=["SPI0_POCI", "UART0_CTS"])
|
||||
|
||||
|
||||
def test_spi_legacy_net_names_match_modern_pin_functions():
|
||||
# Regression for the U3-001/U3-002 false positives: net labels use legacy
|
||||
# MOSI/MISO, the datasheet uses PICO/POCI — the same physical lines. No
|
||||
# finding: PICO≡MOSI, POCI≡MISO.
|
||||
u3 = _ic("U3", "MSPM0G3507SPTR", {"36": "/SPI0.MOSI", "38": "/SPI0.MISO"})
|
||||
g = _graph({"U3": u3},
|
||||
{"/SPI0.MOSI": [("U3", 36)], "/SPI0.MISO": [("U3", 38)]})
|
||||
cmap = {"MSPM0G3507SPTR": _constraints("MSPM0G3507SPTR", [_PB17, _PB19])}
|
||||
assert check_pin_mux_feasibility(g, cmap) == []
|
||||
|
||||
|
||||
def test_spi_controller_peripheral_names_are_synonyms():
|
||||
assert parse_net_token("/SPI0.MOSI") == ("SPI0", "MOSI")
|
||||
assert parse_net_token("/SPI0.PICO") == ("SPI0", "MOSI")
|
||||
assert parse_net_token("SPI0-COPI") == ("SPI0", "MOSI")
|
||||
assert parse_net_token("/SPI0.MISO") == ("SPI0", "MISO")
|
||||
assert parse_net_token("/SPI0.POCI") == ("SPI0", "MISO")
|
||||
assert parse_net_token("SPI0-CIPO") == ("SPI0", "MISO")
|
||||
# Datasheet function strings collapse to the same canonical tokens.
|
||||
assert normalize_functions(["SPI0_PICO"]) == {("SPI0", "MOSI")}
|
||||
assert normalize_functions(["SPI0_POCI"]) == {("SPI0", "MISO")}
|
||||
# Indexed chip-select variants canonicalise to NSS.
|
||||
assert normalize_functions(["SPI0_CS0", "SPI1_CS3"]) == {
|
||||
("SPI0", "NSS"), ("SPI1", "NSS")}
|
||||
assert normalize_functions(["SPI0_STE0"]) == {("SPI0", "NSS")}
|
||||
|
||||
|
||||
def test_spi_genuine_infeasibility_still_fires_with_modern_names():
|
||||
# Net asserts SPI0_MOSI on a pin that exposes SPI0 only as POCI (==MISO) —
|
||||
# genuinely infeasible even after synonym collapse -> ERROR.
|
||||
u3 = _ic("U3", "MSPM0G3507SPTR", {"38": "/SPI0.MOSI"})
|
||||
g = _graph({"U3": u3}, {"/SPI0.MOSI": [("U3", 38)]})
|
||||
cmap = {"MSPM0G3507SPTR": _constraints("MSPM0G3507SPTR", [_PB19])}
|
||||
findings = check_pin_mux_feasibility(g, cmap)
|
||||
assert len(findings) == 1 and findings[0].status == "ERROR"
|
||||
# POCI==MISO is the complement of MOSI -> phrased as a likely swap.
|
||||
assert "swapped" in findings[0].why
|
||||
|
||||
|
||||
def test_finding_prints_full_raw_capability_list_and_intent_caveat():
|
||||
# Net asserts I2C1_SDA on a pin that exposes I2C1 only as SCL -> infeasible.
|
||||
# The finding's `why` must (a) print the pin's full raw alternate-function
|
||||
# list verbatim, and (b) state the intent was inferred from the net name.
|
||||
pin = Pin(number=20, name="PB8", functions=["I2C1_SCL", "TIMA0_C1", "UART1_RX"])
|
||||
u3 = _ic("U3", "MCUX", {"20": "I2C1-SDA-3V3"})
|
||||
g = _graph({"U3": u3}, {"I2C1-SDA-3V3": [("U3", 20)]})
|
||||
cmap = {"MCUX": _constraints("MCUX", [pin])}
|
||||
f = check_pin_mux_feasibility(g, cmap)[0]
|
||||
# (a) every raw datasheet function string appears verbatim in `why`.
|
||||
for fn in ("I2C1_SCL", "TIMA0_C1", "UART1_RX"):
|
||||
assert fn in f.why
|
||||
# (b) the inferred-from-net-name caveat is present.
|
||||
assert "inferred from the net name" in f.why
|
||||
|
||||
|
||||
def test_legacy_report_without_source_validates():
|
||||
# Backward-compat: a report.json from before these fields existed.
|
||||
legacy = {
|
||||
"finding_id": "U1-001", "designator": "U1", "mpn": "X",
|
||||
"finding": "f", "why": "w", "source_page": 3, "status": "WARNING",
|
||||
}
|
||||
f = Finding.model_validate(legacy)
|
||||
assert f.source is None
|
||||
rep = ValidationReport.model_validate({
|
||||
"project": "p", "timestamp": "t", "findings": [legacy],
|
||||
"summary": {"total": 1}, "coverage": {}, "review_errors": {},
|
||||
})
|
||||
assert rep.not_reviewed == []
|
||||
@@ -0,0 +1,766 @@
|
||||
"""Tests for the LCSC → MPN resolver wired into the BOM-parse stage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_is_lcsc_code():
|
||||
from backend.services.purple_parts import is_lcsc_code
|
||||
|
||||
assert is_lcsc_code("C12345") is True
|
||||
assert is_lcsc_code("c123") is True
|
||||
assert is_lcsc_code("C0") is True
|
||||
assert is_lcsc_code("TPS62840DLCR") is False
|
||||
assert is_lcsc_code("C") is False
|
||||
assert is_lcsc_code("C123abc") is False
|
||||
assert is_lcsc_code("") is False
|
||||
assert is_lcsc_code(None) is False
|
||||
assert is_lcsc_code(" C12345 ") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_codes_no_op_when_disabled(monkeypatch):
|
||||
"""When purple-parts isn't configured, the helper must not touch the BOM."""
|
||||
from backend.config import settings
|
||||
from backend.services.pipeline import _resolve_lcsc_codes
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "")
|
||||
|
||||
bom = {
|
||||
"U1": {"value": "", "footprint": "", "mpn": None, "lcsc": "C12345"},
|
||||
"C1": {"value": "10uF", "footprint": "0603", "mpn": "C25804", "lcsc": None},
|
||||
}
|
||||
snapshot = {k: dict(v) for k, v in bom.items()}
|
||||
|
||||
await _resolve_lcsc_codes(ctx=None, bom=bom)
|
||||
|
||||
assert bom == snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_codes_fills_mpn_from_lcsc_column(monkeypatch):
|
||||
"""LCSC code in the dedicated `lcsc` column populates the empty `mpn`."""
|
||||
from backend.config import settings
|
||||
from backend.services import pipeline, purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
async def fake_lookup(codes):
|
||||
assert sorted(codes) == ["C12345"]
|
||||
return {"C12345": {"lcsc": "C12345", "mpn": "TPS62840DLCR", "manufacturer": "TI"}}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
|
||||
class _Ctx:
|
||||
project_id = "p1"
|
||||
lcsc_data: dict = {}
|
||||
|
||||
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
|
||||
|
||||
bom = {
|
||||
"U1": {"value": "", "footprint": "LQFP-48", "mpn": None, "lcsc": "C12345"},
|
||||
}
|
||||
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
|
||||
|
||||
assert bom["U1"]["mpn"] == "TPS62840DLCR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_codes_pipeline_backstop_is_lcsc_column_only(monkeypatch):
|
||||
"""Pipeline-stage resolver is a backstop for the unambiguous case only:
|
||||
LCSC column populated AND MPN slot empty. An LCSC-shaped value sitting
|
||||
in the MPN slot is NOT resolved here — column-level upload-time
|
||||
resolution is the primary path."""
|
||||
from backend.config import settings
|
||||
from backend.services import pipeline, purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
called = False
|
||||
|
||||
async def fake_lookup(codes):
|
||||
nonlocal called
|
||||
called = True
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
|
||||
|
||||
class _Ctx:
|
||||
project_id = "p1"
|
||||
lcsc_data: dict = {}
|
||||
|
||||
bom = {
|
||||
# MPN field has an LCSC-shaped value — pipeline backstop must NOT touch this.
|
||||
"U2": {"value": "", "footprint": "", "mpn": "C2040", "lcsc": None},
|
||||
# A real MPN starting with C must NOT be touched.
|
||||
"C1": {"value": "10uF", "footprint": "0402", "mpn": "CL05B104KO5NNNC", "lcsc": None},
|
||||
}
|
||||
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
|
||||
|
||||
assert called is False
|
||||
assert bom["U2"]["mpn"] == "C2040"
|
||||
assert bom["C1"]["mpn"] == "CL05B104KO5NNNC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_codes_leaves_unresolved_alone(monkeypatch):
|
||||
"""A miss in purple-parts must not blank out the existing MPN/LCSC."""
|
||||
from backend.config import settings
|
||||
from backend.services import pipeline, purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
async def fake_lookup(codes):
|
||||
return {"C99999999": None}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
|
||||
|
||||
class _Ctx:
|
||||
project_id = "p1"
|
||||
lcsc_data: dict = {}
|
||||
|
||||
bom = {
|
||||
"U3": {"value": "", "footprint": "", "mpn": None, "lcsc": "C99999999"},
|
||||
}
|
||||
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
|
||||
|
||||
assert bom["U3"]["mpn"] is None # unchanged
|
||||
assert bom["U3"]["lcsc"] == "C99999999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_codes_caches_payload_for_passive_stage(monkeypatch):
|
||||
"""The resolver must stash the full LCSC payload on ctx.lcsc_data so the
|
||||
downstream passive extraction can use the description/category without a
|
||||
second purple-parts call."""
|
||||
from backend.config import settings
|
||||
from backend.services import pipeline, purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
payload = {
|
||||
"lcsc": "C15850",
|
||||
"mpn": "CL21A106KAYNNNE",
|
||||
"manufacturer": "Samsung",
|
||||
"package": "0805",
|
||||
"description": "10uF 25V X5R ±10% 0805 MLCC",
|
||||
"category": "Capacitors",
|
||||
"subcategory": "MLCC - SMD/SMT",
|
||||
}
|
||||
|
||||
async def fake_lookup(codes):
|
||||
return {"C15850": payload}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
|
||||
|
||||
class _Ctx:
|
||||
project_id = "p1"
|
||||
lcsc_data: dict = {}
|
||||
|
||||
ctx = _Ctx()
|
||||
bom = {
|
||||
"C1": {"value": "10uF", "footprint": "0805", "mpn": None, "lcsc": "C15850"},
|
||||
"C2": {"value": "10uF", "footprint": "0805", "mpn": None, "lcsc": "C15850"},
|
||||
}
|
||||
await pipeline._resolve_lcsc_codes(ctx, bom)
|
||||
|
||||
assert ctx.lcsc_data == {"CL21A106KAYNNNE": payload}
|
||||
assert bom["C1"]["mpn"] == "CL21A106KAYNNNE"
|
||||
assert bom["C2"]["mpn"] == "CL21A106KAYNNNE"
|
||||
|
||||
|
||||
def test_detect_lcsc_column_all_c_codes():
|
||||
from backend.services.purple_parts import detect_lcsc_column
|
||||
|
||||
csv = b"Reference,Manufacturer Part Number,Value\nU1,C12044,\nU2,C2040,\nU3,C25804,\n"
|
||||
assert detect_lcsc_column(csv, "Manufacturer Part Number") is True
|
||||
|
||||
|
||||
def test_detect_lcsc_column_mixed_returns_false():
|
||||
from backend.services.purple_parts import detect_lcsc_column
|
||||
|
||||
csv = (b"Reference,Manufacturer Part Number\n"
|
||||
b"U1,C12044\n"
|
||||
b"U2,TPS62840DLCR\n")
|
||||
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
|
||||
|
||||
|
||||
def test_detect_lcsc_column_empty_returns_false():
|
||||
from backend.services.purple_parts import detect_lcsc_column
|
||||
|
||||
csv = b"Reference,Manufacturer Part Number\nU1,\nU2,\n"
|
||||
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
|
||||
|
||||
|
||||
def test_detect_lcsc_column_missing_column():
|
||||
from backend.services.purple_parts import detect_lcsc_column
|
||||
|
||||
csv = b"Reference,Value\nU1,10uF\n"
|
||||
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_column_bytes_rewrites_csv(monkeypatch):
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
async def fake_lookup(codes):
|
||||
return {
|
||||
"C12044": {"lcsc": "C12044", "mpn": "STM32F103C8T6"},
|
||||
"C2040": {"lcsc": "C2040", "mpn": "RP2040"},
|
||||
"C99999999": None, # unresolved
|
||||
}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
|
||||
csv_in = (
|
||||
b"Reference,Value,Footprint,Manufacturer Part Number\n"
|
||||
b"U1,STM32,LQFP-48,C12044\n"
|
||||
b"U2,RP2040,QFN-56,C2040\n"
|
||||
b"U3,Mystery,SOT-23,C99999999\n"
|
||||
)
|
||||
out_bytes, updated, lcsc_map, lcsc_payloads = await purple_parts.resolve_lcsc_column_bytes(
|
||||
csv_in, mpn_col="Manufacturer Part Number",
|
||||
)
|
||||
assert updated == 2
|
||||
assert lcsc_map == {"C12044": "STM32F103C8T6", "C2040": "RP2040"}
|
||||
# Full payloads keyed by LCSC id (verbatim from purple-parts) so the wizard
|
||||
# can short-cut DigiKey when synthesising auto_resolve_specs input.
|
||||
assert set(lcsc_payloads.keys()) == {"C12044", "C2040"}
|
||||
assert lcsc_payloads["C12044"]["mpn"] == "STM32F103C8T6"
|
||||
out_text = out_bytes.decode()
|
||||
# Resolved rows updated
|
||||
assert "STM32F103C8T6" in out_text
|
||||
assert "RP2040" in out_text
|
||||
# Unresolved row preserved as-is
|
||||
assert "C99999999" in out_text
|
||||
# Column order and other columns preserved
|
||||
assert out_text.splitlines()[0] == "Reference,Value,Footprint,Manufacturer Part Number"
|
||||
assert "LQFP-48" in out_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_column_bytes_no_op_when_disabled(monkeypatch):
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "")
|
||||
|
||||
csv_in = b"Reference,Manufacturer Part Number\nU1,C12044\n"
|
||||
out_bytes, updated, lcsc_map, lcsc_payloads = await purple_parts.resolve_lcsc_column_bytes(
|
||||
csv_in, mpn_col="Manufacturer Part Number",
|
||||
)
|
||||
assert updated == 0
|
||||
assert lcsc_map == {}
|
||||
assert lcsc_payloads == {}
|
||||
assert out_bytes == csv_in
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_lcsc_codes_skips_rows_with_real_mpn(monkeypatch):
|
||||
"""Rows that already have a real MPN should not be re-resolved."""
|
||||
from backend.config import settings
|
||||
from backend.services import pipeline, purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_lookup(codes):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return {c: None for c in codes}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
|
||||
|
||||
class _Ctx:
|
||||
project_id = "p1"
|
||||
lcsc_data: dict = {}
|
||||
|
||||
bom = {
|
||||
"C1": {"value": "10uF", "footprint": "0603", "mpn": "GRM188R71C104KA01D", "lcsc": "C14663"},
|
||||
}
|
||||
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
|
||||
|
||||
assert call_count == 0 # nothing to resolve
|
||||
assert bom["C1"]["mpn"] == "GRM188R71C104KA01D"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload-time classification + lcsc_payloads caching + /lcsc/resolve-passive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_test_client(tmp_path):
|
||||
"""Build a FastAPI TestClient backed by a fresh LocalStorageBackend.
|
||||
|
||||
Mirrors the production app's middleware stack but defaults the user_id
|
||||
to ``"local"`` (auth disabled) so we can hit endpoints without Clerk.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_upload_bom_classifies_components(tmp_path, monkeypatch):
|
||||
"""Upload-time component classification populates ``component_mpns``
|
||||
for a mixed-type BOM, even without LCSC resolution running."""
|
||||
from backend.config import settings
|
||||
|
||||
# Disable purple-parts so we exercise the no-LCSC path.
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "")
|
||||
|
||||
client = _make_test_client(tmp_path)
|
||||
|
||||
# Create project and upload a mixed BOM.
|
||||
resp = client.post("/api/projects", json={"name": "mixed"})
|
||||
assert resp.status_code == 200
|
||||
project_id = resp.json()["id"]
|
||||
|
||||
csv = (
|
||||
b"Reference,Value,Footprint,Manufacturer Part Number\n"
|
||||
b"U1,STM32,LQFP-48,STM32F103C8T6\n"
|
||||
b"C1,10uF,0603,CL10A106KQ8NNNC\n"
|
||||
b"R1,10k,0603,RC0603FR-0710KL\n"
|
||||
b"D1,Diode,SOD-123,1N4148WS\n"
|
||||
)
|
||||
files = {"file": ("bom.csv", csv, "text/csv")}
|
||||
resp = client.post(
|
||||
f"/api/projects/{project_id}/upload/bom",
|
||||
files=files,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# The classification should land on ProjectMeta.component_mpns.
|
||||
resp = client.get(f"/api/projects/{project_id}")
|
||||
meta = resp.json()
|
||||
assert meta["component_mpns"] is not None
|
||||
assert meta["component_mpns"]["ic"] == ["STM32F103C8T6"]
|
||||
assert sorted(meta["component_mpns"]["passive"]) == sorted(
|
||||
["CL10A106KQ8NNNC", "RC0603FR-0710KL"]
|
||||
)
|
||||
assert meta["component_mpns"]["simple"] == ["1N4148WS"]
|
||||
|
||||
|
||||
def test_upload_bom_populates_lcsc_payloads(tmp_path, monkeypatch):
|
||||
"""When the MPN column is detected as all-LCSC ids, the full purple-parts
|
||||
payloads must be cached on ProjectMeta.lcsc_payloads."""
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
async def fake_lookup(codes):
|
||||
return {
|
||||
"C15850": {
|
||||
"lcsc": "C15850",
|
||||
"mpn": "CL21A106KAYNNNE",
|
||||
"manufacturer": "Samsung",
|
||||
"package": "0805",
|
||||
"description": "10uF 25V X5R 0805 MLCC",
|
||||
"category": "Capacitors",
|
||||
"subcategory": "MLCC - SMD/SMT",
|
||||
},
|
||||
"C14663": {
|
||||
"lcsc": "C14663",
|
||||
"mpn": "GRM188R71C104KA01D",
|
||||
"manufacturer": "Murata",
|
||||
"package": "0603",
|
||||
"description": "0.1uF 16V X7R 0603 MLCC",
|
||||
"category": "Capacitors",
|
||||
"subcategory": "MLCC - SMD/SMT",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
|
||||
client = _make_test_client(tmp_path)
|
||||
resp = client.post("/api/projects", json={"name": "lcsc"})
|
||||
project_id = resp.json()["id"]
|
||||
|
||||
csv = (
|
||||
b"Reference,Value,Footprint,Manufacturer Part Number\n"
|
||||
b"C1,10uF,0805,C15850\n"
|
||||
b"C2,0.1uF,0603,C14663\n"
|
||||
)
|
||||
resp = client.post(
|
||||
f"/api/projects/{project_id}/upload/bom",
|
||||
files={"file": ("bom.csv", csv, "text/csv")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["lcsc_resolved"] == 2
|
||||
assert body["lcsc_to_mpn"] == {
|
||||
"C15850": "CL21A106KAYNNNE", "C14663": "GRM188R71C104KA01D",
|
||||
}
|
||||
|
||||
meta = client.get(f"/api/projects/{project_id}").json()
|
||||
assert meta["lcsc_payloads"] is not None
|
||||
assert set(meta["lcsc_payloads"].keys()) == {"C15850", "C14663"}
|
||||
p = meta["lcsc_payloads"]["C15850"]
|
||||
assert p["mpn"] == "CL21A106KAYNNNE"
|
||||
assert p["description"].startswith("10uF")
|
||||
# Component classification ran on the resolved BOM, so the MPNs are the
|
||||
# real manufacturer parts (not the LCSC codes).
|
||||
assert sorted(meta["component_mpns"]["passive"]) == sorted(
|
||||
["CL21A106KAYNNNE", "GRM188R71C104KA01D"]
|
||||
)
|
||||
|
||||
|
||||
def test_lcsc_resolve_passive_404_when_lcsc_id_missing(tmp_path, monkeypatch):
|
||||
"""404 when the requested lcsc_id isn't in the project's cached payloads."""
|
||||
from backend.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "")
|
||||
|
||||
client = _make_test_client(tmp_path)
|
||||
resp = client.post("/api/projects", json={"name": "p"})
|
||||
project_id = resp.json()["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/projects/{project_id}/lcsc/resolve-passive",
|
||||
json={"lcsc_id": "C99999999"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch):
|
||||
"""First call resolves via auto_resolve_specs (mocked) and writes the
|
||||
project model + library copy. Second call short-circuits with cached=True
|
||||
and does not invoke auto_resolve_specs again."""
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.models import CapacitorSpecs, ComponentModel
|
||||
from backend.services import purple_parts
|
||||
from backend.services import extraction as extraction_svc
|
||||
|
||||
# Billing modules are absent in the open-source repo; the endpoint under
|
||||
# test only charges when billing is enabled, so skip there.
|
||||
credits_svc = pytest.importorskip("backend.services.credits")
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
payload = {
|
||||
"lcsc": "C15850",
|
||||
"mpn": "CL21A106KAYNNNE",
|
||||
"manufacturer": "Samsung",
|
||||
"package": "0805",
|
||||
"description": "10uF 25V X5R 0805 MLCC",
|
||||
"category": "Capacitors",
|
||||
"subcategory": "MLCC - SMD/SMT",
|
||||
}
|
||||
|
||||
async def fake_lookup(codes):
|
||||
return {"C15850": payload}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def fake_auto_resolve_specs(**kwargs):
|
||||
call_count["n"] += 1
|
||||
# Drop a synthetic api log entry so the credit-charge path runs.
|
||||
api_logger = kwargs.get("api_logger")
|
||||
if api_logger is not None:
|
||||
api_logger.log(
|
||||
stage="auto_resolve", identifier=kwargs["mpn"],
|
||||
model="claude-haiku-4-5-20251001",
|
||||
input_tokens=500, output_tokens=200,
|
||||
cache_creation_input_tokens=0, cache_read_input_tokens=0,
|
||||
duration_ms=100, stop_reason="end_turn", turns=1,
|
||||
)
|
||||
return ComponentModel(
|
||||
mpn=kwargs["mpn"],
|
||||
specs=CapacitorSpecs(
|
||||
value_farads=10e-6,
|
||||
value_formatted="10uF",
|
||||
voltage_rating_v="25V",
|
||||
tolerance="±10%",
|
||||
dielectric="X5R",
|
||||
package="0805",
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(extraction_svc, "auto_resolve_specs", fake_auto_resolve_specs)
|
||||
|
||||
# Need credits to charge against — grant enough.
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
storage = LocalStorageBackend(tmp_path)
|
||||
credits_svc.grant(storage, "local", 100.0, "trial_grant")
|
||||
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app.state.storage = storage
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post("/api/projects", json={"name": "lcsc"})
|
||||
project_id = resp.json()["id"]
|
||||
|
||||
csv = b"Reference,Value,Footprint,Manufacturer Part Number\nC1,10uF,0805,C15850\n"
|
||||
resp = client.post(
|
||||
f"/api/projects/{project_id}/upload/bom",
|
||||
files={"file": ("bom.csv", csv, "text/csv")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# First call: resolves via mocked auto_resolve_specs.
|
||||
resp = client.post(
|
||||
f"/api/projects/{project_id}/lcsc/resolve-passive",
|
||||
json={"lcsc_id": "C15850"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["mpn"] == "CL21A106KAYNNNE"
|
||||
assert body["lcsc_id"] == "C15850"
|
||||
assert body["cached"] is False
|
||||
assert body["model"]["mpn"] == "CL21A106KAYNNNE"
|
||||
assert call_count["n"] == 1
|
||||
|
||||
# Library copy should exist for cross-project reuse.
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
safe = safe_mpn("CL21A106KAYNNNE")
|
||||
assert storage.exists(f"library/passives/{safe}.json")
|
||||
|
||||
# Second call: short-circuits via the project model file.
|
||||
resp = client.post(
|
||||
f"/api/projects/{project_id}/lcsc/resolve-passive",
|
||||
json={"lcsc_id": "C15850"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["cached"] is True
|
||||
assert body["model"]["mpn"] == "CL21A106KAYNNNE"
|
||||
assert call_count["n"] == 1 # not invoked again
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverse MPN → LCSC lookup (by-mpn) + upload-time real-MPN passive enrich
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pick_exact_matches_case_and_space_insensitive():
|
||||
from backend.services.purple_parts import _pick_exact
|
||||
|
||||
cands = [
|
||||
{"mpn": "OTHER-PART", "lcsc": "C1"},
|
||||
{"mpn": "tps62840 dlcr", "lcsc": "C2040"},
|
||||
]
|
||||
hit = _pick_exact("TPS62840DLCR", cands)
|
||||
assert hit is not None
|
||||
assert hit["lcsc"] == "C2040"
|
||||
|
||||
|
||||
def test_pick_exact_rejects_prefix_only():
|
||||
from backend.services.purple_parts import _pick_exact
|
||||
|
||||
# Only a longer family part is returned — not an exact match → reject so it
|
||||
# can't pollute the shared library.
|
||||
cands = [{"mpn": "RC0603FR-0710KLZZ", "lcsc": "C999"}]
|
||||
assert _pick_exact("RC0603FR-0710KL", cands) is None
|
||||
assert _pick_exact("X", []) is None
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""Stand-in for httpx.AsyncClient used by lookup_mpn_batch — serves the
|
||||
POST /v1/parts/by-mpn/batch endpoint from a {mpn: [candidates]} routes map."""
|
||||
|
||||
def __init__(self, routes):
|
||||
self.routes = routes
|
||||
self.posts: list[dict] = [] # captured request bodies
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def post(self, url, headers=None, json=None):
|
||||
body = json or {}
|
||||
self.posts.append(body)
|
||||
mpns = body.get("mpns", [])
|
||||
return _FakeResp({"results": {m: self.routes.get(m, []) for m in mpns}})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_mpn_batch_disabled_is_noop(monkeypatch):
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "")
|
||||
|
||||
out = await purple_parts.lookup_mpn_batch(["TPS62840DLCR"])
|
||||
assert out == {"TPS62840DLCR": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_mpn_batch_exact_dedup_and_miss(monkeypatch):
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
async def fake_token():
|
||||
return "tok"
|
||||
|
||||
monkeypatch.setattr(purple_parts, "_get_identity_token", fake_token)
|
||||
|
||||
routes = {
|
||||
"TPS62840DLCR": [{"lcsc": "C2040", "mpn": "TPS62840DLCR", "description": "buck"}],
|
||||
"RC0603FR-0710KL": [
|
||||
{"lcsc": "C999", "mpn": "RC0603FR-0710KLZZ", "description": "prefix only"}
|
||||
],
|
||||
"MISSING1": [],
|
||||
}
|
||||
fake = _FakeAsyncClient(routes)
|
||||
monkeypatch.setattr(purple_parts.httpx, "AsyncClient", lambda *a, **kw: fake)
|
||||
|
||||
out = await purple_parts.lookup_mpn_batch(
|
||||
["TPS62840DLCR", "TPS62840DLCR", "RC0603FR-0710KL", "MISSING1"]
|
||||
)
|
||||
|
||||
assert out["TPS62840DLCR"]["lcsc"] == "C2040" # exact hit kept
|
||||
assert out["RC0603FR-0710KL"] is None # prefix-only rejected
|
||||
assert out["MISSING1"] is None # real miss
|
||||
# Deduped into a single batch POST; TPS appears once in the request body.
|
||||
assert len(fake.posts) == 1
|
||||
assert fake.posts[0]["mpns"].count("TPS62840DLCR") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_mpn_batch_no_token_returns_all_none(monkeypatch):
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
async def no_token():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(purple_parts, "_get_identity_token", no_token)
|
||||
|
||||
out = await purple_parts.lookup_mpn_batch(["TPS62840DLCR", "RP2040"])
|
||||
assert out == {"TPS62840DLCR": None, "RP2040": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_mpn_batch_chunks_large_input(monkeypatch):
|
||||
"""More than _BATCH_SIZE unique MPNs are split across multiple POSTs."""
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
monkeypatch.setattr(purple_parts, "_BATCH_SIZE", 2)
|
||||
|
||||
async def fake_token():
|
||||
return "tok"
|
||||
|
||||
monkeypatch.setattr(purple_parts, "_get_identity_token", fake_token)
|
||||
|
||||
routes = {m: [{"lcsc": f"C{i}", "mpn": m}] for i, m in enumerate(["A", "B", "C"])}
|
||||
fake = _FakeAsyncClient(routes)
|
||||
monkeypatch.setattr(purple_parts.httpx, "AsyncClient", lambda *a, **kw: fake)
|
||||
|
||||
out = await purple_parts.lookup_mpn_batch(["A", "B", "C"])
|
||||
|
||||
assert {k: v["lcsc"] for k, v in out.items()} == {"A": "C0", "B": "C1", "C": "C2"}
|
||||
assert len(fake.posts) == 2 # 3 MPNs / chunk size 2
|
||||
assert [len(p["mpns"]) for p in fake.posts] == [2, 1]
|
||||
|
||||
|
||||
def test_upload_bom_enriches_real_mpn_passives_via_by_mpn(tmp_path, monkeypatch):
|
||||
"""For a genuine MPN column, upload reverse-looks-up passives in the LCSC
|
||||
catalogue and caches lcsc_to_mpn + lcsc_payloads so the wizard resolves them
|
||||
through the same machinery as the LCSC-column flow. ICs/simple are excluded,
|
||||
catalogue misses fall through to the pipeline, and the CSV is not rewritten."""
|
||||
from backend.config import settings
|
||||
from backend.services import purple_parts
|
||||
|
||||
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
|
||||
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_by_mpn(mpns, **kw):
|
||||
captured["mpns"] = list(mpns)
|
||||
return {
|
||||
"CL10A106KQ8NNNC": {
|
||||
"lcsc": "C1525",
|
||||
"mpn": "CL10A106KQ8NNNC",
|
||||
"manufacturer": "Samsung",
|
||||
"package": "0603",
|
||||
"description": "10uF 6.3V X5R 0603 MLCC",
|
||||
"category": "Capacitors",
|
||||
"subcategory": "MLCC - SMD/SMT",
|
||||
},
|
||||
"RC0603FR-0710KL": None, # catalogue miss
|
||||
}
|
||||
|
||||
monkeypatch.setattr(purple_parts, "lookup_mpn_batch", fake_by_mpn)
|
||||
|
||||
client = _make_test_client(tmp_path)
|
||||
resp = client.post("/api/projects", json={"name": "realmpn"})
|
||||
project_id = resp.json()["id"]
|
||||
|
||||
csv = (
|
||||
b"Reference,Value,Footprint,Manufacturer Part Number\n"
|
||||
b"U1,STM32,LQFP-48,STM32F103C8T6\n"
|
||||
b"C1,10uF,0603,CL10A106KQ8NNNC\n"
|
||||
b"R1,10k,0603,RC0603FR-0710KL\n"
|
||||
b"D1,Diode,SOD-123,1N4148WS\n"
|
||||
)
|
||||
resp = client.post(
|
||||
f"/api/projects/{project_id}/upload/bom",
|
||||
files={"file": ("bom.csv", csv, "text/csv")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["lcsc_resolved"] == 0 # no CSV rewrite
|
||||
assert body["lcsc_to_mpn"] == {"C1525": "CL10A106KQ8NNNC"}
|
||||
|
||||
# Only passives are looked up (IC + simple excluded).
|
||||
assert sorted(captured["mpns"]) == sorted(
|
||||
["CL10A106KQ8NNNC", "RC0603FR-0710KL"]
|
||||
)
|
||||
|
||||
meta = client.get(f"/api/projects/{project_id}").json()
|
||||
assert set(meta["lcsc_payloads"].keys()) == {"C1525"}
|
||||
assert meta["lcsc_payloads"]["C1525"]["mpn"] == "CL10A106KQ8NNNC"
|
||||
assert meta["lcsc_to_mpn"] == {"C1525": "CL10A106KQ8NNNC"}
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Concurrency of the gated review path in validate_design_async.
|
||||
|
||||
The pipeline drives validate_design_async with a ``before_ic`` credit gate,
|
||||
which (since the parallelism change) runs up to ``settings.ic_concurrency``
|
||||
IC reviews at once. These tests build a minimal synthetic graph (no real
|
||||
datasheets, no network — review_ic_async is faked) and assert:
|
||||
|
||||
* in-flight reviews are bounded by the single ``ic_concurrency`` knob,
|
||||
* each IC's API calls land in its *own* private ApiLogger (no cross-billing
|
||||
between concurrent ICs — the bug the private-logger design prevents),
|
||||
* a gate that trips mid-run stops *new* reviews (pause is honoured).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.models import Component, ComponentType, DesignGraph, Finding
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.validate import ReviewResult
|
||||
from backend.services import validation as val
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
PREFIX = "users/local/projects/test"
|
||||
# Distinct MPNs so each IC maps to its own datasheet PDF + private logger.
|
||||
IC_MPNS = {f"U{i}": f"MPN-{i}" for i in range(1, 6)} # 5 ICs
|
||||
|
||||
|
||||
def _blank_pdf(path: Path) -> None:
|
||||
w = PdfWriter()
|
||||
w.add_blank_page(width=200, height=200)
|
||||
with path.open("wb") as fh:
|
||||
w.write(fh)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
data = tmp_path / "data"
|
||||
proj = data / PREFIX
|
||||
proj.mkdir(parents=True)
|
||||
|
||||
# Minimal graph: one IC component per MPN (plus their datasheet PDFs).
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
ref: Component(
|
||||
reference=ref,
|
||||
value=mpn,
|
||||
footprint="LQFP",
|
||||
component_type=ComponentType.IC,
|
||||
mpn=mpn,
|
||||
)
|
||||
for ref, mpn in IC_MPNS.items()
|
||||
}
|
||||
)
|
||||
graph_path = proj / "design_graph.json"
|
||||
graph_path.write_text(graph.model_dump_json())
|
||||
|
||||
extracted = proj / "extracted"
|
||||
extracted.mkdir()
|
||||
ds_dir = proj / "uploads" / "datasheets"
|
||||
ds_dir.mkdir(parents=True)
|
||||
for mpn in IC_MPNS.values():
|
||||
_blank_pdf(ds_dir / f"{safe_mpn(mpn)}.pdf")
|
||||
|
||||
return dict(
|
||||
data=data,
|
||||
graph=graph_path,
|
||||
report=proj / "report.json",
|
||||
extracted=extracted,
|
||||
ds_dir=ds_dir,
|
||||
storage=LocalStorageBackend(data),
|
||||
)
|
||||
|
||||
|
||||
def _finding(ref: str) -> Finding:
|
||||
return Finding(designator=ref, finding=f"{ref} note", status="INFO")
|
||||
|
||||
|
||||
def _make_fake_review(tracker: dict, *, log_entries):
|
||||
"""Fake review_ic_async: logs ``log_entries(ref)`` calls to the *private*
|
||||
logger it's handed, records peak concurrency, and yields so reviews truly
|
||||
overlap."""
|
||||
|
||||
async def fake_review_ic_async(graph, cmap, ic_ref, pdf_path, *,
|
||||
api_logger=None, **kw):
|
||||
tracker["in_flight"] += 1
|
||||
tracker["peak"] = max(tracker["peak"], tracker["in_flight"])
|
||||
try:
|
||||
# Real suspension point so the event loop can interleave reviews.
|
||||
await asyncio.sleep(0.02)
|
||||
# Log this IC's API calls into ITS OWN private logger. Each IC logs
|
||||
# a distinct number of entries so cross-billing would be visible.
|
||||
for k in range(log_entries(ic_ref)):
|
||||
api_logger.log(
|
||||
stage="validation", identifier=ic_ref, model="fake",
|
||||
input_tokens=100, output_tokens=50, duration_ms=1,
|
||||
stop_reason="end_turn", turns=1,
|
||||
)
|
||||
return ReviewResult(findings=[_finding(ic_ref)], checked_areas=["power"]), {}
|
||||
finally:
|
||||
tracker["in_flight"] -= 1
|
||||
|
||||
return fake_review_ic_async
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrency_bounded_by_knob_and_charging_isolated(workspace, monkeypatch):
|
||||
# One unique entry-count per IC (U1->1, U2->2, ... U5->5).
|
||||
entries_for = {ref: i + 1 for i, ref in enumerate(IC_MPNS)}
|
||||
tracker = {"in_flight": 0, "peak": 0}
|
||||
monkeypatch.setattr(
|
||||
val, "review_ic_async",
|
||||
_make_fake_review(tracker, log_entries=lambda ref: entries_for[ref]),
|
||||
)
|
||||
monkeypatch.setattr(settings, "ic_concurrency", 3)
|
||||
|
||||
shared = ApiLogger()
|
||||
charged: dict[str, int] = {}
|
||||
|
||||
async def before_ic(ref):
|
||||
return True
|
||||
|
||||
async def on_ic_done(ref, result, private):
|
||||
# Mirror _charge_private_logger: the private logger must hold EXACTLY
|
||||
# this IC's entries — never another concurrent IC's.
|
||||
assert all(e["identifier"] == ref for e in private.entries), \
|
||||
f"{ref}'s private logger leaked another IC's entries: {private.entries}"
|
||||
charged[ref] = len(private.entries)
|
||||
shared.entries.extend(private.entries)
|
||||
|
||||
await val.validate_design_async(
|
||||
str(workspace["graph"]),
|
||||
str(workspace["report"]),
|
||||
str(workspace["extracted"]),
|
||||
pdf_dir=str(workspace["ds_dir"]),
|
||||
api_logger=shared,
|
||||
storage=workspace["storage"],
|
||||
before_ic=before_ic,
|
||||
on_ic_done=on_ic_done,
|
||||
)
|
||||
|
||||
# Knob bounds parallelism: 5 ICs, knob=3 -> peak exactly 3.
|
||||
assert tracker["peak"] == 3, f"expected peak 3, got {tracker['peak']}"
|
||||
# Per-IC charging isolated and exact: each IC charged its own entry count.
|
||||
assert charged == entries_for
|
||||
# Shared log accumulated every IC's calls once (1+2+3+4+5 = 15).
|
||||
assert len(shared.entries) == sum(entries_for.values()) == 15
|
||||
|
||||
report = json.loads(workspace["report"].read_text())
|
||||
assert report["summary"]["total"] == len(IC_MPNS) # all 5 reviewed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_knob_scales_to_one_is_sequential(workspace, monkeypatch):
|
||||
tracker = {"in_flight": 0, "peak": 0}
|
||||
monkeypatch.setattr(
|
||||
val, "review_ic_async",
|
||||
_make_fake_review(tracker, log_entries=lambda ref: 1),
|
||||
)
|
||||
monkeypatch.setattr(settings, "ic_concurrency", 1)
|
||||
|
||||
async def before_ic(ref):
|
||||
return True
|
||||
|
||||
await val.validate_design_async(
|
||||
str(workspace["graph"]),
|
||||
str(workspace["report"]),
|
||||
str(workspace["extracted"]),
|
||||
pdf_dir=str(workspace["ds_dir"]),
|
||||
api_logger=ApiLogger(),
|
||||
storage=workspace["storage"],
|
||||
before_ic=before_ic,
|
||||
)
|
||||
# ic_concurrency=1 -> never more than one review in flight (sequential).
|
||||
assert tracker["peak"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_trip_stops_new_reviews(workspace, monkeypatch):
|
||||
tracker = {"in_flight": 0, "peak": 0}
|
||||
monkeypatch.setattr(
|
||||
val, "review_ic_async",
|
||||
_make_fake_review(tracker, log_entries=lambda ref: 1),
|
||||
)
|
||||
monkeypatch.setattr(settings, "ic_concurrency", 2)
|
||||
|
||||
reviewed: list[str] = []
|
||||
gate_calls = {"n": 0}
|
||||
LIMIT = 2 # allow exactly the first 2 gate checks, then "out of credits"
|
||||
|
||||
async def before_ic(ref):
|
||||
# Synchronous (no await) -> atomic counter, so exactly LIMIT pass.
|
||||
gate_calls["n"] += 1
|
||||
return gate_calls["n"] <= LIMIT
|
||||
|
||||
async def on_ic_done(ref, result, private):
|
||||
reviewed.append(ref)
|
||||
|
||||
report_obj = await val.validate_design_async(
|
||||
str(workspace["graph"]),
|
||||
str(workspace["report"]),
|
||||
str(workspace["extracted"]),
|
||||
pdf_dir=str(workspace["ds_dir"]),
|
||||
api_logger=ApiLogger(),
|
||||
storage=workspace["storage"],
|
||||
before_ic=before_ic,
|
||||
on_ic_done=on_ic_done,
|
||||
)
|
||||
|
||||
# Exactly LIMIT ICs got past the gate and were reviewed; the rest were
|
||||
# stopped without starting work.
|
||||
assert len(reviewed) == LIMIT, f"reviewed={reviewed}"
|
||||
# Report is marked partial because a gate tripped.
|
||||
report = json.loads(workspace["report"].read_text())
|
||||
assert report.get("partial") is True
|
||||
assert report["summary"]["total"] == LIMIT
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Integration: deterministic findings are seeded into report.json and skipped
|
||||
ICs surface under not_reviewed — driven through validate_design_async with no
|
||||
datasheet PDFs (so the LLM review loop is empty and no API call is made)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.services import validation as val
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deterministic_findings_seeded_and_not_reviewed(tmp_path):
|
||||
proj = tmp_path / "proj"
|
||||
extracted = proj / "extracted"
|
||||
extracted.mkdir(parents=True)
|
||||
pdf_dir = proj / "pdfs"
|
||||
pdf_dir.mkdir()
|
||||
|
||||
# U3: a UART5_TX net on the RX-only pin (pin-mux ERROR). U6: no MPN -> no
|
||||
# datasheet -> not_reviewed. No PDFs anywhere -> review loop is empty.
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U3": Component(reference="U3", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="MCUX",
|
||||
pins={"54": "MCU-UART5-TX"}),
|
||||
"U6": Component(reference="U6", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn=None,
|
||||
pins={"1": "I2C1-SCL-3V3"}),
|
||||
},
|
||||
nets={
|
||||
"MCU-UART5-TX": Net(name="MCU-UART5-TX", net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref="U3", pin_number="54")]),
|
||||
"I2C1-SCL-3V3": Net(name="I2C1-SCL-3V3", net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref="U6", pin_number="1")]),
|
||||
},
|
||||
)
|
||||
graph_path = proj / "design_graph.json"
|
||||
graph_path.write_text(graph.model_dump_json())
|
||||
|
||||
cons = ComponentConstraints(
|
||||
mpn="MCUX",
|
||||
pintable=[Pin(number=54, name="PD2", functions=["UART5_RX"])],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
(extracted / "MCUX.json").write_text(cons.model_dump_json())
|
||||
|
||||
report_path = proj / "report.json"
|
||||
await val.validate_design_async(
|
||||
graph_path=str(graph_path),
|
||||
output_path=str(report_path),
|
||||
datasheets_dir=str(extracted),
|
||||
pdf_dir=str(pdf_dir),
|
||||
storage=None,
|
||||
)
|
||||
|
||||
data = json.loads(report_path.read_text())
|
||||
|
||||
det = [f for f in data["findings"] if f.get("source") == "pin_mux_check"]
|
||||
assert len(det) == 1
|
||||
assert det[0]["status"] == "ERROR"
|
||||
assert det[0]["designator"] == "U3"
|
||||
assert det[0]["finding_id"] # assign_finding_ids ran over it
|
||||
assert det[0]["source_page"] is None
|
||||
assert data["summary"]["ERROR"] >= 1
|
||||
|
||||
assert {x["designator"] for x in data["not_reviewed"]} == {"U3", "U6"}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Recovery path: when a turn produces no tool calls (model wrote findings
|
||||
as a JSON code block in prose instead of calling submit_review), the loop
|
||||
must NOT drop the work — it should nudge and force submit_review next turn.
|
||||
|
||||
This regression was introduced by a system-prompt change that made Gemini
|
||||
default to text output for findings. The fix is in the review loop itself
|
||||
so it survives future prompt regressions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.services import validation as val
|
||||
from backend.services.llm.types import Completion, ToolCall, Usage
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
GRAPH = Path(__file__).resolve().parent.parent / "simple_project" / "design_graph.json"
|
||||
IC_MPNS = {
|
||||
"U1": "SPX3819M5-L-3-3/TR",
|
||||
"U2": "CH340E",
|
||||
"U3": "MSPM0G3507SPTR",
|
||||
}
|
||||
PREFIX = "users/local/projects/test"
|
||||
|
||||
|
||||
def _blank_pdf(path: Path) -> None:
|
||||
w = PdfWriter()
|
||||
w.add_blank_page(width=200, height=200)
|
||||
with path.open("wb") as fh:
|
||||
w.write(fh)
|
||||
|
||||
|
||||
def _usage():
|
||||
return Usage(input_tokens=10, output_tokens=5,
|
||||
cache_creation_tokens=0, cache_read_tokens=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
data = tmp_path / "data"
|
||||
(data / PREFIX).mkdir(parents=True)
|
||||
graph_path = data / PREFIX / "design_graph.json"
|
||||
graph_path.write_text(GRAPH.read_text())
|
||||
report_path = data / PREFIX / "report.json"
|
||||
extracted = data / PREFIX / "extracted"
|
||||
extracted.mkdir()
|
||||
ds_dir = data / PREFIX / "uploads" / "datasheets"
|
||||
ds_dir.mkdir(parents=True)
|
||||
for mpn in IC_MPNS.values():
|
||||
_blank_pdf(ds_dir / f"{safe_mpn(mpn)}.pdf")
|
||||
storage = LocalStorageBackend(data)
|
||||
return dict(data=data, graph=graph_path, report=report_path,
|
||||
extracted=extracted, ds_dir=ds_dir, storage=storage)
|
||||
|
||||
|
||||
def _recovery_script(ic_ref, n):
|
||||
"""Turn 0: text only, no tool calls (the failure mode).
|
||||
Turn 1: submit_review under forced tool_choice (the recovery)."""
|
||||
if n == 0:
|
||||
return Completion(
|
||||
text="I'll write up findings as JSON below: [...]",
|
||||
tool_calls=[], # the bug: no tool calls
|
||||
usage=_usage(),
|
||||
stop_reason="end_turn",
|
||||
raw_assistant_blocks=[],
|
||||
)
|
||||
# Turn 1 — forced submit_review under the recovery
|
||||
return Completion(
|
||||
text="",
|
||||
tool_calls=[ToolCall(id="t2", name="submit_review", input={
|
||||
"findings": [{
|
||||
"finding": "Recovered finding.",
|
||||
"why": "Recovered from a no-tool-call turn.",
|
||||
"status": "INFO",
|
||||
"source_page": 1,
|
||||
}],
|
||||
"checked_areas": ["recovery"],
|
||||
})],
|
||||
usage=_usage(),
|
||||
stop_reason="tool_use",
|
||||
raw_assistant_blocks=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tool_calls_triggers_forced_submit_next_turn(workspace, monkeypatch):
|
||||
"""A turn with zero tool calls should not drop the review — the next
|
||||
turn must be forced to submit_review and the resulting findings must
|
||||
land in the report."""
|
||||
|
||||
seen_tool_choices: list = []
|
||||
|
||||
class _Session:
|
||||
def __init__(self, ic_ref):
|
||||
self._ic = ic_ref
|
||||
self._n = 0
|
||||
|
||||
async def complete(self, messages, tools, tool_choice):
|
||||
seen_tool_choices.append((self._ic, self._n, tool_choice))
|
||||
c = _recovery_script(self._ic, self._n)
|
||||
self._n += 1
|
||||
return c
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
class _Provider:
|
||||
name = "fake"
|
||||
|
||||
async def create_session(self, model, system, max_tokens, **_kwargs):
|
||||
return _Session(_Provider._current_ic)
|
||||
|
||||
_current_ic = None
|
||||
|
||||
async def fake_cwf(stage, body):
|
||||
return await body(_Provider(), "fake-model")
|
||||
|
||||
monkeypatch.setattr(val, "call_with_fallback", fake_cwf)
|
||||
|
||||
orig = val.review_ic_async
|
||||
|
||||
async def wrapped(graph, cmap, ic_ref, pdf_path, **kw):
|
||||
_Provider._current_ic = ic_ref
|
||||
return await orig(graph, cmap, ic_ref, pdf_path, **kw)
|
||||
|
||||
monkeypatch.setattr(val, "review_ic_async", wrapped)
|
||||
|
||||
async def before_ic(ref):
|
||||
return True
|
||||
|
||||
await val.validate_design_async(
|
||||
str(workspace["graph"]),
|
||||
str(workspace["report"]),
|
||||
str(workspace["extracted"]),
|
||||
pdf_dir=str(workspace["ds_dir"]),
|
||||
storage=workspace["storage"],
|
||||
before_ic=before_ic,
|
||||
project_prefix=PREFIX,
|
||||
run_meta={"git_commit": "testsha"},
|
||||
)
|
||||
|
||||
# All three ICs should have recovered: each had a no-tool-call turn 0,
|
||||
# then submit_review under forced tool_choice on turn 1.
|
||||
report = json.loads(workspace["report"].read_text())
|
||||
assert report["summary"]["total"] == 3
|
||||
assert report["summary"]["INFO"] == 3
|
||||
|
||||
# Verify the recovery actually forced submit_review on turn 1 for each IC.
|
||||
by_ic_turn = {(ic, n): tc for ic, n, tc in seen_tool_choices}
|
||||
for ic in ("U1", "U2", "U3"):
|
||||
# Turn 0 should be auto (model free to use any tool)
|
||||
assert by_ic_turn[(ic, 0)] == "auto", \
|
||||
f"turn 0 for {ic} should be auto, got {by_ic_turn[(ic, 0)]!r}"
|
||||
# Turn 1 should be forced submit_review (the recovery)
|
||||
assert by_ic_turn[(ic, 1)] == {"name": "submit_review"}, \
|
||||
f"turn 1 for {ic} should force submit_review, got {by_ic_turn[(ic, 1)]!r}"
|
||||
|
||||
# Each trace should show the recovery: turn 0 has no tool calls, turn 1
|
||||
# has submit_review.
|
||||
traces_dir = workspace["data"] / PREFIX / "review_traces"
|
||||
for ref in ("U1", "U2", "U3"):
|
||||
t = json.loads((traces_dir / f"{safe_mpn(ref)}.json").read_text())
|
||||
assert len(t["turns"]) == 2
|
||||
assert t["turns"][0]["tool_calls"] == []
|
||||
assert t["turns"][1]["tool_calls"][0]["name"] == "submit_review"
|
||||
assert t["stop_reason"] == "submit_review"
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Per-IC validation trace log — end-to-end on the async (gated) pipeline path.
|
||||
|
||||
Drives validate_design_async with a scripted fake LLM provider (no API key,
|
||||
no network) against the real simple_project graph, asserting that each IC
|
||||
leaves a review_traces/<safe_mpn>.json transcript with the expected schema,
|
||||
and that trace failures never break the review/report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.services import validation as val
|
||||
from backend.services.llm.types import Completion, ToolCall, Usage
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
GRAPH = Path(__file__).resolve().parent.parent / "simple_project" / "design_graph.json"
|
||||
IC_MPNS = {
|
||||
"U1": "SPX3819M5-L-3-3/TR",
|
||||
"U2": "CH340E",
|
||||
"U3": "MSPM0G3507SPTR",
|
||||
}
|
||||
PREFIX = "users/local/projects/test"
|
||||
|
||||
|
||||
def _blank_pdf(path: Path) -> None:
|
||||
w = PdfWriter()
|
||||
w.add_blank_page(width=200, height=200)
|
||||
with path.open("wb") as fh:
|
||||
w.write(fh)
|
||||
|
||||
|
||||
def _fake_provider(script):
|
||||
"""Return a provider whose session.complete() yields scripted Completions.
|
||||
|
||||
`script(ic_ref, call_idx)` -> Completion. call_idx resets per session
|
||||
(i.e. per IC review attempt).
|
||||
"""
|
||||
|
||||
class _Session:
|
||||
def __init__(self, ic_ref):
|
||||
self._ic = ic_ref
|
||||
self._n = 0
|
||||
|
||||
async def complete(self, messages, tools, tool_choice):
|
||||
c = script(self._ic, self._n)
|
||||
self._n += 1
|
||||
return c
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
class _Provider:
|
||||
name = "fake"
|
||||
|
||||
async def create_session(self, model, system, max_tokens, **_kwargs):
|
||||
# ic_ref is recoverable from the build_component_context text in
|
||||
# the first user message; simpler: stash via closure below.
|
||||
# **_kwargs absorbs temperature= (and any future session knobs).
|
||||
return _Session(_Provider._current_ic)
|
||||
|
||||
_current_ic = None
|
||||
|
||||
return _Provider
|
||||
|
||||
|
||||
def _usage():
|
||||
return Usage(input_tokens=10, output_tokens=5,
|
||||
cache_creation_tokens=2, cache_read_tokens=1)
|
||||
|
||||
|
||||
def _script(ic_ref, n):
|
||||
if n == 0:
|
||||
return Completion(
|
||||
text="",
|
||||
tool_calls=[ToolCall(id="t1", name="get_pintable",
|
||||
input={"designator": ic_ref})],
|
||||
usage=_usage(),
|
||||
stop_reason="tool_use",
|
||||
raw_assistant_blocks=[],
|
||||
)
|
||||
return Completion(
|
||||
text="done",
|
||||
tool_calls=[ToolCall(id="t2", name="submit_review", input={
|
||||
"findings": [{
|
||||
"finding": "VCAP not connected.",
|
||||
"why": "Datasheet requires 1uF on VCAP.",
|
||||
"status": "WARNING",
|
||||
"source_page": 7,
|
||||
}],
|
||||
"checked_areas": ["power", "decoupling"],
|
||||
})],
|
||||
usage=_usage(),
|
||||
stop_reason="tool_use",
|
||||
raw_assistant_blocks=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
data = tmp_path / "data"
|
||||
(data / PREFIX).mkdir(parents=True)
|
||||
graph_path = data / PREFIX / "design_graph.json"
|
||||
graph_path.write_text(GRAPH.read_text())
|
||||
report_path = data / PREFIX / "report.json"
|
||||
extracted = data / PREFIX / "extracted"
|
||||
extracted.mkdir()
|
||||
ds_dir = data / PREFIX / "uploads" / "datasheets"
|
||||
ds_dir.mkdir(parents=True)
|
||||
for mpn in IC_MPNS.values():
|
||||
_blank_pdf(ds_dir / f"{safe_mpn(mpn)}.pdf")
|
||||
storage = LocalStorageBackend(data)
|
||||
return dict(data=data, graph=graph_path, report=report_path,
|
||||
extracted=extracted, ds_dir=ds_dir, storage=storage)
|
||||
|
||||
|
||||
async def _run(ws, monkeypatch, before_ic):
|
||||
"""Patch the provider seam and run the gated path."""
|
||||
prov_cls = _fake_provider(_script)
|
||||
|
||||
# review_ic_async calls call_with_fallback("validation", _run); short-circuit
|
||||
# it to invoke the body directly with our fake provider, exercising the
|
||||
# real loop + trace instrumentation.
|
||||
async def fake_cwf(stage, body):
|
||||
# build_component_context is called inside body; the fake session needs
|
||||
# the ic_ref. Patch _select_review_pages to a no-op and recover ic_ref
|
||||
# via the provider's _current_ic class attr set per call below.
|
||||
return await body(prov_cls(), "fake-model")
|
||||
|
||||
monkeypatch.setattr(val, "call_with_fallback", fake_cwf)
|
||||
|
||||
# The fake session needs the current ic_ref; thread it through by wrapping
|
||||
# review_ic_async to set the class attr before delegating.
|
||||
orig = val.review_ic_async
|
||||
|
||||
async def wrapped(graph, cmap, ic_ref, pdf_path, **kw):
|
||||
prov_cls._current_ic = ic_ref
|
||||
return await orig(graph, cmap, ic_ref, pdf_path, **kw)
|
||||
|
||||
monkeypatch.setattr(val, "review_ic_async", wrapped)
|
||||
|
||||
return await val.validate_design_async(
|
||||
str(ws["graph"]),
|
||||
str(ws["report"]),
|
||||
str(ws["extracted"]),
|
||||
pdf_dir=str(ws["ds_dir"]),
|
||||
storage=ws["storage"],
|
||||
before_ic=before_ic,
|
||||
project_prefix=PREFIX,
|
||||
run_meta={"git_commit": "testsha"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_written_per_ic_with_schema(workspace, monkeypatch):
|
||||
async def before_ic(ref):
|
||||
return True
|
||||
|
||||
await _run(workspace, monkeypatch, before_ic)
|
||||
|
||||
traces_dir = workspace["data"] / PREFIX / "review_traces"
|
||||
for ref, mpn in IC_MPNS.items():
|
||||
# Keyed by safe_mpn(ic_ref) per the design — the designator, not MPN.
|
||||
f = traces_dir / f"{safe_mpn(ref)}.json"
|
||||
assert f.is_file(), f"missing trace for {ref}"
|
||||
t = json.loads(f.read_text())
|
||||
assert t["trace_version"] == 1
|
||||
assert t["ic_ref"] == ref
|
||||
assert t["mpn"] == mpn
|
||||
assert t["provider"] == "fake"
|
||||
assert t["git_commit"] == "testsha"
|
||||
assert re.fullmatch(r"[0-9a-f]{32}", t["datasheet"]["md5"])
|
||||
assert t["datasheet"]["safe_mpn"] == safe_mpn(mpn)
|
||||
assert len(t["turns"]) == 2
|
||||
tc0 = t["turns"][0]["tool_calls"][0]
|
||||
assert tc0["name"] == "get_pintable"
|
||||
assert tc0["input"] == {"designator": ref}
|
||||
assert isinstance(tc0["output"], str) and tc0["output"]
|
||||
assert t["turns"][0]["usage"]["input_tokens"] == 10
|
||||
assert t["turns"][1]["tool_calls"][0]["name"] == "submit_review"
|
||||
assert t["final_submission"]["checked_areas"] == ["power", "decoupling"]
|
||||
assert t["stop_reason"] == "submit_review"
|
||||
assert t["result"]["findings_count"] == 1
|
||||
assert t["error"] is None
|
||||
assert isinstance(t["duration_ms"], int)
|
||||
|
||||
report = json.loads(workspace["report"].read_text())
|
||||
# 3 ICs x 1 finding each
|
||||
assert report["summary"]["total"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_write_failure_does_not_break_review(workspace, monkeypatch):
|
||||
storage = workspace["storage"]
|
||||
real_write = storage.write_json
|
||||
|
||||
def flaky(key, data):
|
||||
if "review_traces" in key:
|
||||
raise RuntimeError("simulated trace storage outage")
|
||||
return real_write(key, data)
|
||||
|
||||
monkeypatch.setattr(storage, "write_json", flaky)
|
||||
|
||||
async def before_ic(ref):
|
||||
return True
|
||||
|
||||
# Must not raise despite every trace write failing.
|
||||
await _run(workspace, monkeypatch, before_ic)
|
||||
|
||||
report = json.loads(workspace["report"].read_text())
|
||||
assert report["summary"]["total"] == 3
|
||||
assert not (workspace["data"] / PREFIX / "review_traces").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_ic_flush_survives_pause(workspace, monkeypatch):
|
||||
seen = []
|
||||
|
||||
async def before_ic(ref):
|
||||
seen.append(ref)
|
||||
return len(seen) == 1 # allow only the first IC, then pause
|
||||
|
||||
await _run(workspace, monkeypatch, before_ic)
|
||||
|
||||
traces_dir = workspace["data"] / PREFIX / "review_traces"
|
||||
files = sorted(p.name for p in traces_dir.glob("*.json"))
|
||||
# Exactly the first IC (U1) reviewed before the pause; its trace persisted.
|
||||
assert files == ["U1.json"]
|
||||
Reference in New Issue
Block a user