diff --git a/periscope/src/backend/periscopex/pcb_checks.py b/periscope/src/backend/periscopex/pcb_checks.py index 6612d40..3ffc0b7 100644 --- a/periscope/src/backend/periscopex/pcb_checks.py +++ b/periscope/src/backend/periscopex/pcb_checks.py @@ -13,7 +13,7 @@ from backend.periscopex.finding_engine import complete_findings from backend.periscopex.functional_groups import FunctionalGroupsReport from backend.periscopex.hierarchy import check_hierarchy from backend.periscopex.models import DesignGraph, Finding, LayoutGraph -from backend.periscopex.pcb_net_match import check_pcb_net_match +from backend.periscopex.pcb_net_match import check_pcb_net_match, kicad_nets_match from backend.periscopex.pcb_power_thermal import ( check_pcb_gnd_stitch, check_pcb_junction_temp, @@ -173,6 +173,54 @@ def merge_schema_pcb_reports( return out +def _incomplete_layout_finding(layout: LayoutGraph | None) -> list[Finding]: + """INFO when pads exist on nets that have no track copper.""" + if layout is None or not layout.footprints: + return [] + pad_nets = { + p.net + for fp in layout.footprints.values() + for p in fp.pads + if p.net + } + if not pad_nets: + return [] + seg_nets = {s.net for s in layout.segments if s.net} + missing = sorted( + n for n in pad_nets + if not any(kicad_nets_match(n, s) for s in seg_nets) + ) + if not missing: + return [] + rec = ( + "Finish routing (and placement) then re-run PCB review. " + "Checks that need copper skip or mark INSUFFICIENT; they do not invent tracks." + ) + sample = ", ".join(missing[:6]) + extra = f" (+{len(missing) - 6} more)" if len(missing) > 6 else "" + return [Finding( + designator="PCB", + mpn="", + aspect="pcb_match", + finding=( + f"Layout has {len(layout.footprints)} footprint(s) and " + f"{len(layout.segments)} track segment(s); {len(missing)} pad net(s) " + f"have no copper ({sample}{extra})." + ), + facts=f"unrouted_pad_nets={len(missing)}; segments={len(layout.segments)}.", + requirement="Routing-dependent PCB checks need track geometry.", + inference="INSUFFICIENT — same MODE=pcb exam, no incomplete-board mode.", + why="Unfinished placing/routing is not a fabricated millimetre FAIL.", + status="INFO", + recommendation=rec, + action=rec, + source="pcb_checks", + rule_id="PE-LAY-004", + evidence_status="INSUFFICIENT", + pins=[], + )] + + def run_pcb_checks( graph: DesignGraph, constraints_map: dict, @@ -182,6 +230,7 @@ def run_pcb_checks( ) -> list[Finding]: """Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD.""" out: list[Finding] = [] + out.extend(_incomplete_layout_finding(layout)) for name, fn in ( ("pcb_net_match", lambda: check_pcb_net_match(graph, layout)), ("placement_check", lambda: check_placement(graph, constraints_map, layout)), diff --git a/periscope/src/backend/periscopex/placement_check.py b/periscope/src/backend/periscopex/placement_check.py index efea379..5f41668 100644 --- a/periscope/src/backend/periscopex/placement_check.py +++ b/periscope/src/backend/periscopex/placement_check.py @@ -47,10 +47,6 @@ def _pin_number(cons: ComponentConstraints, token: str) -> str | None: return None -def _dist(a: LayoutPad, b: LayoutPad) -> float: - return math.hypot(a.x - b.x, a.y - b.y) - - def _xy_key(x: float, y: float) -> tuple[float, float]: return (round(x, 3), round(y, 3)) @@ -86,11 +82,13 @@ def _path_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float return None -def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float: - path = _path_mm(layout, net, a, b) - if path is None: - return _dist(a, b) - return path +def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float | None: + """Copper path length, or None when the net is not routed between pads. + + Do not fall back to Euclidean pad distance — that invents geometry on + an unfinished board. + """ + return _path_mm(layout, net, a, b) def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None: @@ -151,11 +149,41 @@ def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou cap_pads.append(pad) if not cap_pads: return [] - nearest = min(_reach_mm(layout, net, ic_pad, p) for p in cap_pads) extracted = rule.get("max_distance_mm") if extracted is None: return [] limit = float(extracted) + reaches = [ + d for d in (_reach_mm(layout, net, ic_pad, p) for p in cap_pads) if d is not None + ] + if not reaches: + rec = ( + f"Route {net} from {ref}.{pin_no} to the decoupling capacitor, " + "then re-run PCB review." + ) + return [Finding( + designator=ref, + mpn=comp.mpn or cons.mpn, + aspect="placement", + finding=( + f"No routed copper on {net} between {ref}.{pin_no} and a " + "decoupling pad — proximity not measured." + ), + facts="Track path missing; pad coordinates are not used as a stand-in length.", + requirement=f"layout_rules max_distance_mm={limit:g} needs a copper path.", + inference="INSUFFICIENT — unfinished routing, not a millimetre FAIL.", + why=f"layout_rules max_distance_mm={limit:g}.", + status="INFO", + recommendation=rec, + action=rec, + source="placement_check", + rule_id="PE-PLC-005", + evidence_status="INSUFFICIENT", + net=net, + pins=[pin_no], + source_page=rule.get("source_page"), + )] + nearest = min(reaches) if nearest <= limit: return [] return [Finding( @@ -171,6 +199,7 @@ def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou recommendation="Place the decoupling capacitor closer to the supply pin.", source="placement_check", rule_id="PE-PLC-001", + evidence_status="SUFFICIENT", net=net, pins=[pin_no], source_page=rule.get("source_page"), diff --git a/periscope/src/backend/periscopex/si_check.py b/periscope/src/backend/periscopex/si_check.py index 53be5d7..a3b9aa9 100644 --- a/periscope/src/backend/periscopex/si_check.py +++ b/periscope/src/backend/periscopex/si_check.py @@ -113,6 +113,11 @@ def net_length_mm(layout: LayoutGraph, net: str) -> float: ) +def _routed_length_mm(layout: LayoutGraph, net: str) -> float | None: + n = net_length_mm(layout, net) + return n if n > 0 else None + + def partner_net(name: str) -> str | None: n = name or "" for a, b in _PAIR_SUFFIXES: @@ -473,7 +478,10 @@ def check_si( layout: LayoutGraph | None, impedance_nets: list[dict] | dict | None = None, ) -> list[Finding]: - if layout is None or not layout.segments: + if layout is None: + return [] + # Unrouted boards skip SI millimetre/Z checks (no invented 0 mm lengths). + if not layout.segments: return [] raw = impedance_nets if isinstance(impedance_nets, dict): @@ -536,6 +544,8 @@ def check_si( if bc: z_covered.add(bc) if avg is None: + if _routed_length_mm(layout, net) is None: + continue findings.append(_si_finding( rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic, verdict="FAIL", @@ -593,9 +603,11 @@ def check_si( if key in done: continue done.add(key) - la = _z_field(zrow, "length_mm") or net_length_mm(layout, net) + la = _z_field(zrow, "length_mm") or _routed_length_mm(layout, net) zb = _z_row(rows, partner) - lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner) + lb = _z_field(zb, "length_mm") or _routed_length_mm(layout, partner) + if la is None or lb is None: + continue skew = abs(la - lb) verdict = "PASS" if skew <= lim else "FAIL" rec = ( @@ -620,7 +632,9 @@ def check_si( continue for net in nets: zrow = _z_row(rows, net) - length = _z_field(zrow, "length_mm") or net_length_mm(layout, net) + length = _z_field(zrow, "length_mm") or _routed_length_mm(layout, net) + if length is None: + continue verdict = "PASS" if length <= lim else "FAIL" findings.append(_si_finding( rule_id="PE-SI-003", net=net, mpn=mpn, designator=ic, diff --git a/periscope/src/backend/routers/projects.py b/periscope/src/backend/routers/projects.py index 426ba56..8bd24d0 100644 --- a/periscope/src/backend/routers/projects.py +++ b/periscope/src/backend/routers/projects.py @@ -289,9 +289,7 @@ async def delete_project(project_id: str, request: Request): return {"ok": True} hit = proj_svc.resolve_project_access(storage, uid, project_id) if hit: - owner_id, _ = hit - proj_svc.remove_collaborator(storage, owner_id, project_id, uid) - return {"ok": True, "removed_self": True} + raise HTTPException(403, "Only the project owner can delete this project") raise HTTPException(404, "Project not found") diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 3c452b4..0eb443c 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Periscope. +## 2.60.0 — 2026-09-20 — Incomplete PCB exam and owner project delete + +Unfinished placing/routing still uses MODE=pcb. Parser and checks run on partial layouts; missing copper is INSUFFICIENT or skip, never invented millimetres. Via≠Pad unchanged. Dashboard owners can delete a project after a confirm dialog. + +- [Changed] Decoupling proximity uses routed copper only (`PE-PLC-005` when unrouted). +- [New] `PE-LAY-004` INFO when pad nets have no tracks. +- [New] Owner-only `DELETE /api/projects/{id}` with confirm dialog; collaborators get 403. + ## 2.59.0 — 2026-09-20 — Docker without unused PinScope product tree Images copy `periscope/src` plus leftover seams (shadcn/Clerk/billing, unused LLM providers, GCS, landing png/gif) and `vendor/`. They do not copy the inherited PinScope product tree. `dependency/` stays in git. Audit: `periscope/src/docs/development/DEPENDENCY_AUDIT.md`. Pad≠Via unchanged. diff --git a/periscope/src/frontend/package-lock.json b/periscope/src/frontend/package-lock.json index 20fcf5f..732ce75 100644 --- a/periscope/src/frontend/package-lock.json +++ b/periscope/src/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "periscope-web", - "version": "2.59.0", + "version": "2.60.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "periscope-web", - "version": "2.59.0", + "version": "2.60.0", "dependencies": { "@base-ui/react": "^1.3.0", "@types/dagre": "^0.7.54", diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index 7a73bae..7b33a72 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.59.0", + "version": "2.60.0", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/periscope/src/frontend/src/components/dashboard/delete-project-button.tsx b/periscope/src/frontend/src/components/dashboard/delete-project-button.tsx new file mode 100644 index 0000000..3b9fad4 --- /dev/null +++ b/periscope/src/frontend/src/components/dashboard/delete-project-button.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { deleteProject } from "@/lib/api"; +import type { Project } from "@/lib/types"; + +export function DeleteProjectButton({ + project, + onDeleted, +}: { + project: Project; + onDeleted?: () => void; +}) { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + + function ask(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + setOpen(true); + } + + async function commitDelete(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + setBusy(true); + try { + await deleteProject(project.id); + setOpen(false); + onDeleted?.(); + } catch { + setBusy(false); + } + } + + return ( + <> + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + Delete {project.name}? + + This removes the project files and metadata for you as owner. It + does not change other users' projects or login accounts. + + + + Cancel + + {busy ? "Deleting…" : "Delete"} + + + + + + ); +} diff --git a/periscope/src/frontend/src/components/dashboard/project-card.tsx b/periscope/src/frontend/src/components/dashboard/project-card.tsx index 78af78f..6f75a6c 100644 --- a/periscope/src/frontend/src/components/dashboard/project-card.tsx +++ b/periscope/src/frontend/src/components/dashboard/project-card.tsx @@ -1,13 +1,12 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; -import { RotateCcw, Trash2, Users } from "lucide-react"; +import { RotateCcw, Users } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DeleteProjectButton } from "@/components/dashboard/delete-project-button"; import { useOptionalUser } from "@/hooks/use-optional-auth"; import { useReviewedCount } from "@/hooks/use-reviewed-count"; -import { deleteProject } from "@/lib/api"; import type { Project } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -40,7 +39,6 @@ export function ProjectCard({ onRerun?: (project: Project) => void; }) { const { user } = useOptionalUser(); - const [busyDelete, setBusyDelete] = useState(false); const reviewed = useReviewedCount(project.id); const { summary } = project; const findingTotal = summary?.total ?? 0; @@ -57,19 +55,6 @@ export function ProjectCard({ project.status === "draft"); const clickOpensWizard = draft && !foreign && onRerun != null; - async function remove(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - if (!confirm(`Delete "${project.name}"?`)) return; - setBusyDelete(true); - try { - await deleteProject(project.id); - onDeleted?.(); - } catch { - setBusyDelete(false); - } - } - function replace(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -116,13 +101,7 @@ export function ProjectCard({ )} {!foreign && ( - + )} diff --git a/periscope/src/frontend/src/components/dashboard/projects-table.tsx b/periscope/src/frontend/src/components/dashboard/projects-table.tsx index 030689c..46e9135 100644 --- a/periscope/src/frontend/src/components/dashboard/projects-table.tsx +++ b/periscope/src/frontend/src/components/dashboard/projects-table.tsx @@ -1,12 +1,11 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; -import { RotateCcw, Trash2, Users } from "lucide-react"; +import { RotateCcw, Users } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { DeleteProjectButton } from "@/components/dashboard/delete-project-button"; import { useOptionalUser } from "@/hooks/use-optional-auth"; import { useReviewedCount } from "@/hooks/use-reviewed-count"; -import { deleteProject } from "@/lib/api"; import type { Project } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -75,7 +74,6 @@ function TableRow({ onRerun?: (project: Project) => void; }) { const { user } = useOptionalUser(); - const [busyDelete, setBusyDelete] = useState(false); const reviewed = useReviewedCount(project.id); const { summary } = project; const findingTotal = summary?.total ?? 0; @@ -92,19 +90,6 @@ function TableRow({ project.status === "draft"); const clickOpensWizard = draft && !foreign && onRerun != null; - async function remove(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - if (!confirm(`Delete "${project.name}"?`)) return; - setBusyDelete(true); - try { - await deleteProject(project.id); - onDeleted?.(); - } catch { - setBusyDelete(false); - } - } - function replace(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -199,13 +184,7 @@ function TableRow({ )} {!foreign && ( - + )} diff --git a/tests/test_incomplete_pcb.py b/tests/test_incomplete_pcb.py new file mode 100644 index 0000000..8a952df --- /dev/null +++ b/tests/test_incomplete_pcb.py @@ -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) + ) diff --git a/tests/test_pcb_review.py b/tests/test_pcb_review.py index e403fc5..38307b4 100644 --- a/tests/test_pcb_review.py +++ b/tests/test_pcb_review.py @@ -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) diff --git a/tests/test_periscope_frontend_content_overlay.py b/tests/test_periscope_frontend_content_overlay.py index 84dd5fb..1d6eef6 100644 --- a/tests/test_periscope_frontend_content_overlay.py +++ b/tests/test_periscope_frontend_content_overlay.py @@ -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" diff --git a/tests/test_periscope_leftover_package_requirements.py b/tests/test_periscope_leftover_package_requirements.py index dc35c08..1956043 100644 --- a/tests/test_periscope_leftover_package_requirements.py +++ b/tests/test_periscope_leftover_package_requirements.py @@ -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") diff --git a/tests/test_periscope_project_delete_ui.py b/tests/test_periscope_project_delete_ui.py new file mode 100644 index 0000000..c3dd3b2 --- /dev/null +++ b/tests/test_periscope_project_delete_ui.py @@ -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 diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py index 03e0095..7eabad6 100644 --- a/tests/test_placement_check.py +++ b/tests/test_placement_check.py @@ -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 = [ diff --git a/tests/test_project_delete.py b/tests/test_project_delete.py new file mode 100644 index 0000000..29fe1c1 --- /dev/null +++ b/tests/test_project_delete.py @@ -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