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:
@@ -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)),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' 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";
|
||||
|
||||
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({
|
||||
</button>
|
||||
)}
|
||||
{!foreign && (
|
||||
<button
|
||||
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>
|
||||
<DeleteProjectButton project={project} onDeleted={onDeleted} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</button>
|
||||
)}
|
||||
{!foreign && (
|
||||
<button
|
||||
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>
|
||||
<DeleteProjectButton project={project} onDeleted={onDeleted} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -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