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:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
View File
+41
View File
@@ -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)
+81
View File
@@ -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
+94
View File
@@ -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)
+138
View File
@@ -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'"
+222
View File
@@ -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")
+173
View File
@@ -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
+179
View File
@@ -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
+122
View File
@@ -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
+113
View File
@@ -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")]}
+247
View File
@@ -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
+218
View File
@@ -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 == []
+766
View File
@@ -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"}
+223
View File
@@ -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"}
+172
View File
@@ -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"
+234
View File
@@ -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"]