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:
2026-09-20 21:17:26 +02:00
parent ed3fff565d
commit 7a32c552bb
17 changed files with 510 additions and 76 deletions
+50 -1
View File
@@ -13,7 +13,7 @@ from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.functional_groups import FunctionalGroupsReport from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.hierarchy import check_hierarchy from backend.periscopex.hierarchy import check_hierarchy
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph 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 ( from backend.periscopex.pcb_power_thermal import (
check_pcb_gnd_stitch, check_pcb_gnd_stitch,
check_pcb_junction_temp, check_pcb_junction_temp,
@@ -173,6 +173,54 @@ def merge_schema_pcb_reports(
return out 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( def run_pcb_checks(
graph: DesignGraph, graph: DesignGraph,
constraints_map: dict, constraints_map: dict,
@@ -182,6 +230,7 @@ def run_pcb_checks(
) -> list[Finding]: ) -> list[Finding]:
"""Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD.""" """Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD."""
out: list[Finding] = [] out: list[Finding] = []
out.extend(_incomplete_layout_finding(layout))
for name, fn in ( for name, fn in (
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)), ("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)), ("placement_check", lambda: check_placement(graph, constraints_map, layout)),
@@ -47,10 +47,6 @@ def _pin_number(cons: ComponentConstraints, token: str) -> str | None:
return 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]: def _xy_key(x: float, y: float) -> tuple[float, float]:
return (round(x, 3), round(y, 3)) 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 return None
def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float: def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float | None:
path = _path_mm(layout, net, a, b) """Copper path length, or None when the net is not routed between pads.
if path is None:
return _dist(a, b) Do not fall back to Euclidean pad distance — that invents geometry on
return path an unfinished board.
"""
return _path_mm(layout, net, a, b)
def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None: 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) cap_pads.append(pad)
if not cap_pads: if not cap_pads:
return [] return []
nearest = min(_reach_mm(layout, net, ic_pad, p) for p in cap_pads)
extracted = rule.get("max_distance_mm") extracted = rule.get("max_distance_mm")
if extracted is None: if extracted is None:
return [] return []
limit = float(extracted) 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: if nearest <= limit:
return [] return []
return [Finding( 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.", recommendation="Place the decoupling capacitor closer to the supply pin.",
source="placement_check", source="placement_check",
rule_id="PE-PLC-001", rule_id="PE-PLC-001",
evidence_status="SUFFICIENT",
net=net, net=net,
pins=[pin_no], pins=[pin_no],
source_page=rule.get("source_page"), source_page=rule.get("source_page"),
+18 -4
View File
@@ -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: def partner_net(name: str) -> str | None:
n = name or "" n = name or ""
for a, b in _PAIR_SUFFIXES: for a, b in _PAIR_SUFFIXES:
@@ -473,7 +478,10 @@ def check_si(
layout: LayoutGraph | None, layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None, impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]: ) -> 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 [] return []
raw = impedance_nets raw = impedance_nets
if isinstance(impedance_nets, dict): if isinstance(impedance_nets, dict):
@@ -536,6 +544,8 @@ def check_si(
if bc: if bc:
z_covered.add(bc) z_covered.add(bc)
if avg is None: if avg is None:
if _routed_length_mm(layout, net) is None:
continue
findings.append(_si_finding( findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic, rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
verdict="FAIL", verdict="FAIL",
@@ -593,9 +603,11 @@ def check_si(
if key in done: if key in done:
continue continue
done.add(key) 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) 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) skew = abs(la - lb)
verdict = "PASS" if skew <= lim else "FAIL" verdict = "PASS" if skew <= lim else "FAIL"
rec = ( rec = (
@@ -620,7 +632,9 @@ def check_si(
continue continue
for net in nets: for net in nets:
zrow = _z_row(rows, net) 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" verdict = "PASS" if length <= lim else "FAIL"
findings.append(_si_finding( findings.append(_si_finding(
rule_id="PE-SI-003", net=net, mpn=mpn, designator=ic, rule_id="PE-SI-003", net=net, mpn=mpn, designator=ic,
+1 -3
View File
@@ -289,9 +289,7 @@ async def delete_project(project_id: str, request: Request):
return {"ok": True} return {"ok": True}
hit = proj_svc.resolve_project_access(storage, uid, project_id) hit = proj_svc.resolve_project_access(storage, uid, project_id)
if hit: if hit:
owner_id, _ = hit raise HTTPException(403, "Only the project owner can delete this project")
proj_svc.remove_collaborator(storage, owner_id, project_id, uid)
return {"ok": True, "removed_self": True}
raise HTTPException(404, "Project not found") raise HTTPException(404, "Project not found")
@@ -2,6 +2,14 @@
What's new in Periscope. 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 ## 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. 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "periscope-web", "name": "periscope-web",
"version": "2.59.0", "version": "2.60.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "periscope-web", "name": "periscope-web",
"version": "2.59.0", "version": "2.60.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@types/dagre": "^0.7.54", "@types/dagre": "^0.7.54",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "periscope-web", "name": "periscope-web",
"version": "2.59.0", "version": "2.60.0",
"private": true, "private": true,
"scripts": { "scripts": {
"sync-version": "node scripts/sync-version.mjs", "sync-version": "node scripts/sync-version.mjs",
@@ -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 (
<>
<button
type="button"
onClick={ask}
disabled={busy}
title="Delete project"
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<AlertDialogHeader>
<AlertDialogTitle>Delete {project.name}?</AlertDialogTitle>
<AlertDialogDescription>
This removes the project files and metadata for you as owner. It
does not change other users&apos; projects or login accounts.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={commitDelete}
>
{busy ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,13 +1,12 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; 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 { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReviewedCount } from "@/hooks/use-reviewed-count"; import { useReviewedCount } from "@/hooks/use-reviewed-count";
import { deleteProject } from "@/lib/api";
import type { Project } from "@/lib/types"; import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -40,7 +39,6 @@ export function ProjectCard({
onRerun?: (project: Project) => void; onRerun?: (project: Project) => void;
}) { }) {
const { user } = useOptionalUser(); const { user } = useOptionalUser();
const [busyDelete, setBusyDelete] = useState(false);
const reviewed = useReviewedCount(project.id); const reviewed = useReviewedCount(project.id);
const { summary } = project; const { summary } = project;
const findingTotal = summary?.total ?? 0; const findingTotal = summary?.total ?? 0;
@@ -57,19 +55,6 @@ export function ProjectCard({
project.status === "draft"); project.status === "draft");
const clickOpensWizard = draft && !foreign && onRerun != null; 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) { function replace(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -116,13 +101,7 @@ export function ProjectCard({
</button> </button>
)} )}
{!foreign && ( {!foreign && (
<button <DeleteProjectButton project={project} onDeleted={onDeleted} />
onClick={remove}
disabled={busyDelete}
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)} )}
</div> </div>
</div> </div>
@@ -1,12 +1,11 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; 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 { Badge } from "@/components/ui/badge";
import { DeleteProjectButton } from "@/components/dashboard/delete-project-button";
import { useOptionalUser } from "@/hooks/use-optional-auth"; import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReviewedCount } from "@/hooks/use-reviewed-count"; import { useReviewedCount } from "@/hooks/use-reviewed-count";
import { deleteProject } from "@/lib/api";
import type { Project } from "@/lib/types"; import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -75,7 +74,6 @@ function TableRow({
onRerun?: (project: Project) => void; onRerun?: (project: Project) => void;
}) { }) {
const { user } = useOptionalUser(); const { user } = useOptionalUser();
const [busyDelete, setBusyDelete] = useState(false);
const reviewed = useReviewedCount(project.id); const reviewed = useReviewedCount(project.id);
const { summary } = project; const { summary } = project;
const findingTotal = summary?.total ?? 0; const findingTotal = summary?.total ?? 0;
@@ -92,19 +90,6 @@ function TableRow({
project.status === "draft"); project.status === "draft");
const clickOpensWizard = draft && !foreign && onRerun != null; 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) { function replace(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -199,13 +184,7 @@ function TableRow({
</button> </button>
)} )}
{!foreign && ( {!foreign && (
<button <DeleteProjectButton project={project} onDeleted={onDeleted} />
onClick={remove}
disabled={busyDelete}
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)} )}
</div> </div>
</td> </td>
+126
View File
@@ -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)
)
+10 -2
View File
@@ -173,11 +173,15 @@ def test_missing_footprint_is_pe_lay_002():
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations(): def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout 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( findings = run_pcb_checks(
_graph(), _graph(),
_xtal_cons(max_distance_mm=2.0), _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"] plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
assert plc assert plc
@@ -187,11 +191,15 @@ def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
def test_close_decoupling_has_no_plc_001(): def test_close_decoupling_has_no_plc_001():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout 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( findings = run_pcb_checks(
_graph(), _graph(),
_xtal_cons(max_distance_mm=2.0), _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) 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 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") 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] first = changelog_paths()[0]
assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md" assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md"
assert first == SRC / "changelog.md" assert first == SRC / "changelog.md"
@@ -17,7 +17,7 @@ DOCKERIGNORE = ROOT / ".dockerignore"
def test_package_json_is_periscope_web(): def test_package_json_is_periscope_web():
text = PKG.read_text(encoding="utf-8") text = PKG.read_text(encoding="utf-8")
assert '"name": "periscope-web"' in text 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 "Native Periscope overlay" not in text[:400]
assert LOCK.is_file() assert LOCK.is_file()
lock = LOCK.read_text(encoding="utf-8") lock = LOCK.read_text(encoding="utf-8")
+65
View File
@@ -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
+21 -2
View File
@@ -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(): def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001():
limit = 2.0 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( findings = check_placement(
_graph(), _graph(),
_xtal_cons(max_distance_mm=limit), _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"] plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
assert len(plc) == 1 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(): 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( assert check_placement(
_graph(), _graph(),
_xtal_cons(max_distance_mm=2.0), _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(): def test_track_path_longer_than_max_distance_mm_is_ps_plc_001():
limit = 2.0 limit = 2.0
segs = [ segs = [
+74
View File
@@ -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