Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2d3cd613 | ||
|
|
5ab69a835c | ||
|
|
f79c722e68 | ||
|
|
7e872cfbc3 | ||
|
|
3046c4aeee | ||
|
|
b32d581d27 | ||
|
|
29e1a3033d |
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from backend.periscopex.af_rlgc import cascade_sparam
|
||||
@@ -24,8 +25,9 @@ from backend.periscopex.hf_line_check import COORD_QUANT_MM, ENDPOINT_SNAP_MM
|
||||
from backend.periscopex.impedance import GeometryError
|
||||
from backend.periscopex.impedance_traces import analyze_specified_nets
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.si_check import partner_net
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net
|
||||
from backend.periscopex.protocol_catalog import load_catalog
|
||||
from backend.periscopex.si_check import bus_class, partner_net
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
SOURCE = "af_trace_check"
|
||||
@@ -35,6 +37,198 @@ _WIDTH_STEP_MM = 0.05
|
||||
_PARALLEL_MIN_MM = 5.0
|
||||
|
||||
|
||||
_MDI_POL = re.compile(
|
||||
r"^(?P<stem>.*(?:TX|RX|TD|RD|TRD\d|TP))(?P<pol>[PN])$",
|
||||
re.I,
|
||||
)
|
||||
_GBE_RE = re.compile(r"1000\s*BASE|1000BASE|\bGBE\b|GIGABIT", re.I)
|
||||
_FAST_RE = re.compile(r"LAN8720|10\s*/\s*100|100BASE|10BASE|BASE[\s\-]?TX", re.I)
|
||||
|
||||
|
||||
def _mdi_mate(name: str) -> str | None:
|
||||
"""TXP/TXN and RXP/RXN, plus the existing _P/_N and +/- partners."""
|
||||
m = _MDI_POL.match(name or "")
|
||||
if m:
|
||||
pol = m.group("pol").upper()
|
||||
return m.group("stem") + ("N" if pol == "P" else "P")
|
||||
return partner_net(name)
|
||||
|
||||
|
||||
def _is_eth_mdi_net(net: str) -> bool:
|
||||
return bus_class(net) == "eth_mdi"
|
||||
|
||||
|
||||
def _coalesce_eth_pairs(units: list[AfUnit]) -> list[AfUnit]:
|
||||
"""Treat each RJ45/PHY MDI pair as one unit, including TXP/TXN names."""
|
||||
by_net = {n: u for u in units for n in u.nets}
|
||||
out: list[AfUnit] = []
|
||||
seen: set[str] = set()
|
||||
for unit in units:
|
||||
if unit.nets[0] in seen:
|
||||
continue
|
||||
if len(unit.nets) == 1 and _is_eth_mdi_net(unit.nets[0]):
|
||||
mate = _mdi_mate(unit.nets[0])
|
||||
other = by_net.get(mate or "")
|
||||
if mate and other is not None and mate not in seen:
|
||||
nets = tuple(sorted((unit.nets[0], mate)))
|
||||
seen.update(nets)
|
||||
length = max(unit.length_mm, other.length_mm)
|
||||
out.append(AfUnit(nets=nets, length_mm=length, bus="eth_mdi"))
|
||||
continue
|
||||
if all(_is_eth_mdi_net(n) for n in unit.nets):
|
||||
seen.update(unit.nets)
|
||||
out.append(AfUnit(
|
||||
nets=tuple(sorted(unit.nets)),
|
||||
length_mm=unit.length_mm,
|
||||
bus="eth_mdi",
|
||||
))
|
||||
continue
|
||||
seen.update(unit.nets)
|
||||
out.append(unit)
|
||||
return out
|
||||
|
||||
|
||||
def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None:
|
||||
"""10/100 or gbe from the PHY/jack actually on these nets. Else None."""
|
||||
bits: list[str] = []
|
||||
for net in nets:
|
||||
for ref in refs_on_matched_net(graph, net):
|
||||
comp = graph.components.get(ref)
|
||||
if comp is None:
|
||||
continue
|
||||
bits.extend([
|
||||
comp.mpn or "", comp.value or "", comp.footprint or "",
|
||||
comp.component_subtype or "",
|
||||
])
|
||||
row = (graph.bom_fields or {}).get(ref) or {}
|
||||
for key in ("description", "Description", "value", "Value"):
|
||||
if row.get(key):
|
||||
bits.append(str(row.get(key)))
|
||||
text = " ".join(bits)
|
||||
if _GBE_RE.search(text):
|
||||
return "gbe"
|
||||
if _FAST_RE.search(text):
|
||||
return "10/100"
|
||||
return None
|
||||
|
||||
|
||||
def _eth_cite(speed: str) -> tuple[float, str] | None:
|
||||
"""Packed cable_channel ohm for the identified speed. Nothing invented."""
|
||||
want = "1000base-t-mdi" if speed == "gbe" else "100base-tx-mdi"
|
||||
cat = load_catalog()
|
||||
for iface in cat.physical_interfaces:
|
||||
if iface.id != want:
|
||||
continue
|
||||
for c in iface.constraints:
|
||||
if c.parameter != "cable_channel" or c.unit != "ohm" or c.value is None:
|
||||
continue
|
||||
src = c.source
|
||||
doc = (src.document if src and src.document else "IEEE Std 802.3")
|
||||
section = (src.section if src and src.section else "")
|
||||
if speed == "gbe":
|
||||
label = f"{doc} Clause 40 (§{section})" if section else f"{doc} Clause 40"
|
||||
else:
|
||||
label = (
|
||||
f"{doc} 100BASE-TX 25.4.9 (§{section})"
|
||||
if section else f"{doc} 100BASE-TX 25.4.9"
|
||||
)
|
||||
return float(c.value), label
|
||||
return None
|
||||
|
||||
|
||||
def _pair_z_ohm(layout: LayoutGraph, unit: AfUnit) -> float | None:
|
||||
"""One Z for the pair. None when stackup or geometry cannot produce it."""
|
||||
if layout.stackup is None or len(unit.nets) < 2:
|
||||
return None
|
||||
samples = _samples(layout, unit)
|
||||
vals = _z_vals(samples, True)
|
||||
if not vals:
|
||||
return None
|
||||
return sum(vals) / len(vals)
|
||||
|
||||
|
||||
def _ethernet_mdi_finding(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph,
|
||||
unit: AfUnit,
|
||||
) -> Finding:
|
||||
"""Calculated pair Z vs the cited clause for the speed on the board.
|
||||
|
||||
Inside → PASS. Outside → FAIL. Cannot calculate → ERROR.
|
||||
"""
|
||||
speed = _eth_speed(graph, unit.nets)
|
||||
cite = _eth_cite(speed) if speed else None
|
||||
measured = _pair_z_ohm(layout, unit)
|
||||
label = _label(unit)
|
||||
if cite is None:
|
||||
text = (
|
||||
f"ERROR: Ethernet pair {label} speed is not identified on the "
|
||||
"PHY/jack, so no 10/100 or 1000 clause was applied."
|
||||
)
|
||||
req = "Pair Z is certified only against the cited clause for the identified speed."
|
||||
rec = "Identify the PHY/jack speed. No ohm value is assumed."
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed=unidentified; measured=none.",
|
||||
requirement=req, rec=rec, status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
)
|
||||
limit, cite_label = cite
|
||||
req = f"Cited limit {limit:g} Ω, {cite_label}."
|
||||
if measured is None:
|
||||
missing: list[str] = []
|
||||
if len(unit.nets) < 2:
|
||||
missing.append("pair")
|
||||
if layout.stackup is None:
|
||||
missing.append("stackup")
|
||||
else:
|
||||
missing.append("geometry")
|
||||
miss = "/".join(missing)
|
||||
text = (
|
||||
f"ERROR: pair Z for {label} cannot be calculated from the PCB "
|
||||
f"({miss} missing). Cited limit {limit:g} Ω, {cite_label}."
|
||||
)
|
||||
rec = (
|
||||
f"Pair Z was not calculated ({miss} missing). "
|
||||
f"Cited limit {limit:g} Ω, {cite_label}."
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed={speed}; missing={miss}; measured=none.",
|
||||
requirement=req, rec=rec, status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"no computed Z; cited {limit:g} Ω.",
|
||||
)
|
||||
inside = abs(measured - limit) <= 1e-6 * max(1.0, abs(limit))
|
||||
if inside:
|
||||
text = (
|
||||
f"PASS: {label} pair Z {measured:g} Ω is inside the cited limit "
|
||||
f"{limit:g} Ω, {cite_label}."
|
||||
)
|
||||
rec = f"Pair Z matches {limit:g} Ω ({cite_label})."
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
|
||||
requirement=req, rec=rec, status="INFO", cls="INFO",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
|
||||
)
|
||||
text = (
|
||||
f"FAIL: {label} pair Z {measured:g} Ω is outside the cited limit "
|
||||
f"{limit:g} Ω, {cite_label}."
|
||||
)
|
||||
rec = (
|
||||
f"Pair Z {measured:g} Ω is outside {limit:g} Ω ({cite_label})."
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
|
||||
requirement=req, rec=rec, status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
|
||||
)
|
||||
|
||||
|
||||
def check_af_traces(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
@@ -50,7 +244,10 @@ def check_af_traces(
|
||||
if f.rule_id == "PE-SI-002" and f.net
|
||||
}
|
||||
zrows = _z_rows(impedance_nets)
|
||||
for unit in iter_af_units(layout, graph):
|
||||
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
|
||||
if unit.bus == "eth_mdi":
|
||||
out.append(_ethernet_mdi_finding(graph, layout, unit))
|
||||
continue
|
||||
trig = evaluate_trigger(graph, constraints_map, layout, unit)
|
||||
if trig.missing and not trig.af:
|
||||
out.append(_skip(unit, trig.missing))
|
||||
@@ -116,7 +313,9 @@ def _finding(
|
||||
cls: str = "RISK",
|
||||
evidence: str = "SUFFICIENT",
|
||||
calculation: str = "",
|
||||
provenance: str = "",
|
||||
) -> Finding:
|
||||
prov = provenance or ("RECOMMENDED" if cls != "REVIEW" else "TYPICAL")
|
||||
return Finding(
|
||||
designator="layout",
|
||||
mpn="",
|
||||
@@ -132,7 +331,7 @@ def _finding(
|
||||
source=SOURCE,
|
||||
rule_id=rule_id,
|
||||
finding_class=cls, # type: ignore[arg-type]
|
||||
provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL",
|
||||
provenance=prov,
|
||||
evidence_status=evidence, # type: ignore[arg-type]
|
||||
net=unit.nets[0],
|
||||
pins=[],
|
||||
|
||||
@@ -52,7 +52,8 @@ _MODULE_FP = re.compile(
|
||||
re.I,
|
||||
)
|
||||
_EP_PAD = re.compile(
|
||||
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
|
||||
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|"
|
||||
r"THERMAL[\s_]?PAD|EXPOSED[\s_]?PAD)(?:[_-]?\d+)?$",
|
||||
re.I,
|
||||
)
|
||||
_EP_SPLIT = re.compile(r"^\d+_\d+$")
|
||||
@@ -112,6 +113,45 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None
|
||||
return False
|
||||
|
||||
|
||||
def _is_exposed_alias(name: str) -> bool:
|
||||
"""EP, EPAD, THERMAL PAD, exposed pad. Not a signal pin number."""
|
||||
s = str(name or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
if _EP_PAD.match(s):
|
||||
return True
|
||||
compact = re.sub(r"[\s_\-]+", "", s).lower()
|
||||
return compact in {"thermalpad", "exposedpad", "epad", "thermal"}
|
||||
|
||||
|
||||
def _ids_have_exposed(
|
||||
ids: list[tuple[str, str]] | None,
|
||||
pin_count: int | None,
|
||||
) -> bool:
|
||||
if not ids:
|
||||
return False
|
||||
return any(_is_ep_land(n, pf, pin_count) for n, pf in ids)
|
||||
|
||||
|
||||
def _drop_exposed_name_mismatch(
|
||||
miss: list[str],
|
||||
extra: list[str],
|
||||
*,
|
||||
left_has: bool,
|
||||
right_has: bool,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Same exposed pad under two names is not a missing/extra pin.
|
||||
|
||||
Datasheet EP with no exposed land on the other side stays a miss.
|
||||
"""
|
||||
if not (left_has and right_has):
|
||||
return miss, extra
|
||||
return (
|
||||
[m for m in miss if not _is_exposed_alias(m)],
|
||||
[e for e in extra if not _is_exposed_alias(e)],
|
||||
)
|
||||
|
||||
|
||||
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
|
||||
s = str(number).strip()
|
||||
pf = str(pinfunction or "").strip()
|
||||
@@ -273,6 +313,16 @@ def resolve_kicad_mod(
|
||||
return None
|
||||
|
||||
|
||||
def _lib_pad_ids(
|
||||
footprint: str,
|
||||
search_dirs: list[Path] | None,
|
||||
) -> list[tuple[str, str]] | None:
|
||||
path = resolve_kicad_mod(footprint, search_dirs)
|
||||
if path is None:
|
||||
return None
|
||||
return parse_kicad_mod_pads(path)
|
||||
|
||||
|
||||
def _lib_join_keys(
|
||||
footprint: str,
|
||||
*,
|
||||
@@ -489,6 +539,10 @@ def _pinout_findings(
|
||||
declared = _declared_io_pins(pkg, cad)
|
||||
ep_count = declared or pin_count
|
||||
pcb = _join_keys_from_ids(pcb_ids, pin_count=ep_count, ds_keys=ds)
|
||||
pcb_has_ep = _ids_have_exposed(pcb_ids, ep_count)
|
||||
ds_has_ep = any(_is_exposed_alias(k) for k in ds)
|
||||
lib_raw = _lib_pad_ids(fp_name, footprint_dirs)
|
||||
lib_has_ep = _ids_have_exposed(lib_raw, ep_count)
|
||||
lib, lib_path = _lib_join_keys(
|
||||
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
|
||||
)
|
||||
@@ -522,8 +576,10 @@ def _pinout_findings(
|
||||
)
|
||||
|
||||
if lib is not None:
|
||||
miss_lib = sorted(ds - lib)
|
||||
extra_lib = sorted(lib - ds)
|
||||
miss_lib, extra_lib = _drop_exposed_name_mismatch(
|
||||
sorted(ds - lib), sorted(lib - ds),
|
||||
left_has=ds_has_ep, right_has=lib_has_ep,
|
||||
)
|
||||
if miss_lib or extra_lib:
|
||||
rec = (
|
||||
f"Change {ref}'s KiCad footprint library so pad names match "
|
||||
@@ -547,8 +603,11 @@ def _pinout_findings(
|
||||
)
|
||||
if fnd:
|
||||
out.append(fnd)
|
||||
miss_emb = sorted(lib - pcb) if pcb else sorted(lib)
|
||||
extra_emb = sorted(pcb - lib) if pcb else []
|
||||
miss_emb, extra_emb = _drop_exposed_name_mismatch(
|
||||
sorted(lib - pcb) if pcb else sorted(lib),
|
||||
sorted(pcb - lib) if pcb else [],
|
||||
left_has=lib_has_ep, right_has=pcb_has_ep,
|
||||
)
|
||||
if pcb and (miss_emb or extra_emb):
|
||||
rec = (
|
||||
f"Replace {ref}'s embedded PCB footprint so it matches the "
|
||||
@@ -571,8 +630,10 @@ def _pinout_findings(
|
||||
out.append(fnd)
|
||||
return out
|
||||
if pcb:
|
||||
miss = sorted(ds - pcb)
|
||||
extra = sorted(pcb - ds)
|
||||
miss, extra = _drop_exposed_name_mismatch(
|
||||
sorted(ds - pcb), sorted(pcb - ds),
|
||||
left_has=ds_has_ep, right_has=pcb_has_ep,
|
||||
)
|
||||
if miss or extra:
|
||||
rec = (
|
||||
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
|
||||
|
||||
@@ -27,7 +27,14 @@ _NC_NET_RE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
_ONBOARD_POWER_RE = re.compile(
|
||||
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|3V3|\+3V3|3\.3V)$",
|
||||
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|VCC\d*|VDD\d*|3V3|\+3V3|3\.3V)"
|
||||
r"(?:[_./-].*)?$",
|
||||
re.I,
|
||||
)
|
||||
_VBUS_LEAF_RE = re.compile(r"(?:^|[_./-])VBUS(?:$|[_./-])", re.I)
|
||||
_SPDIF_RE = re.compile(r"SP(?:DIF|IF)", re.I)
|
||||
_OPTICAL_RE = re.compile(
|
||||
r"TOSLINK|OPTOCOUPL|OPTICAL|OPTODEVICE|\bAFBR\b|PHOTO[-_ ]?COUPL",
|
||||
re.I,
|
||||
)
|
||||
|
||||
@@ -57,6 +64,50 @@ def _skip_esd_net(net: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _optical_evidence(graph: DesignGraph, comp) -> bool:
|
||||
"""Schematic/BOM/footprint shows TOSLINK, optocoupler, or optical."""
|
||||
parts = [
|
||||
comp.value or "",
|
||||
comp.footprint or "",
|
||||
comp.mpn or "",
|
||||
comp.component_subtype or "",
|
||||
]
|
||||
row = (graph.bom_fields or {}).get(comp.reference) or {}
|
||||
for key in ("description", "Description", "value", "Value", "footprint", "Footprint"):
|
||||
if row.get(key):
|
||||
parts.append(str(row.get(key)))
|
||||
return bool(_OPTICAL_RE.search(" ".join(parts)))
|
||||
|
||||
|
||||
def _esd_cite(cons, *, need_vbus: bool) -> str | None:
|
||||
"""Part + section/page quote. None when the datasheet is not cited."""
|
||||
if cons is None:
|
||||
return None
|
||||
for rule in cons.layout_rules or []:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
blob = " ".join(
|
||||
str(rule.get(k) or "")
|
||||
for k in ("kind", "parameter", "note", "net", "pin", "requirement")
|
||||
)
|
||||
if "esd" not in blob.lower():
|
||||
continue
|
||||
if need_vbus and not re.search(r"vbus", blob, re.I):
|
||||
continue
|
||||
doc = str(
|
||||
rule.get("document") or rule.get("part") or rule.get("source") or ""
|
||||
).strip()
|
||||
section = str(rule.get("section") or "").strip()
|
||||
page = str(rule.get("page") or "").strip()
|
||||
if not doc or not (section or page):
|
||||
continue
|
||||
loc = section
|
||||
if page:
|
||||
loc = f"{loc} p.{page}".strip()
|
||||
return f"{doc} {loc}".strip()
|
||||
return None
|
||||
|
||||
|
||||
def _is_j_connector(comp) -> bool:
|
||||
if comp.component_type != ComponentType.CONNECTOR:
|
||||
return False
|
||||
@@ -68,7 +119,8 @@ def check_esd(
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
"""One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam."""
|
||||
_ = constraints_map
|
||||
from backend.periscopex.constraints_lookup import match_constraints
|
||||
|
||||
out: list[Finding] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
|
||||
@@ -106,28 +158,64 @@ def check_esd(
|
||||
(str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)),
|
||||
"",
|
||||
)
|
||||
rec = (
|
||||
f"Add a datasheet-specified ESD device on {net} between "
|
||||
f"{j_ref} and {ic_ref}."
|
||||
)
|
||||
leaf = _net_leaf(net)
|
||||
is_vbus = bool(_VBUS_LEAF_RE.search(leaf))
|
||||
is_rail = bool(_ONBOARD_POWER_RE.match(leaf))
|
||||
is_spdif = bool(_SPDIF_RE.search(leaf))
|
||||
optical = _optical_evidence(graph, j_comp)
|
||||
cons = match_constraints(ic.mpn or ic.value, constraints_map or {})
|
||||
quote = _esd_cite(cons, need_vbus=is_vbus)
|
||||
if is_spdif and optical:
|
||||
continue
|
||||
if is_vbus and not quote:
|
||||
continue
|
||||
if is_rail and not quote:
|
||||
continue
|
||||
if is_spdif and not optical:
|
||||
finding = (
|
||||
f"{net} ({j_ref}–{ic_ref}) was not verified as optical "
|
||||
"S/PDIF."
|
||||
)
|
||||
rec = (
|
||||
f"Confirm whether {net} is photo-coupled "
|
||||
"(TOSLINK/optocoupler). The path was not verified as optical."
|
||||
)
|
||||
requirement = (
|
||||
"Optical S/PDIF is not an external copper signal. "
|
||||
"Without TOSLINK/optocoupler evidence the path stays visible."
|
||||
)
|
||||
else:
|
||||
finding = (
|
||||
f"{net} is a {j_ref}–{ic_ref} path with no ESD/"
|
||||
"protection part on the net."
|
||||
)
|
||||
if quote:
|
||||
rec = (
|
||||
f"Add a datasheet-specified ESD device ({quote}) on "
|
||||
f"{net} between {j_ref} and {ic_ref}."
|
||||
)
|
||||
requirement = f"Cited ESD requirement: {quote}."
|
||||
else:
|
||||
rec = (
|
||||
f"Review board ESD on {net} between {j_ref} and {ic_ref}."
|
||||
)
|
||||
requirement = (
|
||||
"Connector–IC nets are reviewed for a board ESD part. "
|
||||
"No datasheet section is cited for this net."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=ic_ref,
|
||||
mpn=ic.mpn or "",
|
||||
aspect="esd",
|
||||
finding=(
|
||||
f"{net} is a {j_ref}–{ic_ref} path with no ESD/"
|
||||
"protection part on the net."
|
||||
),
|
||||
finding=finding,
|
||||
facts=(
|
||||
f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; "
|
||||
"ESD parts on net: 0."
|
||||
+ (f" cite={quote}." if quote else " cite=none.")
|
||||
),
|
||||
requirement=(
|
||||
"External ESD is recommended on connector–IC nets unless "
|
||||
"the extraction lists a mandatory clamp with a measured FACT."
|
||||
),
|
||||
requirement=requirement,
|
||||
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
|
||||
why="Only J* ∩ IC nets; NC, unconnected, VSYS/GND/3V3 excluded.",
|
||||
why="Only J* ∩ IC nets; NC, unconnected, onboard rails excluded.",
|
||||
status="WARNING",
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
|
||||
@@ -385,7 +385,7 @@ class ValidationReport(BaseModel):
|
||||
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
|
||||
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
|
||||
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
|
||||
# M0 protocol cert: empty instances / "nessun protocollo riconosciuto". No Z.
|
||||
# M0 protocol cert: empty instances / "No protocol recognized.". No Z.
|
||||
protocol_certification: dict[str, Any] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ CATALOG_DIR = Path(__file__).resolve().parent / "protocol_data"
|
||||
CATALOG_PATH = CATALOG_DIR / "catalog.json"
|
||||
SCHEMA_PATH = CATALOG_DIR / f"schema-{SCHEMA_VERSION}.json"
|
||||
|
||||
EMPTY_PROTOCOL_MESSAGE = "nessun protocollo riconosciuto"
|
||||
EMPTY_PROTOCOL_MESSAGE = "No protocol recognized."
|
||||
|
||||
BusType = Literal[
|
||||
"MEMORY",
|
||||
@@ -582,9 +582,19 @@ DOC_USB_LSFS = (
|
||||
"Universal Serial Bus Specification Revision 2.0 §7 (field not a single PCB number)"
|
||||
)
|
||||
DOC_USB_PD_VSAFE = (
|
||||
"USB Power Delivery specification vSafe5V (not on file; Type-C CabCon R2.5 names vSafe5V "
|
||||
"and tVBUSON as the minimum vSafe5V threshold without restating the volt range. "
|
||||
"Type-C Current charger 4.75–5.5 V is not vSafe5V)"
|
||||
"USB Power Delivery Specification (USB-IF; not on file). "
|
||||
"USB Type-C Cable and Connector Specification Release 2.5, March 2026 "
|
||||
"§1.6 p.35: vSafe5V is VBUS “5 volts” as defined by the USB PD specification "
|
||||
"(no volt range restated). "
|
||||
"Alta Frequenza zip snla027b.pdf (AN-807 Reflections SNLA027B–May 2004–Revised May 2004) "
|
||||
"and sdaa499.pdf (Simple Methods to Reduce EMI from a PCB Trace, SDAA499 – September 2026) "
|
||||
"do not state vSafe5V. Type-C Current charger Figure 4-39 4.75–5.5 V is not vSafe5V."
|
||||
)
|
||||
DOC_USB_PD_VSAFE0 = (
|
||||
"USB Power Delivery Specification (USB-IF; not on file). "
|
||||
"USB Type-C Cable and Connector Specification Release 2.5, March 2026 "
|
||||
"§1.6 p.35: vSafe0V is VBUS “0 volts” as defined by the USB PD specification "
|
||||
"(no volt range restated). Alta Frequenza TI SI PDFs do not state vSafe0V."
|
||||
)
|
||||
DOC_IEEE_8023 = "IEEE 802.3 (clause for this variant; not on file)"
|
||||
DOC_IEEE_2012 = "IEEE Std 802.3-2012 IEEE Standard for Ethernet"
|
||||
@@ -1173,6 +1183,10 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
pid, "vsafe5v", "UNKNOWN",
|
||||
needed_document=DOC_USB_PD_VSAFE,
|
||||
))
|
||||
out.append(_c(
|
||||
pid, "vsafe0v", "UNKNOWN",
|
||||
needed_document=DOC_USB_PD_VSAFE0,
|
||||
))
|
||||
return out
|
||||
|
||||
def _eth_cons(pid: str, lid: str) -> list[dict[str, Any]]:
|
||||
@@ -1949,7 +1963,7 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
hints=["USB_C", "TYPE_C", "CC1", "CC2"],
|
||||
notes=(
|
||||
"USB Type-C connector/interface system. Not USB 2.0/3.x/USB4 protocol. "
|
||||
"Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. VBUS volts still USB PD vSafe5V."
|
||||
"Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. Figure 4-39 charger volts are not vSafe5V. USB PD vSafe still not on file."
|
||||
),
|
||||
))
|
||||
usb2_over_c = _usb2_cons("usb-c-usb2-receptacle", speed="hs")
|
||||
|
||||
@@ -2930,10 +2930,25 @@
|
||||
"conditions": [],
|
||||
"value_origin": "SPEC",
|
||||
"typical": false,
|
||||
"needed_document": "USB Power Delivery specification vSafe5V (not on file; Type-C CabCon R2.5 names vSafe5V and tVBUSON as the minimum vSafe5V threshold without restating the volt range. Type-C Current charger 4.75\u20135.5 V is not vSafe5V)"
|
||||
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe5V is VBUS \u201c5 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza zip snla027b.pdf (AN-807 Reflections SNLA027B\u2013May 2004\u2013Revised May 2004) and sdaa499.pdf (Simple Methods to Reduce EMI from a PCB Trace, SDAA499 \u2013 September 2026) do not state vSafe5V. Type-C Current charger Figure 4-39 4.75\u20135.5 V is not vSafe5V."
|
||||
},
|
||||
{
|
||||
"id": "usb-c-receptacle-vsafe0v",
|
||||
"parameter": "vsafe0v",
|
||||
"value_kind": "UNKNOWN",
|
||||
"mandatory": "MANDATORY",
|
||||
"source_type": "STANDARD",
|
||||
"source_class": "NORMATIVE",
|
||||
"value": null,
|
||||
"unit": null,
|
||||
"source": null,
|
||||
"conditions": [],
|
||||
"value_origin": "SPEC",
|
||||
"typical": false,
|
||||
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe0V is VBUS \u201c0 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza TI SI PDFs do not state vSafe0V."
|
||||
}
|
||||
],
|
||||
"notes": "USB Type-C connector/interface system. Not USB 2.0/3.x/USB4 protocol. Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. VBUS volts still USB PD vSafe5V.",
|
||||
"notes": "USB Type-C connector/interface system. Not USB 2.0/3.x/USB4 protocol. Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. Figure 4-39 charger volts are not vSafe5V. USB PD vSafe still not on file.",
|
||||
"incomplete": false
|
||||
},
|
||||
{
|
||||
@@ -3902,7 +3917,22 @@
|
||||
"conditions": [],
|
||||
"value_origin": "SPEC",
|
||||
"typical": false,
|
||||
"needed_document": "USB Power Delivery specification vSafe5V (not on file; Type-C CabCon R2.5 names vSafe5V and tVBUSON as the minimum vSafe5V threshold without restating the volt range. Type-C Current charger 4.75\u20135.5 V is not vSafe5V)"
|
||||
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe5V is VBUS \u201c5 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza zip snla027b.pdf (AN-807 Reflections SNLA027B\u2013May 2004\u2013Revised May 2004) and sdaa499.pdf (Simple Methods to Reduce EMI from a PCB Trace, SDAA499 \u2013 September 2026) do not state vSafe5V. Type-C Current charger Figure 4-39 4.75\u20135.5 V is not vSafe5V."
|
||||
},
|
||||
{
|
||||
"id": "usb-c-usb2-receptacle-vsafe0v",
|
||||
"parameter": "vsafe0v",
|
||||
"value_kind": "UNKNOWN",
|
||||
"mandatory": "MANDATORY",
|
||||
"source_type": "STANDARD",
|
||||
"source_class": "NORMATIVE",
|
||||
"value": null,
|
||||
"unit": null,
|
||||
"source": null,
|
||||
"conditions": [],
|
||||
"value_origin": "SPEC",
|
||||
"typical": false,
|
||||
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe0V is VBUS \u201c0 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza TI SI PDFs do not state vSafe0V."
|
||||
},
|
||||
{
|
||||
"id": "usb-c-usb2-receptacle-cc_rd_rp",
|
||||
|
||||
@@ -13,8 +13,18 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import (
|
||||
normalize_kicad_hierarchy_net,
|
||||
refs_on_matched_net,
|
||||
)
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
SCHEMA_VERSION,
|
||||
PhysicalInterface,
|
||||
@@ -47,7 +57,8 @@ L0_CHECKS = frozenset({
|
||||
"superspeed_pairs",
|
||||
})
|
||||
|
||||
_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I)
|
||||
_OHM_EMBEDDED = re.compile(r"^(\d+)([RKMG])(\d+)$", re.I)
|
||||
_OHM_SUFFIX = re.compile(r"^(\d*\.?\d+)([RKMG])$", re.I)
|
||||
_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I)
|
||||
_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I)
|
||||
_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I)
|
||||
@@ -160,6 +171,156 @@ def certify_instance_l0(
|
||||
return out
|
||||
|
||||
|
||||
def _parse_ohms(raw: str | None) -> float | None:
|
||||
t = (raw or "").strip().upper().replace("OHMS", "").replace("OHM", "")
|
||||
t = t.replace("Ω", "").replace(" ", "")
|
||||
if not t:
|
||||
return None
|
||||
m = _OHM_EMBEDDED.match(t)
|
||||
if m:
|
||||
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
|
||||
return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult
|
||||
m = _OHM_SUFFIX.match(t)
|
||||
if m:
|
||||
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
|
||||
return float(m.group(1)) * mult
|
||||
try:
|
||||
return float(t)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _resistor_ohms(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
|
||||
return float(specs.value_ohms)
|
||||
return _parse_ohms(comp.value)
|
||||
|
||||
|
||||
def _rd_cite() -> tuple[float | None, str]:
|
||||
"""CabCon Rd already packed. No other USB number is introduced here."""
|
||||
cat = load_catalog()
|
||||
chosen = None
|
||||
fallback = None
|
||||
for iface in cat.physical_interfaces:
|
||||
if iface.id not in {"usb-c-usb2-receptacle", "usb-c-receptacle"}:
|
||||
continue
|
||||
for c in iface.constraints:
|
||||
if c.parameter == "rd" and c.value is not None:
|
||||
if iface.id == "usb-c-usb2-receptacle":
|
||||
chosen = c
|
||||
elif fallback is None:
|
||||
fallback = c
|
||||
chosen = chosen or fallback
|
||||
if chosen is None or chosen.value is None:
|
||||
return None, "CabCon Rd cite is not in the protocol pack."
|
||||
src = chosen.source
|
||||
bits = []
|
||||
if src and src.document:
|
||||
bits.append(src.document)
|
||||
if src and src.revision:
|
||||
bits.append(src.revision)
|
||||
if src and src.section:
|
||||
bits.append(f"§{src.section}")
|
||||
if src and src.table:
|
||||
bits.append(src.table)
|
||||
if src and src.page:
|
||||
bits.append(f"p.{src.page}")
|
||||
nominal = "nominal 5.1 kΩ" if any(
|
||||
"5.1" in (cond or "") for cond in (chosen.conditions or [])
|
||||
) else f"{chosen.value:g} {chosen.unit or 'ohm'}"
|
||||
cite = ", ".join(bits) if bits else chosen.id
|
||||
return float(chosen.value), f"{cite}; {nominal}"
|
||||
|
||||
|
||||
def _cc_pin_pairs(comp: Component) -> list[tuple[str, str | None]]:
|
||||
"""Receptacle CC pins. A5/B5 are the Type-C CC contacts, not net names."""
|
||||
pins = {str(k).upper(): v for k, v in comp.pins.items()}
|
||||
if "A5" in pins or "B5" in pins:
|
||||
return [("A5", pins.get("A5")), ("B5", pins.get("B5"))]
|
||||
if "CC1" in pins or "CC2" in pins:
|
||||
return [("CC1", pins.get("CC1")), ("CC2", pins.get("CC2"))]
|
||||
return []
|
||||
|
||||
|
||||
def _typec_receptacle(graph: DesignGraph, inst: PhysicalBusInstance) -> Component | None:
|
||||
found: list[Component] = []
|
||||
for comp in graph.components.values():
|
||||
if comp.component_type != ComponentType.CONNECTOR:
|
||||
continue
|
||||
if _cc_pin_pairs(comp):
|
||||
found.append(comp)
|
||||
if not found:
|
||||
return None
|
||||
for comp in found:
|
||||
if comp.reference == inst.host_ref:
|
||||
return comp
|
||||
inst_nets = set(inst.nets)
|
||||
for g in inst.groups:
|
||||
inst_nets.update(g.nets)
|
||||
for comp in found:
|
||||
if inst_nets.intersection(v for v in comp.pins.values() if v):
|
||||
return comp
|
||||
return found[0]
|
||||
|
||||
|
||||
def _rd_refs_on_net(graph: DesignGraph, net: str, nominal: float) -> list[str]:
|
||||
"""Resistors on this net whose value is the packed Rd (float slack 1 Ω)."""
|
||||
hits: list[str] = []
|
||||
for ref in refs_on_matched_net(graph, net):
|
||||
comp = graph.components.get(ref)
|
||||
if comp is None or comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
ohms = _resistor_ohms(comp)
|
||||
if ohms is not None and abs(ohms - nominal) <= 1.0:
|
||||
hits.append(ref)
|
||||
return hits
|
||||
|
||||
|
||||
def _cc_rd_on_pins(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
mand: MandatoryClass,
|
||||
) -> L0CheckResult:
|
||||
"""Pin → net → Rd. The net does not have to be named CC1/CC2."""
|
||||
nominal, cite = _rd_cite()
|
||||
jack = _typec_receptacle(graph, inst)
|
||||
if jack is None or nominal is None:
|
||||
return _pack(
|
||||
"cc_rd_rp", "FAIL", mand,
|
||||
notes=(
|
||||
"Type-C CC pins were not followed to an Rd. "
|
||||
f"{cite}"
|
||||
),
|
||||
)
|
||||
pairs = _cc_pin_pairs(jack)
|
||||
nets: list[str] = []
|
||||
missing: list[str] = []
|
||||
present: list[str] = []
|
||||
for pin, net in pairs:
|
||||
if not net:
|
||||
missing.append(f"{jack.reference}.{pin} has no net")
|
||||
continue
|
||||
nets.append(net)
|
||||
refs = _rd_refs_on_net(graph, net, nominal)
|
||||
if refs:
|
||||
present.append(f"{jack.reference}.{pin} net {net} Rd {', '.join(refs)}")
|
||||
else:
|
||||
missing.append(
|
||||
f"{jack.reference}.{pin} net {net} has no {nominal:g} Ω Rd"
|
||||
)
|
||||
if missing or len(present) < 2:
|
||||
detail = "; ".join(missing) if missing else "both CC pins need an Rd"
|
||||
return _pack(
|
||||
"cc_rd_rp", "FAIL", mand, nets=nets,
|
||||
notes=f"{detail}. {cite}. Net name is not required.",
|
||||
)
|
||||
return _pack(
|
||||
"cc_rd_rp", "PASS", mand, nets=nets,
|
||||
notes=f"{'; '.join(present)}. {cite}.",
|
||||
)
|
||||
|
||||
|
||||
def _run_l0_check(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
@@ -212,11 +373,7 @@ def _run_l0_check(
|
||||
ok, msg = _structural_nets_ok(graph, clocks)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg)
|
||||
if check == "cc_rd_rp":
|
||||
ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))]
|
||||
if len(ccs) < 1:
|
||||
return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.")
|
||||
ok, msg = _structural_nets_ok(graph, ccs)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg)
|
||||
return _cc_rd_on_pins(graph, inst, mand)
|
||||
if check == "vbus_gnd":
|
||||
vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))]
|
||||
gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))]
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -220,6 +221,14 @@ async def run_pcb_pipeline(
|
||||
pcb_state=None,
|
||||
)
|
||||
|
||||
t_total = time.perf_counter()
|
||||
stage_seconds: dict[str, float] = {}
|
||||
|
||||
def _lap(name: str, started: float) -> float:
|
||||
seconds = round(time.perf_counter() - started, 3)
|
||||
stage_seconds[name] = seconds
|
||||
return seconds
|
||||
|
||||
try:
|
||||
async with PipelineWorkspace(storage, user_id, project_id) as ws:
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -247,7 +256,10 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "classify", "running", "domains and groups")
|
||||
t_extract = time.perf_counter()
|
||||
cmap = _load_constraints_map(ws.local_path("extracted"), storage)
|
||||
extract_s = _lap("extract", t_extract)
|
||||
_step(project_id, "extract", "complete", f"loaded stored extractions; {extract_s}s")
|
||||
plan = build_placement_plan(graph, cmap)
|
||||
fg_path = ws.local_path("functional_groups.json")
|
||||
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
|
||||
@@ -306,7 +318,9 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "checks", "running")
|
||||
t_checks = time.perf_counter()
|
||||
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
|
||||
checks_s = _lap("pcb_checks", t_checks)
|
||||
scan_ver = app_settings.get_default_model_version()
|
||||
si_skip = si_extract_needed_skips(graph, cmap, scan_ver)
|
||||
sipath = ws.local_path("si_extract_needed.json")
|
||||
@@ -315,7 +329,8 @@ async def run_pcb_pipeline(
|
||||
_step(
|
||||
project_id, "checks", "complete",
|
||||
f"{len(findings)} deterministic findings"
|
||||
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
|
||||
+ (f"; {len(si_skip)} SI re-extract" if si_skip else "")
|
||||
+ f"; {checks_s}s",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -323,6 +338,7 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "af_trace", "running", "AF trigger + closed-form/cascade (not OpenEMS)")
|
||||
t_af_trace = time.perf_counter()
|
||||
try:
|
||||
extra_af = check_af_traces(graph, cmap, layout, zrep, findings)
|
||||
findings.extend(extra_af)
|
||||
@@ -330,9 +346,10 @@ async def run_pcb_pipeline(
|
||||
except Exception:
|
||||
logger.exception("AF trace analysis failed — keeping PCB checks")
|
||||
n_trace = 0
|
||||
af_trace_s = _lap("af_trace", t_af_trace)
|
||||
_step(
|
||||
project_id, "af_trace", "complete",
|
||||
f"{n_trace} AF trace findings (SI/HF checks kept)",
|
||||
f"{n_trace} AF trace findings (SI/HF checks kept); {af_trace_s}s",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -340,10 +357,13 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "af_ai", "running", "AI HF flags then deterministic investigate")
|
||||
t_af_ai = time.perf_counter()
|
||||
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
|
||||
af_ai_s = _lap("af_ai", t_af_ai)
|
||||
stage_seconds["af"] = round(stage_seconds.get("af_trace", 0) + af_ai_s, 3)
|
||||
_step(
|
||||
project_id, "af_ai", "complete",
|
||||
f"{n_af} extra AF+AI findings (SI/HF checks kept)",
|
||||
f"{n_af} extra AF+AI findings (SI/HF checks kept); {af_ai_s}s; af={stage_seconds['af']}s",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -387,11 +407,14 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "write_report", "running")
|
||||
t_protocol = time.perf_counter()
|
||||
proto_section, proto_findings = protocol_exam(
|
||||
graph, layout=layout,
|
||||
constraints_map=cmap, impedance_nets=zrep,
|
||||
channel_data=_load_channel_data(ws),
|
||||
)
|
||||
protocol_s = _lap("protocol", t_protocol)
|
||||
_step(project_id, "protocol", "complete", f"{protocol_s}s")
|
||||
findings.extend(proto_findings)
|
||||
assign_pcb_finding_ids(findings)
|
||||
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
||||
@@ -422,10 +445,22 @@ async def run_pcb_pipeline(
|
||||
ws._upload_file("periscope-findings.json")
|
||||
_step(project_id, "write_report", "complete", f"{len(findings)} findings")
|
||||
|
||||
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
|
||||
_step(
|
||||
project_id, "timing", "complete",
|
||||
"extract={extract}s pcb_checks={pcb_checks}s protocol={protocol}s af={af}s total={total}s".format(
|
||||
extract=stage_seconds.get("extract"),
|
||||
pcb_checks=stage_seconds.get("pcb_checks"),
|
||||
protocol=stage_seconds.get("protocol"),
|
||||
af=stage_seconds.get("af"),
|
||||
total=stage_seconds.get("total"),
|
||||
),
|
||||
)
|
||||
_publish(project_id, "pcb_complete", {
|
||||
"findings": len(findings),
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
"stage_seconds": stage_seconds,
|
||||
})
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
@@ -435,15 +470,17 @@ async def run_pcb_pipeline(
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
"skipped": len(skipped),
|
||||
"stage_seconds": stage_seconds,
|
||||
},
|
||||
pcb_cancel_requested=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("pcb pipeline failed for %s", project_id)
|
||||
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="error",
|
||||
pcb_state={"error": str(e)},
|
||||
pcb_state={"error": str(e), "stage_seconds": stage_seconds},
|
||||
)
|
||||
_publish(project_id, "pcb_error", {"error": str(e)})
|
||||
|
||||
|
||||
@@ -2,14 +2,42 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.83.0 — 2026-09-22 — Apple-like shell, findings tree, HDMI 1.4 UNKNOWN
|
||||
## 2.85.0 — 2026-09-23 — iPad top-bar shell; Markdown report export
|
||||
|
||||
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff. Catalog JSON untouched.
|
||||
Replace the left sidebar with a thin top toolbar. Navigation is tabs (and sheets on narrow widths) from that bar. Light theme is the default; dark mode lives in Settings and persists on the device. Report stays the findings tree. Export is Save as Markdown (.md), not Excel.
|
||||
|
||||
- [Changed] App shell: `TopBar` instead of sidebar column (`layout`, `top-bar`).
|
||||
- [Changed] Theme default `light`; `storageKey=periscope-theme`; Appearance in Settings sheet + project Settings tab.
|
||||
- [Changed] Report export: `exportReportToMarkdown` / `buildReportMarkdown` (tree hierarchy as Markdown lists).
|
||||
- [Changed] English chrome labels on the top bar (Projects, Exam, Report, Settings).
|
||||
- [Changed] Frontend contract tests for shell, theme, and Markdown export.
|
||||
|
||||
## 2.84.0 — 2026-09-22 — Exposed-pad names, ESD cites, CC-via-net, Ethernet pair Z
|
||||
|
||||
PE-BOM-011 treats EP, EPAD, THERMAL PAD, exposed pad, and the footprint's exposed land (including pin_count+1) as the same pad when both sides have one. A datasheet exposed pad with no land on the footprint stays ERROR.
|
||||
|
||||
PE-ESD-001 says "datasheet-specified" only with a part and section/page. A 3V3 jack rail and VBUS are not warned without that cite. Optical S/PDIF (TOSLINK/optocoupler) is not an ESD warning; an unverified S/PDIF path stays a visible warning.
|
||||
|
||||
PE-PRT-L0-001 follows Type-C CC pins (A5/B5) to the net and looks for the packed CabCon Rd 5.1 kΩ. The net does not have to be named CC1/CC2.
|
||||
|
||||
PE-AF-002 on an Ethernet MDI pair compares one calculated pair Z to the packed cite for the speed on the board (LAN8720A 10/100 → 100 Ω, IEEE 100BASE-TX 25.4.9; Clause 40 only if gigabit). Inside is PASS, outside is FAIL, and no calculation is ERROR. No tr/f request.
|
||||
|
||||
- [Changed] `bom_pcb_check.py`, `esd_return_check.py`, `protocol_l0.py`, `af_trace_check.py`.
|
||||
- [Changed] pytest `tests/pcb/test_bom_esd_ep_cc_eth.py`.
|
||||
- [Changed] Findings tree labels are English: domains, Certifications, Protocols, actions, and empty states. Error / Warning / Info stay.
|
||||
|
||||
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
|
||||
|
||||
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
|
||||
|
||||
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.75–5.5 V stays not-vSafe.
|
||||
|
||||
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
|
||||
- [Changed] Findings tree grouping + protocol section inside the tree.
|
||||
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
|
||||
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
|
||||
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
|
||||
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
|
||||
|
||||
## 2.82.0 — 2026-09-22 — HDMI 2.0+ TI fields use source_type VENDOR
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "periscope-web",
|
||||
"version": "2.83.0",
|
||||
"version": "2.85.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
@@ -10,7 +10,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts"
|
||||
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts src/lib/report-export.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
||||
import { CreditsProvider } from "@/components/billing/credits-context";
|
||||
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
|
||||
import { AuthGate } from "@/components/layout/auth-gate";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
export default function AppShellLayout({
|
||||
@@ -12,8 +12,8 @@ export default function AppShellLayout({
|
||||
<TooltipProvider>
|
||||
<CreditsProvider>
|
||||
<AuthGate>
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<div className="flex h-full flex-col">
|
||||
<TopBar />
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { CollaboratorsSection } from "@/components/project/collaborators-section
|
||||
import { SkippedComponentsSection } from "@/components/project/skipped-components-section";
|
||||
import { ReportVersionSection } from "@/components/project/report-version-section";
|
||||
import { PipelineErrorBanner } from "@/components/project/pipeline-error-banner";
|
||||
import { AppearanceSettings } from "@/components/theme/appearance-settings";
|
||||
import {
|
||||
deratingOverridesKey,
|
||||
deratingSettingsKey,
|
||||
@@ -518,6 +519,9 @@ export default function ProjectDetailPage({
|
||||
|
||||
{tab === "settings" && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[11px] border border-black/10 bg-white px-5 py-4 dark:border-white/10 dark:bg-[#2c2c2e]">
|
||||
<AppearanceSettings />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">KiCad PCB</CardTitle>
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
signReport,
|
||||
downloadEcoCsv,
|
||||
} from "@/lib/api";
|
||||
import { exportReportToExcel } from "@/lib/report-export";
|
||||
import { exportReportToMarkdown } from "@/lib/report-export";
|
||||
import { cn, getFindingKey } from "@/lib/utils";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
|
||||
|
||||
@@ -260,10 +260,13 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportReportToExcel(report, graph, projectName)}
|
||||
disabled={report.findings.length === 0}
|
||||
onClick={() => exportReportToMarkdown(report, graph, projectName)}
|
||||
disabled={
|
||||
report.findings.length === 0 &&
|
||||
!(report.protocol_certification?.recognized_instances?.length)
|
||||
}
|
||||
>
|
||||
<Download /> Export Excel
|
||||
<Download /> Save as Markdown
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -343,9 +346,9 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
{report.summary.total === 0 &&
|
||||
!(report.protocol_certification?.recognized_instances?.length) ? (
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
|
||||
<p className="text-[15px] font-medium tracking-tight">Nessun finding</p>
|
||||
<p className="text-[15px] font-medium tracking-tight">No findings</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
Carica i datasheet degli IC sulla pagina progetto e rilancia l’esame.
|
||||
Upload the IC datasheets on the project page and run the exam again.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SidebarCredits() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SidebarUserButton() {
|
||||
export function SidebarUserButton({ compact = false }: { compact?: boolean }) {
|
||||
const { user, isLoaded } = useOptionalUser();
|
||||
const { signOut, isSignedIn } = useOptionalAuth();
|
||||
|
||||
@@ -26,6 +26,16 @@ export function SidebarUserButton() {
|
||||
}
|
||||
|
||||
if (!isSignedIn || !user) {
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className={cn(buttonVariants({ size: "sm", variant: "outline" }), "h-7 px-2 text-[11px]")}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<Link
|
||||
@@ -44,6 +54,22 @@ export function SidebarUserButton() {
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={user.email || user.name || "Account"}
|
||||
className="max-w-[120px] truncate rounded-[8px] px-2 py-1 text-[11px] text-neutral-600 hover:bg-black/[0.04] dark:text-neutral-300 dark:hover:bg-white/[0.06]"
|
||||
onClick={() => {
|
||||
signOut?.();
|
||||
window.location.href = "/sign-in";
|
||||
}}
|
||||
>
|
||||
{user.name || user.email}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-2 py-1">
|
||||
<div className="truncate text-xs font-medium">{user.name || user.email}</div>
|
||||
|
||||
@@ -1,294 +1,2 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
Folder,
|
||||
Library,
|
||||
Loader2,
|
||||
MessageSquareWarning,
|
||||
ScanLine,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
import { useAuthApi } from "@/hooks/use-auth-api";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { fetchProject } from "@/lib/api";
|
||||
import type { Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
function useProjectFromPath(pathname: string): {
|
||||
projectId: string | null;
|
||||
project: Project | null;
|
||||
} {
|
||||
const match = pathname.match(/^\/project\/([^/]+)/);
|
||||
const projectId = match ? match[1] : null;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setProject(null);
|
||||
return;
|
||||
}
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => setProject(null));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || project?.status !== "running") return;
|
||||
const interval = setInterval(() => {
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => {
|
||||
/* keep last known project */
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [projectId, project?.status]);
|
||||
|
||||
return { projectId, project };
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useOptionalUser();
|
||||
useAuthApi();
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const { projectId, project } = useProjectFromPath(pathname);
|
||||
const isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return (
|
||||
<aside className="flex w-[196px] shrink-0 flex-col border-r border-black/10 bg-[#f5f5f7]/90 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90">
|
||||
<div className="border-b border-black/10 px-3 py-3 dark:border-white/10">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<PeriscopeMark className="h-5 w-5" />
|
||||
<span className="text-[13px] font-semibold tracking-tight">Periscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{projectId ? (
|
||||
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
|
||||
<ProjectNav
|
||||
pathname={pathname}
|
||||
projectId={projectId}
|
||||
project={project}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<DefaultNav pathname={pathname} isAdmin={isAdmin} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-black/10 dark:border-white/10">
|
||||
<SidebarCredits />
|
||||
<div className="border-b border-black/[0.06] px-2 py-1.5 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
className="flex w-full items-center gap-2 rounded-[8px] px-2 py-1.5 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900"
|
||||
>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<SidebarUserButton />
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Link>
|
||||
<ThemeToggle className="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function navItemClass(active: boolean) {
|
||||
return cn(
|
||||
"flex items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] transition-colors duration-150 ease-out",
|
||||
active
|
||||
? "bg-[#007aff]/12 text-[#007aff]"
|
||||
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
|
||||
return (
|
||||
<nav className="flex-1 space-y-0.5 px-2 py-3">
|
||||
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Progetti
|
||||
</Link>
|
||||
<Link href="/library" className={navItemClass(pathname === "/library")}>
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Libreria
|
||||
</Link>
|
||||
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
type NavItem =
|
||||
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
|
||||
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
|
||||
|
||||
const PRIMARY_NAV: NavItem[] = [
|
||||
{ type: "tab", tab: "bom", label: "Esame", icon: ScanLine },
|
||||
{ type: "route", path: "/report", label: "Protocolli", icon: BookOpen },
|
||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||
];
|
||||
|
||||
const SECONDARY_NAV: NavItem[] = [
|
||||
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
|
||||
{ type: "tab", tab: "settings", label: "Impostazioni", icon: Settings },
|
||||
];
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
forceDocument,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
forceDocument?: boolean;
|
||||
}) {
|
||||
const className = navItemClass(Boolean(active));
|
||||
if (forceDocument) {
|
||||
return (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectNav({
|
||||
pathname,
|
||||
projectId,
|
||||
project,
|
||||
isAdmin,
|
||||
}: {
|
||||
pathname: string;
|
||||
projectId: string;
|
||||
project: Project | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const base = `/project/${projectId}`;
|
||||
const currentTab = searchParams.get("tab");
|
||||
const isRunning = project?.status === "running";
|
||||
const isOnProgress = pathname === `${base}/progress`;
|
||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||
const onReport = pathname === `${base}/report`;
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.label === "Protocolli") return onReport;
|
||||
if (item.label === "Report") return onReport;
|
||||
if (item.type === "route") {
|
||||
return pathname === `${base}${item.path}` && !currentTab;
|
||||
}
|
||||
if (item.tab === "bom") {
|
||||
return pathname === base && (currentTab === "bom" || currentTab === null);
|
||||
}
|
||||
return pathname === base && currentTab === item.tab;
|
||||
}
|
||||
|
||||
function getHref(item: NavItem): string {
|
||||
if (item.label === "Protocolli") return `${base}/report`;
|
||||
if (item.type === "route") return `${base}${item.path}`;
|
||||
return `${base}?tab=${item.tab}`;
|
||||
}
|
||||
|
||||
function renderItem(item: NavItem) {
|
||||
const active = isActive(item);
|
||||
const href = getHref(item);
|
||||
const forceDocument = item.type === "tab" && onNestedRoute;
|
||||
if (isRunning) {
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className="flex cursor-not-allowed items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] text-neutral-400"
|
||||
>
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex-1 space-y-1 px-2 py-3">
|
||||
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Progetti
|
||||
</NavLink>
|
||||
<NavLink href="/library" forceDocument={onNestedRoute}>
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Libreria
|
||||
</NavLink>
|
||||
|
||||
<p className="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
|
||||
{project?.name ?? "…"}
|
||||
</p>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{isRunning && (
|
||||
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Esame in corso
|
||||
</NavLink>
|
||||
)}
|
||||
{PRIMARY_NAV.map(renderItem)}
|
||||
</div>
|
||||
<div className="mt-3 space-y-0.5 border-t border-black/10 pt-2 dark:border-white/10">
|
||||
{SECONDARY_NAV.map(renderItem)}
|
||||
{isAdmin ? (
|
||||
<NavLink href={`${base}?tab=logs`} forceDocument={onNestedRoute}>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Log
|
||||
</NavLink>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
/** @deprecated Left sidebar removed — iPad top bar is the shell. Kept for import stability. */
|
||||
export { TopBar as Sidebar } from "./top-bar";
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
Folder,
|
||||
Library,
|
||||
Loader2,
|
||||
Menu,
|
||||
MessageSquareWarning,
|
||||
ScanLine,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
|
||||
import { AppearanceSettings } from "@/components/theme/appearance-settings";
|
||||
import { useAuthApi } from "@/hooks/use-auth-api";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { fetchProject } from "@/lib/api";
|
||||
import type { Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
function useProjectFromPath(pathname: string): {
|
||||
projectId: string | null;
|
||||
project: Project | null;
|
||||
} {
|
||||
const match = pathname.match(/^\/project\/([^/]+)/);
|
||||
const projectId = match ? match[1] : null;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setProject(null);
|
||||
return;
|
||||
}
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => setProject(null));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || project?.status !== "running") return;
|
||||
const interval = setInterval(() => {
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => {
|
||||
/* keep last known project */
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [projectId, project?.status]);
|
||||
|
||||
return { projectId, project };
|
||||
}
|
||||
|
||||
type NavItem =
|
||||
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
|
||||
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
|
||||
|
||||
const PRIMARY_NAV: NavItem[] = [
|
||||
{ type: "tab", tab: "bom", label: "Exam", icon: ScanLine },
|
||||
{ type: "route", path: "/report", label: "Protocols", icon: BookOpen },
|
||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||
];
|
||||
|
||||
const SECONDARY_NAV: NavItem[] = [
|
||||
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
|
||||
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
function tabClass(active: boolean) {
|
||||
return cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] transition-colors duration-150 ease-out",
|
||||
active
|
||||
? "bg-[#007aff]/12 text-[#007aff]"
|
||||
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
forceDocument,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
forceDocument?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const cls = className ?? tabClass(Boolean(active));
|
||||
if (forceDocument) {
|
||||
return (
|
||||
<a href={href} className={cls}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={cls}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Thin iPad-style top toolbar. Replaces the left sidebar column. */
|
||||
export function TopBar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useOptionalUser();
|
||||
useAuthApi();
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const { projectId, project } = useProjectFromPath(pathname);
|
||||
const isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 flex h-11 shrink-0 items-center gap-2 border-b border-black/10 bg-[#f5f5f7]/90 px-2 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90 sm:px-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex shrink-0 items-center gap-1.5 pr-1"
|
||||
aria-label="Periscope home"
|
||||
>
|
||||
<PeriscopeMark className="h-5 w-5" />
|
||||
<span className="hidden text-[13px] font-semibold tracking-tight sm:inline">
|
||||
Periscope
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
{projectId ? (
|
||||
<Suspense fallback={<div className="h-7" aria-hidden />}>
|
||||
<ProjectTabs
|
||||
pathname={pathname}
|
||||
projectId={projectId}
|
||||
project={project}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<DefaultTabs pathname={pathname} isAdmin={isAdmin} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<SidebarCredits />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
className="hidden items-center gap-1 rounded-[8px] px-2 py-1 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900 sm:inline-flex dark:hover:bg-white/[0.06]"
|
||||
aria-label="Feedback"
|
||||
>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
<span className="hidden md:inline">Feedback</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="hidden text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900 sm:inline dark:hover:text-white"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={tabClass(false)}
|
||||
aria-label="Open settings"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
<span className="hidden lg:inline">Settings</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className={cn(tabClass(false), "sm:hidden")}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="hidden sm:block">
|
||||
<SidebarUserButton compact />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Sheet open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-sm">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Settings</SheetTitle>
|
||||
<SheetDescription>Appearance and account.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="space-y-6 px-4 pb-6">
|
||||
<AppearanceSettings />
|
||||
<div className="sm:hidden">
|
||||
<SidebarUserButton />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[12px] text-neutral-500">
|
||||
<Link href="/changelog" className="hover:text-neutral-900 dark:hover:text-white">
|
||||
Changelog
|
||||
</Link>
|
||||
<span className="tabular-nums">v{APP_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<SheetContent side="top" className="max-h-[70vh]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
<SheetDescription>Navigate Periscope.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-1 px-4 pb-6" onClick={() => setMenuOpen(false)}>
|
||||
<NavLink href="/dashboard" className={tabClass(pathname === "/dashboard")}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</NavLink>
|
||||
<NavLink href="/library" className={tabClass(pathname === "/library")}>
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Library
|
||||
</NavLink>
|
||||
<NavLink href="/feedback" className={tabClass(pathname === "/feedback")}>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
href="/admin"
|
||||
className={tabClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
{projectId ? (
|
||||
<>
|
||||
<p className="truncate px-2.5 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
|
||||
{project?.name ?? "…"}
|
||||
</p>
|
||||
<NavLink href={`/project/${projectId}?tab=bom`}>Exam</NavLink>
|
||||
<NavLink href={`/project/${projectId}/report`}>Report</NavLink>
|
||||
<NavLink href={`/project/${projectId}?tab=settings`}>Project settings</NavLink>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultTabs({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
|
||||
return (
|
||||
<nav className="flex items-center gap-0.5" aria-label="Main">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</NavLink>
|
||||
<NavLink href="/library" active={pathname === "/library"}>
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Library
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href="/feedback"
|
||||
active={pathname === "/feedback"}
|
||||
className={cn(tabClass(pathname === "/feedback"), "hidden md:inline-flex")}
|
||||
>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
href="/admin"
|
||||
active={pathname === "/admin" || pathname.startsWith("/admin/")}
|
||||
className={cn(
|
||||
tabClass(pathname === "/admin" || pathname.startsWith("/admin/")),
|
||||
"hidden lg:inline-flex",
|
||||
)}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectTabs({
|
||||
pathname,
|
||||
projectId,
|
||||
project,
|
||||
isAdmin,
|
||||
}: {
|
||||
pathname: string;
|
||||
projectId: string;
|
||||
project: Project | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const base = `/project/${projectId}`;
|
||||
const currentTab = searchParams.get("tab");
|
||||
const isRunning = project?.status === "running";
|
||||
const isOnProgress = pathname === `${base}/progress`;
|
||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||
const onReport = pathname === `${base}/report`;
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.label === "Protocols") return onReport;
|
||||
if (item.label === "Report") return onReport;
|
||||
if (item.type === "route") {
|
||||
return pathname === `${base}${item.path}` && !currentTab;
|
||||
}
|
||||
if (item.tab === "bom") {
|
||||
return pathname === base && (currentTab === "bom" || currentTab === null);
|
||||
}
|
||||
return pathname === base && currentTab === item.tab;
|
||||
}
|
||||
|
||||
function getHref(item: NavItem): string {
|
||||
if (item.label === "Protocols") return `${base}/report`;
|
||||
if (item.type === "route") return `${base}${item.path}`;
|
||||
return `${base}?tab=${item.tab}`;
|
||||
}
|
||||
|
||||
function renderItem(item: NavItem) {
|
||||
const active = isActive(item);
|
||||
const href = getHref(item);
|
||||
const forceDocument = item.type === "tab" && onNestedRoute;
|
||||
if (isRunning) {
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] text-neutral-400"
|
||||
>
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-0.5" aria-label="Project">
|
||||
<NavLink href="/dashboard" forceDocument={onNestedRoute} className={cn(tabClass(false), "hidden sm:inline-flex")}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</NavLink>
|
||||
<span className="hidden max-w-[140px] truncate px-1 text-[11px] font-medium text-neutral-500 md:inline">
|
||||
{project?.name ?? "…"}
|
||||
</span>
|
||||
{isRunning && (
|
||||
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Running
|
||||
</NavLink>
|
||||
)}
|
||||
{PRIMARY_NAV.map(renderItem)}
|
||||
<span className="mx-0.5 hidden h-4 w-px bg-black/10 sm:inline dark:bg-white/10" aria-hidden />
|
||||
{SECONDARY_NAV.map(renderItem)}
|
||||
{isAdmin ? (
|
||||
<NavLink
|
||||
href={`${base}?tab=logs`}
|
||||
forceDocument={onNestedRoute}
|
||||
className={cn(tabClass(pathname === base && currentTab === "logs"), "hidden xl:inline-flex")}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Log
|
||||
</NavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
forestHasCertifications,
|
||||
groupByWorstStatus,
|
||||
isProtocolFinding,
|
||||
protocolEmptyMessage,
|
||||
protocolInstancesFromSection,
|
||||
protocolInstancesForStatus,
|
||||
worstStatus,
|
||||
@@ -172,10 +173,10 @@ export function FindingsTree(props: FindingsTreeProps) {
|
||||
))}
|
||||
{cert ? (
|
||||
<CertificationsBranch
|
||||
{...props}
|
||||
status={status}
|
||||
instances={inst}
|
||||
findings={protocolAtStatus}
|
||||
{...props}
|
||||
/>
|
||||
) : null}
|
||||
</TreeBranch>
|
||||
@@ -267,9 +268,9 @@ function CertificationsBranch({
|
||||
} & FindingsTreeProps) {
|
||||
const all = [...findings, ...instances.map(() => ({ status }))];
|
||||
return (
|
||||
<TreeBranch label="Certificazioni" findings={all.length ? all : [{ status }]} defaultOpen>
|
||||
<TreeBranch label="Certifications" findings={all.length ? all : [{ status }]} defaultOpen>
|
||||
<TreeBranch
|
||||
label="Protocolli"
|
||||
label="Protocols"
|
||||
findings={all.length ? all : [{ status }]}
|
||||
defaultOpen
|
||||
>
|
||||
@@ -318,7 +319,7 @@ function CertificationsBranch({
|
||||
) : null}
|
||||
{instances.length === 0 && findings.length === 0 ? (
|
||||
<p className="px-2 py-2 text-[13px] text-neutral-500">
|
||||
{props.protocolSection?.message || "Nessun protocollo riconosciuto."}
|
||||
{protocolEmptyMessage(props.protocolSection?.message)}
|
||||
</p>
|
||||
) : null}
|
||||
</TreeBranch>
|
||||
@@ -329,9 +330,9 @@ function CertificationsBranch({
|
||||
export function FindingsTreeEmpty() {
|
||||
return (
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
|
||||
<p className="text-[15px] font-medium tracking-tight">Nessun finding visibile</p>
|
||||
<p className="text-[15px] font-medium tracking-tight">No visible findings</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
I filtri nascondono l’elenco. Togli un filtro, oppure apri i findings già rivisti sotto.
|
||||
Filters are hiding the list. Clear a filter, or open the reviewed findings below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ProtocolReportCheck,
|
||||
} from "@/lib/protocol-report";
|
||||
import {
|
||||
protocolEmptyMessage,
|
||||
protocolInstancesFromSection,
|
||||
type ProtocolTreeInstance,
|
||||
} from "@/lib/findings-forest";
|
||||
@@ -28,7 +29,7 @@ export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInsta
|
||||
{instance.worst ? ` · ${instance.worst}` : ""}
|
||||
</p>
|
||||
{fail ? (
|
||||
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL in testa — non sotto Warning.</p>
|
||||
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL is listed first — not under Warning.</p>
|
||||
) : null}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] tabular-nums">
|
||||
@@ -57,7 +58,7 @@ export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInsta
|
||||
</table>
|
||||
</div>
|
||||
{instance.chainExample ? (
|
||||
<p className="break-all text-[11px] text-neutral-500">Catena: {instance.chainExample}</p>
|
||||
<p className="break-all text-[11px] text-neutral-500">Chain: {instance.chainExample}</p>
|
||||
) : null}
|
||||
{checks
|
||||
.filter((c) => c.result === "FAIL" || c.skip_visible)
|
||||
@@ -113,12 +114,12 @@ export function ProtocolSection({
|
||||
section?: ProtocolCertificationSection | null;
|
||||
}) {
|
||||
const instances = protocolInstancesFromSection(section);
|
||||
const message = section?.message || "Nessun protocollo riconosciuto.";
|
||||
const message = protocolEmptyMessage(section?.message);
|
||||
const maxLevel = section?.max_level_reached || "—";
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Livello massimo {maxLevel} (L0–L3). HDMI 1.4 TMDS resta UNKNOWN — niente Zdiff inventato.
|
||||
Max level {maxLevel} (L0–L3). HDMI 1.4 TMDS stays UNKNOWN — no invented Zdiff.
|
||||
</p>
|
||||
{instances.length === 0 ? (
|
||||
<p className="text-[13px] text-neutral-600">{message}</p>
|
||||
|
||||
@@ -20,18 +20,18 @@ export function ReportSummary({
|
||||
const infos = summary.INFO ?? 0;
|
||||
const total = summary.total || errors + warnings + infos;
|
||||
const outcome =
|
||||
errors > 0 ? "Error" : warnings > 0 ? "Attenzione" : total > 0 ? "Completato" : "Senza findings";
|
||||
errors > 0 ? "Error" : warnings > 0 ? "Warning" : total > 0 ? "Complete" : "No findings";
|
||||
const bits = [
|
||||
`${errors} Error`,
|
||||
`${warnings} Warning`,
|
||||
`${infos} Info`,
|
||||
`${reviewedCount} rivisti`,
|
||||
`${reviewedCount} reviewed`,
|
||||
];
|
||||
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
|
||||
bits.push(`$${totalCostUsd.toFixed(2)}`);
|
||||
}
|
||||
if (typeof creditsSpent === "number" && creditsSpent > 0) {
|
||||
bits.push(`${creditsSpent.toFixed(2)} crediti`);
|
||||
bits.push(`${creditsSpent.toFixed(2)} credits`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Light/dark preference. Persisted by next-themes (localStorage). */
|
||||
export function AppearanceSettings({ className }: { className?: string }) {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const current = mounted ? (theme ?? resolvedTheme ?? "light") : "light";
|
||||
|
||||
return (
|
||||
<section className={cn("space-y-3", className)}>
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Appearance</h2>
|
||||
<p className="mt-0.5 text-[13px] text-neutral-500">
|
||||
Light is the default. Dark mode is optional and remembered on this device.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Color theme"
|
||||
className="inline-flex rounded-[10px] border border-black/10 bg-black/[0.03] p-0.5 dark:border-white/10 dark:bg-white/[0.06]"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "dark", label: "Dark" },
|
||||
] as const
|
||||
).map((opt) => {
|
||||
const active = current === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setTheme(opt.id)}
|
||||
className={cn(
|
||||
"rounded-[8px] px-3.5 py-1.5 text-[13px] transition-colors duration-150 ease-out",
|
||||
active
|
||||
? "bg-white text-neutral-900 shadow-sm dark:bg-[#2c2c2e] dark:text-neutral-50"
|
||||
: "text-neutral-500 hover:text-neutral-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeProvider as NextThemes } from "next-themes";
|
||||
|
||||
/** Class-based color scheme. Default is dark; OS preference is ignored. */
|
||||
/** Class-based color scheme. Light by default; preference persisted in localStorage. */
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemes
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
storageKey="periscope-theme"
|
||||
>
|
||||
{children}
|
||||
</NextThemes>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { groupByWorstStatus, groupDomains, isProtocolFinding, worstStatus } from "./findings-forest.ts";
|
||||
import { isPcbExamFinding } from "./layout-finding.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
import {
|
||||
groupByWorstStatus,
|
||||
groupDomains,
|
||||
isProtocolFinding,
|
||||
protocolEmptyMessage,
|
||||
ruleDomain,
|
||||
worstStatus,
|
||||
} from "./findings-forest";
|
||||
import { isPcbExamFinding } from "./layout-finding";
|
||||
import type { Finding } from "./types";
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||
return {
|
||||
@@ -72,6 +79,16 @@ test("Error folder lists domains and rules after status, protocol stays out of t
|
||||
assert.equal(groupDomains(groups[0].findings.filter((x) => !isProtocolFinding(x))).length, 1);
|
||||
});
|
||||
|
||||
test("protocol folder label is Protocols, including a stored Italian empty message", () => {
|
||||
const domain = ruleDomain(
|
||||
f({ designator: "J2", status: "ERROR", rule_id: "PE-PRT-L0-001", source: "protocol_l0" }),
|
||||
);
|
||||
assert.equal(domain.label, "Protocols");
|
||||
assert.equal(protocolEmptyMessage("nessun protocollo riconosciuto"), "No protocol recognized.");
|
||||
assert.equal(protocolEmptyMessage(""), "No protocol recognized.");
|
||||
assert.equal(protocolEmptyMessage("USB2 recognized"), "USB2 recognized");
|
||||
});
|
||||
|
||||
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
|
||||
assert.equal(
|
||||
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Group report findings for the tree: status folders, then domains/rules, then Certificazioni. */
|
||||
/** Group report findings for the tree: status folders, then domains/rules, then Certifications. */
|
||||
|
||||
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
|
||||
import {
|
||||
instanceWorstResult,
|
||||
protocolResultToFindingStatus,
|
||||
type ProtocolReportCheck,
|
||||
} from "./protocol-report.ts";
|
||||
} from "./protocol-report";
|
||||
|
||||
export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
||||
ERROR: "Error",
|
||||
@@ -45,7 +45,7 @@ const DOMAIN_LABEL: Record<string, string> = {
|
||||
PDN: "PDN",
|
||||
PD: "USB-PD",
|
||||
AF: "AF Board + AI",
|
||||
PRT: "Protocolli",
|
||||
PRT: "Protocols",
|
||||
MUX: "Pin mux",
|
||||
NC: "NC pins",
|
||||
DEC: "Decoupling",
|
||||
@@ -112,6 +112,17 @@ export function groupByDesignator(findings: Finding[]): DesignatorGroup[] {
|
||||
.map(([designator, items]) => ({ designator, findings: items }));
|
||||
}
|
||||
|
||||
export const PROTOCOL_EMPTY_MESSAGE = "No protocol recognized.";
|
||||
|
||||
/** Tree copy. Stored 2.83 reports still carry the Italian empty phrase. */
|
||||
export function protocolEmptyMessage(message?: string | null): string {
|
||||
const text = (message || "").trim();
|
||||
if (!text || text.toLowerCase() === "nessun protocollo riconosciuto") {
|
||||
return PROTOCOL_EMPTY_MESSAGE;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function isProtocolFinding(f: {
|
||||
source?: string | null;
|
||||
rule_id?: string | null;
|
||||
@@ -122,7 +133,7 @@ export function isProtocolFinding(f: {
|
||||
}
|
||||
|
||||
export function ruleDomain(f: Finding): { id: string; label: string } {
|
||||
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocolli" };
|
||||
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocols" };
|
||||
const rid = f.rule_id || "";
|
||||
const m = rid.match(/^PE-([A-Z]+)/);
|
||||
if (m) {
|
||||
@@ -173,7 +184,7 @@ export function groupDomains(findings: Finding[]): DomainGroup[] {
|
||||
/**
|
||||
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
|
||||
* U18 with PE-PLC-002 ERROR + five WARNING → Error, badge ERROR, count 6.
|
||||
* Protocol findings (PE-PRT) are excluded from domain folders — they sit under Certificazioni.
|
||||
* Protocol findings (PE-PRT) are excluded from domain folders — they sit under Certifications.
|
||||
*/
|
||||
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
|
||||
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
protocolResultRank,
|
||||
protocolResultToFindingStatus,
|
||||
sortProtocolChecks,
|
||||
} from "./protocol-report.ts";
|
||||
import { groupByWorstStatus } from "./findings-forest.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
} from "./protocol-report";
|
||||
import { groupByWorstStatus } from "./findings-forest";
|
||||
import type { Finding } from "./types";
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { buildReportMarkdown } from "./report-export";
|
||||
import type { Component, DesignGraph, Finding, ValidationReport } from "./types";
|
||||
|
||||
function comp(reference: string, mpn: string): Component {
|
||||
return {
|
||||
reference,
|
||||
value: "",
|
||||
footprint: "",
|
||||
component_type: "ic",
|
||||
component_subtype: null,
|
||||
mpn,
|
||||
pins: {},
|
||||
specs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status" | "finding">): Finding {
|
||||
return {
|
||||
finding_id: partial.finding_id ?? null,
|
||||
mpn: partial.mpn ?? "",
|
||||
aspect: null,
|
||||
why: partial.why ?? "Because",
|
||||
source_page: null,
|
||||
reference: "",
|
||||
rule_id: partial.rule_id ?? "PE-PLC-002",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
test("Markdown export has title, summary counts, and Error→Warning tree", () => {
|
||||
const findings: Finding[] = [
|
||||
f({
|
||||
designator: "U1",
|
||||
status: "ERROR",
|
||||
finding: "Missing decoupling",
|
||||
finding_id: "PCB-U1-001",
|
||||
rule_id: "PE-DEC-001",
|
||||
}),
|
||||
f({
|
||||
designator: "R2",
|
||||
status: "WARNING",
|
||||
finding: "Tolerance wide",
|
||||
finding_id: "PCB-R2-001",
|
||||
rule_id: "PE-BOM-011",
|
||||
}),
|
||||
];
|
||||
const report: ValidationReport = {
|
||||
project: "demo",
|
||||
timestamp: "2026-09-23T00:00:00Z",
|
||||
findings,
|
||||
summary: { ERROR: 1, WARNING: 1, INFO: 0, total: 2 },
|
||||
coverage: {},
|
||||
};
|
||||
const graph: DesignGraph = {
|
||||
components: {
|
||||
U1: comp("U1", "MSPM0"),
|
||||
R2: comp("R2", "10k"),
|
||||
},
|
||||
nets: {},
|
||||
};
|
||||
|
||||
const md = buildReportMarkdown(report, graph, "Demo Board");
|
||||
assert.match(md, /^# Demo Board/m);
|
||||
assert.match(md, /## Summary/);
|
||||
assert.match(md, /- Error: 1/);
|
||||
assert.match(md, /- Warning: 1/);
|
||||
assert.match(md, /## Error/);
|
||||
assert.match(md, /## Warning/);
|
||||
assert.match(md, /##### U1/);
|
||||
assert.match(md, /\*\*Missing decoupling\*\*/);
|
||||
assert.ok(md.indexOf("## Error") < md.indexOf("## Warning"));
|
||||
assert.doesNotMatch(md, /xlsx|\.xls\b/i);
|
||||
});
|
||||
@@ -1,60 +1,199 @@
|
||||
/** Client-side findings workbook (.xlsx) for a finished review. */
|
||||
/** Client-side findings report as readable Markdown (.md). Not a spreadsheet. */
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import type { DesignGraph, Finding, ValidationReport } from "./types";
|
||||
import { sortFindings } from "./utils";
|
||||
|
||||
const COLUMNS: string[] = [
|
||||
"Designator",
|
||||
"MPN",
|
||||
"ID",
|
||||
"Severity",
|
||||
"Title",
|
||||
"Description",
|
||||
"Recommendation",
|
||||
"Source",
|
||||
"Net",
|
||||
"Pins",
|
||||
"Rule",
|
||||
];
|
||||
|
||||
const WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24, 16, 16, 14];
|
||||
|
||||
function sourceCell(f: Finding): string {
|
||||
if (f.source && f.source !== "review") return "Automated check";
|
||||
const who = f.source_designator || f.designator;
|
||||
return f.source_page ? `${who} datasheet p.${f.source_page}` : `${who} datasheet`;
|
||||
}
|
||||
import type {
|
||||
DesignGraph,
|
||||
Finding,
|
||||
FindingStatus,
|
||||
ProtocolCertificationSection,
|
||||
ValidationReport,
|
||||
} from "./types";
|
||||
import {
|
||||
STATUS_FOLDER,
|
||||
groupByWorstStatus,
|
||||
isProtocolFinding,
|
||||
protocolEmptyMessage,
|
||||
protocolInstancesFromSection,
|
||||
protocolInstancesForStatus,
|
||||
} from "./findings-forest";
|
||||
|
||||
function fileStem(name: string): string {
|
||||
const slug = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return slug || "report";
|
||||
}
|
||||
|
||||
export function exportReportToExcel(
|
||||
function escapeMd(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").trim();
|
||||
}
|
||||
|
||||
function findingBullet(f: Finding, mpn: string): string {
|
||||
const bits: string[] = [];
|
||||
const title = escapeMd(f.finding || f.finding_id || "Finding");
|
||||
bits.push(`- **${title}**`);
|
||||
if (f.status) bits.push(` - Severity: ${f.status}`);
|
||||
if (f.finding_id) bits.push(` - ID: \`${f.finding_id}\``);
|
||||
if (f.rule_id) bits.push(` - Rule: \`${f.rule_id}\``);
|
||||
if (mpn) bits.push(` - MPN: ${mpn}`);
|
||||
if (f.net) bits.push(` - Net: \`${f.net}\``);
|
||||
if (f.pins?.length) bits.push(` - Pins: ${(f.pins ?? []).join(", ")}`);
|
||||
if (f.why) bits.push(` - ${escapeMd(f.why)}`);
|
||||
if (f.recommendation) bits.push(` - Recommendation: ${escapeMd(f.recommendation)}`);
|
||||
return bits.join("\n");
|
||||
}
|
||||
|
||||
function renderStatusTree(
|
||||
findings: Finding[],
|
||||
graph: DesignGraph,
|
||||
protocolSection?: ProtocolCertificationSection | null,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const protocolFindings = findings.filter(isProtocolFinding);
|
||||
const nonProtocol = findings.filter((f) => !isProtocolFinding(f));
|
||||
const instances = protocolInstancesFromSection(protocolSection);
|
||||
const groups = groupByWorstStatus(nonProtocol.length ? nonProtocol : findings);
|
||||
|
||||
// Always walk Error → Warning → Info even if empty folders omitted by groupByWorstStatus
|
||||
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||
const byStatus = new Map(groups.map((g) => [g.status, g]));
|
||||
|
||||
for (const status of order) {
|
||||
const group = byStatus.get(status);
|
||||
const protoHere = protocolInstancesForStatus(instances, status);
|
||||
const protoFindingsHere = protocolFindings.filter((f) => f.status === status);
|
||||
if (!group && protoHere.length === 0 && protoFindingsHere.length === 0) continue;
|
||||
|
||||
lines.push(`## ${STATUS_FOLDER[status]}`);
|
||||
lines.push("");
|
||||
|
||||
if (group) {
|
||||
for (const domain of group.domains) {
|
||||
lines.push(`### ${domain.label}`);
|
||||
lines.push("");
|
||||
for (const rule of domain.rules) {
|
||||
lines.push(`#### ${rule.ruleId}`);
|
||||
lines.push("");
|
||||
for (const des of rule.designators) {
|
||||
lines.push(`##### ${des.designator}`);
|
||||
lines.push("");
|
||||
for (const f of des.findings) {
|
||||
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
|
||||
lines.push(findingBullet(f, mpn));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (protoHere.length > 0 || protoFindingsHere.length > 0) {
|
||||
lines.push("### Certifications");
|
||||
lines.push("");
|
||||
lines.push("#### Protocols");
|
||||
lines.push("");
|
||||
if (protoHere.length === 0 && protoFindingsHere.length === 0) {
|
||||
lines.push(`- ${protocolEmptyMessage(protocolSection?.message)}`);
|
||||
lines.push("");
|
||||
}
|
||||
for (const inst of protoHere) {
|
||||
lines.push(
|
||||
`- **${inst.logicalId || inst.physicalId || inst.instanceId}** — ${inst.worst || inst.recognition}`,
|
||||
);
|
||||
if (inst.notes) lines.push(` - ${escapeMd(inst.notes)}`);
|
||||
for (const c of inst.checks) {
|
||||
lines.push(
|
||||
` - [${c.level || "?"}] ${c.check || "check"}: ${c.result}${c.notes ? ` — ${escapeMd(c.notes)}` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
for (const f of protoFindingsHere) {
|
||||
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
|
||||
lines.push(findingBullet(f, mpn));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
instances.length === 0 &&
|
||||
protocolFindings.length === 0 &&
|
||||
protocolSection
|
||||
) {
|
||||
lines.push("## Certifications");
|
||||
lines.push("");
|
||||
lines.push("### Protocols");
|
||||
lines.push("");
|
||||
lines.push(`- ${protocolEmptyMessage(protocolSection.message)}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Build Markdown for the report tree (title, summary counts, hierarchy). */
|
||||
export function buildReportMarkdown(
|
||||
report: ValidationReport,
|
||||
graph: DesignGraph,
|
||||
projectName?: string,
|
||||
): string {
|
||||
const title = projectName || report.project || "Periscope report";
|
||||
const summary = report.summary ?? {};
|
||||
const errors = summary.ERROR ?? 0;
|
||||
const warnings = summary.WARNING ?? 0;
|
||||
const infos = summary.INFO ?? 0;
|
||||
const total = summary.total ?? report.findings.length;
|
||||
|
||||
const lines: string[] = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
`Generated ${report.timestamp || new Date().toISOString()}`,
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
`- Total: ${total}`,
|
||||
`- Error: ${errors}`,
|
||||
`- Warning: ${warnings}`,
|
||||
`- Info: ${infos}`,
|
||||
"",
|
||||
];
|
||||
|
||||
lines.push(
|
||||
...renderStatusTree(report.findings, graph, report.protocol_certification),
|
||||
);
|
||||
|
||||
if (report.not_reviewed?.length) {
|
||||
lines.push("## Not reviewed");
|
||||
lines.push("");
|
||||
for (const nr of report.not_reviewed) {
|
||||
lines.push(`- **${nr.designator}** — ${escapeMd(nr.reason)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, contents: string): void {
|
||||
const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Save the findings tree as a `.md` file (readable Markdown lists). */
|
||||
export function exportReportToMarkdown(
|
||||
report: ValidationReport,
|
||||
graph: DesignGraph,
|
||||
projectName?: string,
|
||||
): void {
|
||||
const body = sortFindings(report.findings).map((f) => [
|
||||
f.designator,
|
||||
f.mpn || graph.components[f.designator]?.mpn || "",
|
||||
f.finding_id ?? "",
|
||||
f.status,
|
||||
f.finding,
|
||||
f.why ?? "",
|
||||
f.recommendation ?? "",
|
||||
sourceCell(f),
|
||||
f.net ?? "",
|
||||
(f.pins ?? []).join(", "),
|
||||
f.rule_id ?? "",
|
||||
]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([COLUMNS, ...body]);
|
||||
sheet["!cols"] = WIDTHS.map((wch) => ({ wch }));
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, "Findings");
|
||||
XLSX.writeFile(
|
||||
book,
|
||||
`${fileStem(projectName || report.project || "report")}-findings.xlsx`,
|
||||
const md = buildReportMarkdown(report, graph, projectName);
|
||||
downloadTextFile(
|
||||
`${fileStem(projectName || report.project || "report")}-findings.md`,
|
||||
md,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
||||
export const APP_VERSION = "2.83.0";
|
||||
export const APP_VERSION_DATE = "2026-09-22";
|
||||
export const APP_VERSION = "2.85.0";
|
||||
export const APP_VERSION_DATE = "2026-09-23";
|
||||
|
||||
@@ -21,5 +21,7 @@ TI files are **source_type VENDOR**, never HDMI Forum STANDARD. `https://pcbsync
|
||||
| `tmds1204.pdf` | TMDS1204 12Gbps HDMI Hybrid Redriver | SLLSF57A – August 2022 / revised April 2024 | `tmds1204-hdmi-sink`, `tmds1204-hdmi-source`; HDMI **2.0 / FRL** common layout `source_type=VENDOR` | **VENDOR** |
|
||||
| `slla633.pdf` | TMDS_CLOCK/FRL_Data Detection Design in HDMI Sink Applications TMDS1204 | SLLA633 – May 2024 | HDMI 2.0+/`tmds1204-*` `sigdet_wakeup` PHY_DEPENDENT (no layout ohms) | **VENDOR** |
|
||||
| `HDMI-FRL-Software-Datasheet-61W6169600.pdf` | HDMI 2.1 FRL Compliance Test Solution Datasheet (Tektronix) | (on file; 4 pages) | **not packed** — test-equipment datasheet, not HDMI Forum Adopter | archived only |
|
||||
| `snla027b.pdf` | AN-807 Reflections: Computations and Waveforms | SNLA027B – May 2004 / revised May 2004 | **not packed** for USB-C vSafe — TI SI, no vSafe5V/vSafe0V | archived only |
|
||||
| `sdaa499.pdf` | Simple Methods to Reduce EMI from a PCB Trace | SDAA499 – September 2026 | **not packed** for USB-C vSafe — TI EMI, no vSafe | archived only |
|
||||
|
||||
Duplicates on Desktop (same MD5, not stored twice here): `USB2_Electrical_Compliance.pdf`, `DVI_Spec_v1.0.pdf`, `USB_TypeC_Functional_Test.pdf`.
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""HubAudio false findings: exposed-pad names, ESD cites, CC-via-net, Ethernet Z."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.periscopex.af_trace_check import check_af_traces
|
||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||
from backend.periscopex.esd_return_check import check_esd
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
Net,
|
||||
NetType,
|
||||
PackageInfo,
|
||||
Pin,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.periscopex.protocol_l0 import _run_l0_check, l0_findings
|
||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
||||
|
||||
|
||||
def _ic(ref: str, mpn: str, pins: dict[str, str], *, footprint: str = "") -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn, footprint=footprint,
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
return Net(
|
||||
name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||
)
|
||||
|
||||
|
||||
def _cons(mpn: str, numbers: list[str], pin_count: int) -> ComponentConstraints:
|
||||
return ComponentConstraints(
|
||||
mpn=mpn,
|
||||
package_info=PackageInfo(base_family="QFN", package=f"QFN-{pin_count}", pin_count=pin_count),
|
||||
pintable=[Pin(number=n, name=n) for n in numbers],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
|
||||
|
||||
def _layout(ref: str, pads: list[tuple[str, str]], *, footprint: str) -> LayoutGraph:
|
||||
return LayoutGraph(
|
||||
footprints={
|
||||
ref: LayoutFootprint(
|
||||
reference=ref, footprint=footprint, x=0, y=0,
|
||||
pads=[
|
||||
LayoutPad(number=n, x=0, y=0, net="GND", pinfunction=pf)
|
||||
for n, pf in pads
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _bom(ref: str, mpn: str, numbers: list[str], pads: list[tuple[str, str]], pin_count: int):
|
||||
graph = DesignGraph(components={
|
||||
ref: _ic(ref, mpn, {n: "N" for n, _pf in pads if n}, footprint="Lib:Part"),
|
||||
})
|
||||
# Schematic pins follow the pintable numbers that are real contacts.
|
||||
graph.components[ref].pins = {n: "N" for n in numbers if n not in {"EP", "THERMAL PAD"}}
|
||||
layout = _layout(ref, pads, footprint="Lib:Part")
|
||||
findings = check_bom_pcb_datasheet(graph, {mpn: _cons(mpn, numbers, pin_count)}, layout)
|
||||
return [f for f in findings if f.rule_id == "PE-BOM-011"]
|
||||
|
||||
|
||||
def test_u16_ep_matches_pad_41():
|
||||
"""AXP2101: pintable EP, footprint pad 41 / EP_41."""
|
||||
hits = _bom(
|
||||
"U16", "AXP2101", ["1", "EP"],
|
||||
[("1", "CHGLED_1"), ("41", "EP_41")],
|
||||
40,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_u18_ep_matches_pad_89():
|
||||
"""ADAU1467: pintable EP, footprint pad 89 / EP_89."""
|
||||
hits = _bom(
|
||||
"U18", "ADAU1467", ["1", "EP"],
|
||||
[("1", "DGND_1_1"), ("89", "EP_89")],
|
||||
88,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_u19_ep_matches_pad_25_vss():
|
||||
"""LAN8720A: pintable EP, VQFN-24 land is pad 25 / VSS_25."""
|
||||
hits = _bom(
|
||||
"U19", "LAN8720A", ["1", "EP"],
|
||||
[("1", "VDD2A_1"), ("25", "VSS_25")],
|
||||
24,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_u5_thermal_pad_matches_epad_29():
|
||||
"""TAC5212: pintable THERMAL PAD, footprint pad 29 / EPAD_29."""
|
||||
hits = _bom(
|
||||
"U5", "XTAC5212", ["1", "THERMAL PAD"],
|
||||
[("1", "DREG_1"), ("29", "EPAD_29")],
|
||||
24,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_exposed_pad_absent_from_footprint_stays_error():
|
||||
hits = _bom(
|
||||
"U9", "BARE", ["1", "EP"],
|
||||
[("1", "PIN_1")],
|
||||
24,
|
||||
)
|
||||
assert hits
|
||||
assert any("EP" in (f.finding or "") for f in hits)
|
||||
|
||||
|
||||
def _esd_graph(net: str, *, j_fp: str = "", j_value: str = "JACK") -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value=j_value, footprint=j_fp,
|
||||
component_type=ComponentType.CONNECTOR, mpn="",
|
||||
pins={"13": net},
|
||||
),
|
||||
"U19": _ic("U19", "LAN8720A", {"1": net}),
|
||||
},
|
||||
nets={net: _net(net, ("J1", "13"), ("U19", "1"))},
|
||||
)
|
||||
|
||||
|
||||
def test_esd_does_not_say_datasheet_specified_without_a_cite():
|
||||
findings = check_esd(_esd_graph("USB_DP"))
|
||||
assert findings and findings[0].rule_id == "PE-ESD-001"
|
||||
blob = f"{findings[0].action} {findings[0].recommendation}"
|
||||
assert "datasheet-specified" not in blob
|
||||
|
||||
|
||||
def test_esd_quotes_datasheet_when_part_and_section_exist():
|
||||
cons = ComponentConstraints(
|
||||
mpn="LAN8720A",
|
||||
pintable=[],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
layout_rules=[{
|
||||
"kind": "esd",
|
||||
"document": "LAN8720A datasheet",
|
||||
"section": "3.2",
|
||||
"page": "12",
|
||||
"note": "ESD on USB_DP",
|
||||
}],
|
||||
)
|
||||
findings = check_esd(_esd_graph("USB_DP"), {"LAN8720A": cons})
|
||||
assert findings
|
||||
blob = f"{findings[0].action} {findings[0].finding}"
|
||||
assert "datasheet-specified" in blob
|
||||
assert "LAN8720A datasheet" in blob
|
||||
assert "3.2" in blob
|
||||
|
||||
|
||||
def test_3v3_ethernet_rail_is_not_an_esd_warning():
|
||||
assert check_esd(_esd_graph("3V3_ETHERNET")) == []
|
||||
|
||||
|
||||
def test_vbus_without_cite_is_not_an_esd_warning():
|
||||
graph = _esd_graph("VBUS")
|
||||
graph.components["U16"] = _ic("U16", "AXP2101", {"37": "VBUS"})
|
||||
graph.components["J2"] = Component(
|
||||
reference="J2", value="USB_C", footprint="Connector_USB:USB_C_Receptacle",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A4": "VBUS"},
|
||||
)
|
||||
graph.nets = {"VBUS": _net("VBUS", ("J2", "A4"), ("U16", "37"))}
|
||||
findings = check_esd(graph)
|
||||
assert findings == []
|
||||
blob = " ".join(f.action or "" for f in findings)
|
||||
assert "datasheet-specified" not in blob
|
||||
|
||||
|
||||
def test_optical_spdif_is_not_an_esd_warning():
|
||||
findings = check_esd(_esd_graph(
|
||||
"SPIF_IN",
|
||||
j_fp="OptoDevice:Broadcom_AFBR-16xxZ_Horizontal",
|
||||
j_value="TOSLINK",
|
||||
))
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_unverified_spdif_warns_it_was_not_seen_as_optical():
|
||||
findings = check_esd(_esd_graph("SPIF_OUT", j_fp="Connector:PinHeader"))
|
||||
assert findings
|
||||
assert findings[0].status == "WARNING"
|
||||
assert "not verified as optical" in (findings[0].finding or "")
|
||||
assert "datasheet-specified" not in (findings[0].action or "")
|
||||
|
||||
|
||||
def _cc_inst() -> PhysicalBusInstance:
|
||||
return PhysicalBusInstance(
|
||||
instance_id="usb-c-usb2-receptacle:J2",
|
||||
logical_protocol_id="usb2-hs",
|
||||
physical_interface_id="usb-c-usb2-receptacle",
|
||||
pcb_relevant="YES",
|
||||
confidence=1.0,
|
||||
evidence_kind="pinout",
|
||||
recognition_status="RECOGNIZED",
|
||||
host_ref="J2",
|
||||
nets=["Net-(J2-A5)", "Net-(J2-B5)"],
|
||||
)
|
||||
|
||||
|
||||
def _cc_graph(*, both: bool) -> DesignGraph:
|
||||
comps = {
|
||||
"J2": Component(
|
||||
reference="J2",
|
||||
value="USB_C_Receptacle_USB2.0_16P",
|
||||
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A5": "Net-(J2-A5)", "B5": "Net-(J2-B5)", "A4": "VBUS"},
|
||||
),
|
||||
"R1": Component(
|
||||
reference="R1", value="5.1k", footprint="R_0603",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "Net-(J2-A5)", "2": "GND"},
|
||||
),
|
||||
}
|
||||
nets = {
|
||||
"Net-(J2-A5)": _net("Net-(J2-A5)", ("J2", "A5"), ("R1", "1")),
|
||||
"Net-(J2-B5)": _net("Net-(J2-B5)", ("J2", "B5")),
|
||||
"GND": _net("GND", ("R1", "2")),
|
||||
}
|
||||
if both:
|
||||
comps["R2"] = Component(
|
||||
reference="R2", value="5.1k", footprint="R_0603",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "Net-(J2-B5)", "2": "GND"},
|
||||
)
|
||||
nets["Net-(J2-B5)"] = _net("Net-(J2-B5)", ("J2", "B5"), ("R2", "1"))
|
||||
nets["GND"] = _net("GND", ("R1", "2"), ("R2", "2"))
|
||||
return DesignGraph(components=comps, nets=nets)
|
||||
|
||||
|
||||
def test_cc_rd_present_on_unnamed_nets_does_not_fail():
|
||||
row = _run_l0_check(_cc_graph(both=True), _cc_inst(), "cc_rd_rp", "MANDATORY")
|
||||
assert row.result == "PASS"
|
||||
assert "5.1" in row.notes
|
||||
assert "CC1/CC2 net not present" not in row.notes
|
||||
findings = l0_findings(_cc_inst(), [row])
|
||||
assert not any(f.rule_id == "PE-PRT-L0-001" for f in findings)
|
||||
|
||||
|
||||
def test_cc_rd_missing_on_pin_net_fails():
|
||||
row = _run_l0_check(_cc_graph(both=False), _cc_inst(), "cc_rd_rp", "MANDATORY")
|
||||
assert row.result == "FAIL"
|
||||
assert "B5" in row.notes
|
||||
assert "CC1/CC2 net not present" not in row.notes
|
||||
assert "5.1" in row.notes
|
||||
findings = l0_findings(_cc_inst(), [row])
|
||||
assert any(f.rule_id == "PE-PRT-L0-001" and f.status == "ERROR" for f in findings)
|
||||
|
||||
|
||||
def _eth_graph(mpn: str, *, value: str = "") -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value="RJ45",
|
||||
footprint="Connector_RJ:RJ45_Abracon_ARJP11A-MA_Horizontal",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"9": "RJ45_TXP", "10": "RJ45_TXN"},
|
||||
),
|
||||
"U19": _ic("U19", mpn, {"21": "RJ45_TXP", "20": "RJ45_TXN"}, footprint="VQFN-24"),
|
||||
},
|
||||
nets={
|
||||
"RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")),
|
||||
"RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")),
|
||||
},
|
||||
) if not value else DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value="RJ45",
|
||||
footprint="Connector_RJ:RJ45",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"9": "RJ45_TXP", "10": "RJ45_TXN"},
|
||||
),
|
||||
"U19": Component(
|
||||
reference="U19", value=value, footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn,
|
||||
pins={"21": "RJ45_TXP", "20": "RJ45_TXN"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")),
|
||||
"RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _eth_layout() -> LayoutGraph:
|
||||
return LayoutGraph(
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="RJ45_TXP"),
|
||||
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="RJ45_TXN"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _eth_findings(graph: DesignGraph, monkeypatch, z: float | None):
|
||||
if z is not None:
|
||||
monkeypatch.setattr(
|
||||
"backend.periscopex.af_trace_check._pair_z_ohm",
|
||||
lambda layout, unit: z,
|
||||
)
|
||||
return [f for f in check_af_traces(graph, {}, _eth_layout()) if f.rule_id == "PE-AF-002"]
|
||||
|
||||
|
||||
def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch):
|
||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, None)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f.status == "ERROR"
|
||||
blob = f"{f.finding} {f.action} {f.requirement}"
|
||||
assert blob.startswith("ERROR:") or "ERROR:" in f.finding
|
||||
assert "100" in blob and "25.4.9" in blob
|
||||
assert "stackup" in blob
|
||||
assert "Fornire" not in blob
|
||||
assert "tr" not in blob.lower()
|
||||
assert "90" not in blob
|
||||
assert "Clause 40" not in blob
|
||||
assert "1000BASE" not in blob
|
||||
assert "PASS" not in f.finding
|
||||
|
||||
|
||||
def test_lan8720_pair_z_inside_cite_is_pass(monkeypatch):
|
||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 100.0)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].finding.startswith("PASS:")
|
||||
assert findings[0].status == "INFO"
|
||||
assert "RJ45_TXP" in findings[0].finding and "RJ45_TXN" in findings[0].finding
|
||||
assert "25.4.9" in findings[0].finding
|
||||
|
||||
|
||||
def test_lan8720_pair_z_outside_cite_is_fail(monkeypatch):
|
||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 90.0)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].finding.startswith("FAIL:")
|
||||
assert findings[0].status == "ERROR"
|
||||
assert "PASS" not in findings[0].finding
|
||||
assert "25.4.9" in findings[0].finding
|
||||
|
||||
|
||||
def test_gigabit_phy_uses_clause_40_not_100base(monkeypatch):
|
||||
findings = _eth_findings(
|
||||
_eth_graph("RTL8211F", value="1000BASE-T"),
|
||||
monkeypatch,
|
||||
None,
|
||||
)
|
||||
assert len(findings) == 1
|
||||
blob = findings[0].finding
|
||||
assert findings[0].status == "ERROR"
|
||||
assert "Clause 40" in blob
|
||||
assert "25.4.9" not in blob
|
||||
assert "PASS" not in blob
|
||||
@@ -223,7 +223,7 @@ def test_reject_same_logical_and_physical_id():
|
||||
def test_empty_report_section_has_no_z():
|
||||
section = empty_protocol_section()
|
||||
dumped = section.model_dump()
|
||||
assert section.message == "nessun protocollo riconosciuto"
|
||||
assert section.message == "No protocol recognized."
|
||||
assert section.recognized_instances == []
|
||||
assert dumped.get("max_level_reached") is None
|
||||
blob = json.dumps(dumped)
|
||||
@@ -238,7 +238,7 @@ def test_empty_report_section_has_no_z():
|
||||
summary={"ERROR": 0, "WARNING": 0, "INFO": 0},
|
||||
protocol_certification=dumped,
|
||||
)
|
||||
assert report.protocol_certification["message"] == "nessun protocollo riconosciuto"
|
||||
assert report.protocol_certification["message"] == "No protocol recognized."
|
||||
|
||||
|
||||
def test_pcie_cxl_physical_packs_are_empty_numbers():
|
||||
|
||||
@@ -123,7 +123,17 @@ def test_type_c_rp_rd_from_cabcon_r25_vbus_volts_unknown():
|
||||
assert by["t_vbuson_max"].source.page == "247"
|
||||
assert "2.5" in by["t_vbuson_max"].source.revision
|
||||
assert by["vsafe5v"].value_kind == "UNKNOWN"
|
||||
assert "vSafe5V" in (by["vsafe5v"].needed_document or "")
|
||||
assert by["vsafe5v"].value is None
|
||||
assert "USB Power Delivery" in (by["vsafe5v"].needed_document or "")
|
||||
assert "snla027b" in (by["vsafe5v"].needed_document or "")
|
||||
assert "Figure 4-39" in (by["vsafe5v"].needed_document or "")
|
||||
assert by["vsafe0v"].value_kind == "UNKNOWN"
|
||||
assert by["vsafe0v"].value is None
|
||||
assert "vSafe0V" in (by["vsafe0v"].needed_document or "")
|
||||
hdmi14 = next(p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-a")
|
||||
hz14 = next(c for c in hdmi14.constraints if c.parameter == "differential_impedance")
|
||||
assert hz14.value_kind == "UNKNOWN"
|
||||
assert hz14.value is None
|
||||
_cite_ok(by["gnd_ir_drop_cable"])
|
||||
assert by["gnd_ir_drop_cable"].value == 250.0
|
||||
assert by["gnd_ir_drop_cable"].source_type == "CABLE"
|
||||
|
||||
@@ -138,9 +138,9 @@ def test_axi4_internal_does_not_create_pcb_nets():
|
||||
assert "AXI_WDATA" not in one.nets
|
||||
|
||||
|
||||
def test_empty_graph_keeps_nessun_protocollo():
|
||||
def test_empty_graph_keeps_no_protocol_message():
|
||||
sec = protocol_section_for_graph(DesignGraph())
|
||||
assert sec.message == "nessun protocollo riconosciuto"
|
||||
assert sec.message == "No protocol recognized."
|
||||
assert sec.recognized_instances == []
|
||||
blob = json.dumps(sec.model_dump())
|
||||
assert "ohm" not in blob.lower()
|
||||
|
||||
@@ -25,6 +25,7 @@ def test_rewritten_pages_are_src():
|
||||
"app/(marketing)/file-guide/page.tsx",
|
||||
"app/layout.tsx",
|
||||
"components/layout/sidebar.tsx",
|
||||
"components/layout/top-bar.tsx",
|
||||
"hooks/use-pipeline-progress.ts",
|
||||
"hooks/use-report.ts",
|
||||
]
|
||||
@@ -35,6 +36,20 @@ def test_rewritten_pages_are_src():
|
||||
assert "Native Periscope overlay" not in head
|
||||
|
||||
|
||||
def test_app_shell_uses_top_bar():
|
||||
text = (SRC / "app/(app)/layout.tsx").read_text(encoding="utf-8")
|
||||
assert "TopBar" in text
|
||||
assert "flex-col" in text
|
||||
assert "<Sidebar" not in text
|
||||
|
||||
|
||||
def test_report_save_as_markdown():
|
||||
text = (SRC / "app/(app)/project/[id]/report/page.tsx").read_text(encoding="utf-8")
|
||||
assert "exportReportToMarkdown" in text
|
||||
assert "Save as Markdown" in text
|
||||
assert "Export Excel" not in text
|
||||
|
||||
|
||||
def test_landing_keeps_pricing_seam_import():
|
||||
text = (SRC / "app/(marketing)/page.tsx").read_text(encoding="utf-8")
|
||||
assert "PricingSection" in text
|
||||
|
||||
@@ -37,8 +37,12 @@ def test_site_exports():
|
||||
|
||||
def test_report_export_entry():
|
||||
text = (SRC / "src/lib/report-export.ts").read_text(encoding="utf-8")
|
||||
assert "export function exportReportToExcel" in text
|
||||
assert "Findings" in text
|
||||
assert "export function exportReportToMarkdown" in text
|
||||
assert "export function buildReportMarkdown" in text
|
||||
assert "Save as Markdown" not in text # UI copy lives on the report page
|
||||
assert ".md" in text
|
||||
assert "xlsx" not in text.lower()
|
||||
assert "spreadsheet" in text.lower() or "Markdown" in text
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
|
||||
@@ -21,13 +21,22 @@ def test_theme_provider_contract():
|
||||
text = (SRC / "components/theme/theme-provider.tsx").read_text(encoding="utf-8")
|
||||
assert "export function ThemeProvider" in text
|
||||
assert 'attribute="class"' in text
|
||||
assert 'defaultTheme="dark"' in text
|
||||
assert 'defaultTheme="light"' in text
|
||||
assert "enableSystem={false}" in text
|
||||
assert "disableTransitionOnChange" in text
|
||||
assert 'storageKey="periscope-theme"' in text
|
||||
clerk = SRC / "components/theme/clerk-theme-provider.tsx"
|
||||
assert not clerk.exists()
|
||||
|
||||
|
||||
def test_appearance_settings_contract():
|
||||
text = (SRC / "components/theme/appearance-settings.tsx").read_text(encoding="utf-8")
|
||||
assert "export function AppearanceSettings" in text
|
||||
assert "setTheme" in text
|
||||
assert "Light" in text
|
||||
assert "Dark" in text
|
||||
|
||||
|
||||
def test_theme_toggle_contract():
|
||||
text = (SRC / "components/theme/theme-toggle.tsx").read_text(encoding="utf-8")
|
||||
assert "export function ThemeToggle" in text
|
||||
|
||||
Reference in New Issue
Block a user