Stamp 2.60.0 incomplete PCB exam and owner project delete.
Partial layouts still use MODE=pcb. Missing copper is INSUFFICIENT or
skipped, never Euclidean stand-in tracks. Via≠Pad unchanged. DELETE
/api/projects/{id} is owner-only with a dashboard confirm dialog.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""Partial PCB: footprints with few tracks still parse and run MODE=pcb checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Net,
|
||||
NetType,
|
||||
)
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
||||
from backend.periscopex.placement_check import check_placement
|
||||
|
||||
|
||||
_PARTIAL = """(kicad_pcb (version 20240108) (generator pcbnew)
|
||||
(net 0 "")
|
||||
(net 1 "GND")
|
||||
(net 2 "+3V3")
|
||||
(net 3 "USB_DP")
|
||||
(footprint "Package_SO:SOIC-8"
|
||||
(layer "F.Cu")
|
||||
(at 20 10 0)
|
||||
(property "Reference" "U1" (at 0 0 0) (effects (font (size 1 1))))
|
||||
(pad "1" smd rect (at -2 1) (size 1 0.6) (layers "F.Cu") (net 2 "+3V3"))
|
||||
(pad "2" smd rect (at -2 -1) (size 1 0.6) (layers "F.Cu") (net 1 "GND"))
|
||||
(pad "3" smd rect (at 2 1) (size 1 0.6) (layers "F.Cu") (net 3 "USB_DP"))
|
||||
)
|
||||
(footprint "Capacitor_SMD:C_0603"
|
||||
(layer "F.Cu")
|
||||
(at 40 30 0)
|
||||
(property "Reference" "C1" (at 0 0 0) (effects (font (size 1 1))))
|
||||
(pad "1" smd rect (at -0.5 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "+3V3"))
|
||||
(pad "2" smd rect (at 0.5 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "GND"))
|
||||
)
|
||||
(segment (start 18 11) (end 19 11) (width 0.2) (layer "F.Cu") (net 2))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_footprints_with_few_tracks(tmp_path: Path):
|
||||
p = tmp_path / "partial.kicad_pcb"
|
||||
p.write_text(_PARTIAL)
|
||||
g = parse_kicad_pcb(p)
|
||||
assert "U1" in g.footprints and "C1" in g.footprints
|
||||
assert len(g.segments) == 1
|
||||
assert g.segments[0].net == "+3V3"
|
||||
assert len(g.vias) == 0
|
||||
|
||||
|
||||
def _partial_graph() -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="MCU", footprint="",
|
||||
component_type=ComponentType.IC, mpn="X",
|
||||
pins={"1": "+3V3", "2": "GND", "3": "USB_DP"},
|
||||
),
|
||||
"C1": Component(
|
||||
reference="C1", value="100n", footprint="",
|
||||
component_type=ComponentType.CAPACITOR, mpn="C",
|
||||
pins={"1": "+3V3", "2": "GND"},
|
||||
),
|
||||
"R9": Component(
|
||||
reference="R9", value="10k", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="R",
|
||||
pins={"1": "NRESET"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
|
||||
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
|
||||
"USB_DP": Net(name="USB_DP", net_type=NetType.SIGNAL, pins=[]),
|
||||
"NRESET": Net(name="NRESET", net_type=NetType.SIGNAL, pins=[]),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_run_pcb_checks_on_partial_layout_does_not_invent_tracks(tmp_path: Path):
|
||||
p = tmp_path / "partial.kicad_pcb"
|
||||
p.write_text(_PARTIAL)
|
||||
layout = parse_kicad_pcb(p)
|
||||
graph = _partial_graph()
|
||||
findings = run_pcb_checks(graph, {}, layout)
|
||||
lay = [f for f in findings if f.rule_id == "PE-LAY-004"]
|
||||
assert lay, findings
|
||||
assert lay[0].evidence_status == "INSUFFICIENT"
|
||||
assert lay[0].status == "INFO"
|
||||
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||
unplaced = [f for f in findings if f.rule_id == "PE-LAY-002"]
|
||||
assert any(f.designator == "R9" for f in unplaced)
|
||||
|
||||
|
||||
def test_partial_board_via_is_not_a_pad(tmp_path: Path):
|
||||
from tests.test_pcb_via_not_pad import _qfn24_pcb
|
||||
|
||||
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, board_vias=[(80.0, 80.0, "GND", 1)]))
|
||||
u1 = layout.footprints["U1"]
|
||||
assert len(u1.pads) == 24
|
||||
assert len(layout.vias) == 1
|
||||
via = layout.vias[0]
|
||||
assert all(abs(p.x - via.x) > 0.01 or abs(p.y - via.y) > 0.01 for p in u1.pads)
|
||||
|
||||
|
||||
def test_unplaced_ref_is_lay_002_not_invented_xy():
|
||||
graph = _partial_graph()
|
||||
from backend.periscopex.models import LayoutFootprint, LayoutGraph, LayoutPad
|
||||
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", x=0, y=0, layer="F.Cu",
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_pcb_net_match(graph, layout)
|
||||
assert any(f.rule_id == "PE-LAY-002" and f.designator == "C1" for f in findings)
|
||||
assert check_placement(graph, {}, layout) == [] or all(
|
||||
f.evidence_status == "INSUFFICIENT" or f.rule_id != "PE-PLC-001"
|
||||
for f in check_placement(graph, {}, layout)
|
||||
)
|
||||
@@ -173,11 +173,15 @@ def test_missing_footprint_is_pe_lay_002():
|
||||
|
||||
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||
from backend.periscopex.models import LayoutSegment
|
||||
|
||||
segs = [
|
||||
LayoutSegment(start=(0.0, 0.0), end=(10.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||
]
|
||||
findings = run_pcb_checks(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=10.0),
|
||||
_x1_c9_layout(cap_x=10.0, segments=segs),
|
||||
)
|
||||
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
||||
assert plc
|
||||
@@ -187,11 +191,15 @@ def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
||||
|
||||
def test_close_decoupling_has_no_plc_001():
|
||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||
from backend.periscopex.models import LayoutSegment
|
||||
|
||||
segs = [
|
||||
LayoutSegment(start=(0.0, 0.0), end=(0.5, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||
]
|
||||
findings = run_pcb_checks(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=0.5),
|
||||
_x1_c9_layout(cap_x=0.5, segments=segs),
|
||||
)
|
||||
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ def test_legal_pages_are_operator_not_faradworks_controller():
|
||||
assert "This Service is **not** operated by Faradworks, Inc." in terms
|
||||
|
||||
|
||||
def test_changelog_stamp_is_2_59_0_and_src_wins():
|
||||
def test_changelog_stamp_is_2_60_0_and_src_wins():
|
||||
text = (SRC / "changelog.md").read_text(encoding="utf-8")
|
||||
assert "## 2.59.0 — 2026-09-20" in text
|
||||
assert "## 2.60.0 — 2026-09-20" in text
|
||||
first = changelog_paths()[0]
|
||||
assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md"
|
||||
assert first == SRC / "changelog.md"
|
||||
|
||||
@@ -17,7 +17,7 @@ DOCKERIGNORE = ROOT / ".dockerignore"
|
||||
def test_package_json_is_periscope_web():
|
||||
text = PKG.read_text(encoding="utf-8")
|
||||
assert '"name": "periscope-web"' in text
|
||||
assert '"version": "2.59.0"' in text
|
||||
assert '"version": "2.60.0"' in text
|
||||
assert "Native Periscope overlay" not in text[:400]
|
||||
assert LOCK.is_file()
|
||||
lock = LOCK.read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Dashboard owner delete uses a confirm dialog, not window.confirm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BTN = (
|
||||
ROOT
|
||||
/ "periscope"
|
||||
/ "src"
|
||||
/ "frontend"
|
||||
/ "src"
|
||||
/ "components"
|
||||
/ "dashboard"
|
||||
/ "delete-project-button.tsx"
|
||||
)
|
||||
CARD = (
|
||||
ROOT
|
||||
/ "periscope"
|
||||
/ "src"
|
||||
/ "frontend"
|
||||
/ "src"
|
||||
/ "components"
|
||||
/ "dashboard"
|
||||
/ "project-card.tsx"
|
||||
)
|
||||
TABLE = (
|
||||
ROOT
|
||||
/ "periscope"
|
||||
/ "src"
|
||||
/ "frontend"
|
||||
/ "src"
|
||||
/ "components"
|
||||
/ "dashboard"
|
||||
/ "projects-table.tsx"
|
||||
)
|
||||
API = ROOT / "periscope" / "src" / "frontend" / "src" / "lib" / "api.ts"
|
||||
|
||||
|
||||
def test_delete_dialog_is_src_and_owner_only_copy():
|
||||
text = BTN.read_text(encoding="utf-8")
|
||||
assert "Native Periscope overlay" not in text[:400]
|
||||
assert "export function DeleteProjectButton" in text
|
||||
assert "AlertDialog" in text
|
||||
assert "deleteProject" in text
|
||||
assert "window.confirm" not in text
|
||||
assert "confirm(`" not in text
|
||||
|
||||
|
||||
def test_dashboard_cards_and_table_use_delete_dialog():
|
||||
card = CARD.read_text(encoding="utf-8")
|
||||
table = TABLE.read_text(encoding="utf-8")
|
||||
assert "DeleteProjectButton" in card
|
||||
assert "DeleteProjectButton" in table
|
||||
assert "confirm(" not in card
|
||||
assert "confirm(" not in table
|
||||
assert "!foreign" in card
|
||||
assert "!foreign" in table
|
||||
|
||||
|
||||
def test_api_delete_is_http_delete():
|
||||
text = API.read_text(encoding="utf-8")
|
||||
assert "export async function deleteProject" in text
|
||||
assert 'method: "DELETE"' in text
|
||||
@@ -186,10 +186,13 @@ def _x1_c9_layout(*, segments=None, cap_x: float = 0.5):
|
||||
|
||||
def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001():
|
||||
limit = 2.0
|
||||
segs = [
|
||||
LayoutSegment(start=(0.0, 0.0), end=(10.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||
]
|
||||
findings = check_placement(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=limit),
|
||||
_x1_c9_layout(cap_x=10.0),
|
||||
_x1_c9_layout(cap_x=10.0, segments=segs),
|
||||
)
|
||||
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
||||
assert len(plc) == 1
|
||||
@@ -198,13 +201,29 @@ def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001():
|
||||
|
||||
|
||||
def test_crystal_load_cap_within_max_distance_mm_is_silent():
|
||||
segs = [
|
||||
LayoutSegment(start=(0.0, 0.0), end=(0.5, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||
]
|
||||
assert check_placement(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=0.5),
|
||||
_x1_c9_layout(cap_x=0.5, segments=segs),
|
||||
) == []
|
||||
|
||||
|
||||
def test_unrouted_crystal_cap_is_insufficient_not_euclidean():
|
||||
findings = check_placement(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=10.0),
|
||||
)
|
||||
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||
insuf = [f for f in findings if f.rule_id == "PE-PLC-005"]
|
||||
assert len(insuf) == 1
|
||||
assert insuf[0].evidence_status == "INSUFFICIENT"
|
||||
assert insuf[0].status == "INFO"
|
||||
|
||||
|
||||
def test_track_path_longer_than_max_distance_mm_is_ps_plc_001():
|
||||
limit = 2.0
|
||||
segs = [
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Owner-only project delete: files+meta gone; other owners and auth users untouched."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
|
||||
def _client(tmp_path: Path) -> TestClient:
|
||||
from backend.main import app
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_owner_delete_removes_project_files_not_auth(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
auth_users = tmp_path / "auth" / "users.json"
|
||||
auth_users.parent.mkdir(parents=True)
|
||||
auth_users.write_text('{"users":[{"email":"keep@example.com"}]}\n')
|
||||
|
||||
meta = client.post("/api/projects", json={"name": "mine"}).json()
|
||||
pid = meta["id"]
|
||||
prefix = f"users/local/projects/{pid}"
|
||||
storage = client.app.state.storage
|
||||
storage.write_json(f"{prefix}/report.json", {"findings": []})
|
||||
storage.write_text(f"{prefix}/uploads/netlist.asc", "*PADS-PCB*\n")
|
||||
|
||||
resp = client.delete(f"/api/projects/{pid}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["ok"] is True
|
||||
assert not storage.exists(f"{prefix}/project.json")
|
||||
assert not storage.exists(f"{prefix}/report.json")
|
||||
assert auth_users.is_file()
|
||||
assert "keep@example.com" in auth_users.read_text()
|
||||
listed = client.get("/api/projects").json()
|
||||
assert all(p["id"] != pid for p in listed)
|
||||
|
||||
|
||||
def test_delete_another_owners_project_is_404(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
storage = client.app.state.storage
|
||||
other = proj_svc.create_project(storage, "alice", "secret-board")
|
||||
key = f"users/alice/projects/{other.id}/project.json"
|
||||
report = f"users/alice/projects/{other.id}/report.json"
|
||||
storage.write_json(report, {"findings": [{"id": 1}]})
|
||||
alice_users = tmp_path / "users" / "alice" / "profile.json"
|
||||
alice_users.parent.mkdir(parents=True, exist_ok=True)
|
||||
alice_users.write_text('{"user_id":"alice"}\n')
|
||||
|
||||
resp = client.delete(f"/api/projects/{other.id}")
|
||||
assert resp.status_code == 404
|
||||
assert storage.exists(key)
|
||||
assert storage.exists(report)
|
||||
assert alice_users.is_file()
|
||||
|
||||
|
||||
def test_collaborator_cannot_delete_owner_project(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
storage = client.app.state.storage
|
||||
other = proj_svc.create_project(storage, "alice", "shared")
|
||||
proj_svc.add_collaborator(storage, "alice", other.id, "local")
|
||||
key = f"users/alice/projects/{other.id}/project.json"
|
||||
|
||||
resp = client.delete(f"/api/projects/{other.id}")
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert storage.exists(key)
|
||||
meta = proj_svc.get_project(storage, "alice", other.id)
|
||||
assert meta is not None
|
||||
assert "local" in meta.collaborators
|
||||
Reference in New Issue
Block a user