diff --git a/periscope/src/frontend/src/lib/api.ts b/periscope/src/frontend/src/lib/api.ts new file mode 100644 index 0000000..f51ed47 --- /dev/null +++ b/periscope/src/frontend/src/lib/api.ts @@ -0,0 +1,1176 @@ +/** Browser client for the Periscope HTTP API. Paths and JSON match the FastAPI routers. */ + +import { projectVersionFromPayload } from "./pinscope-compat"; +import type { + AntennaReport, + ApiLogEntry, + AutoTopupConfig, + BomSummaryRow, + Collaborator, + ComponentMpnBuckets, + CostEstimate, + CreditGrant, + CreditLedgerEntry, + CreditSnapshot, + DesignGraph, + DeratingRow, + EdifSubDesign, + FindingComment, + FindingReview, + FindingReviewState, + ImpedanceKind, + ImpedanceNetsReport, + ImpedanceStackupResult, + ImpedanceTraceResult, + LcscPayload, + NetlistPreviewDesignator, + PauseCheckpoint, + PlacementPack, + PlacementPlan, + Project, + SkippedComponent, + ValidationReport, +} from "./types"; + +export function safeMpn(mpn: string): string { + return mpn.replace(/\//g, "_").replace(/:/g, "_"); +} + +const ROOT = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:18741"; + +let tokenSource: (() => Promise) | null = null; + +export function setTokenGetter(getter: () => Promise) { + tokenSource = getter; +} + +async function bearer(): Promise { + if (!tokenSource) return {}; + const token = await tokenSource(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function send(path: string, init: RequestInit = {}): Promise { + return fetch(`${ROOT}${path}`, { + ...init, + headers: { ...init.headers, ...(await bearer()) }, + }); +} + +async function fail(res: Response, fallback: string): Promise { + let detail: unknown; + try { + const body = (await res.json()) as { detail?: unknown; error?: unknown }; + detail = body.detail ?? body.error; + } catch { + detail = undefined; + } + const msg = typeof detail === "string" && detail.trim() ? detail : fallback; + throw new Error(msg); +} + +async function jsonOrThrow(res: Response, fallback: string): Promise { + if (!res.ok) await fail(res, fallback); + return res.json() as Promise; +} + +function asProject(raw: Record): Project { + const pipeline = raw.pipeline_state as Record | null | undefined; + const err = pipeline?.error; + return { + id: raw.id as string, + name: raw.name as string, + created: raw.created as string, + status: raw.status as Project["status"], + summary: raw.summary as Record | undefined, + hasNetlist: Boolean(raw.has_netlist), + hasBom: Boolean(raw.has_bom), + hasPcb: Boolean(raw.has_pcb), + datasheetCount: raw.datasheet_count as number, + skippedComponents: (raw.skipped_components as SkippedComponent[] | null) ?? undefined, + completedReviewRefs: (raw.completed_review_refs as string[] | null) ?? undefined, + userId: raw.user_id as string | undefined, + collaborators: (raw.collaborators as string[] | null) ?? undefined, + creditsSpent: raw.credits_spent as number | undefined, + totalCostUsd: (raw.total_cost_usd as number | null | undefined) ?? null, + pauseCheckpoint: (raw.pause_checkpoint as PauseCheckpoint | null) ?? null, + pauseReason: (raw.pause_reason as string | null | undefined) ?? null, + bomColumns: (raw.bom_columns as { reference: string; mpn: string } | null) ?? null, + pipelineError: typeof err === "string" ? err : null, + lcscToMpn: (raw.lcsc_to_mpn as Record | null) ?? null, + lcscPayloads: (raw.lcsc_payloads as Record | null) ?? null, + componentMpns: (raw.component_mpns as ComponentMpnBuckets | null) ?? null, + periscopeVersion: projectVersionFromPayload(raw), + netlistFormat: (raw.netlist_format as Project["netlistFormat"]) ?? null, + netlistSubdesigns: (raw.netlist_subdesigns as string[] | null) ?? null, + placementStatus: (raw.placement_status as Project["placementStatus"]) ?? "draft", + placementState: (raw.placement_state as Record | null) ?? null, + pcbStatus: (raw.pcb_status as Project["pcbStatus"]) ?? "draft", + pcbState: (raw.pcb_state as Record | null) ?? null, + }; +} + +export async function fetchProjects(): Promise { + const res = await send("/api/projects"); + if (!res.ok) throw new Error("Failed to fetch projects"); + const rows = (await res.json()) as Record[]; + return rows.map(asProject); +} + +export async function createPortalSession(returnUrl: string): Promise { + const res = await send("/api/billing/portal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ return_url: returnUrl }), + }); + const data = await jsonOrThrow<{ url: string }>(res, "Failed to create portal session"); + return data.url; +} + +export async function createProject(name: string): Promise { + const res = await send("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!res.ok) throw new Error("Failed to create project"); + return asProject(await res.json()); +} + +export async function fetchProject(projectId: string): Promise { + const res = await send(`/api/projects/${projectId}`); + if (!res.ok) throw new Error("Failed to fetch project"); + return asProject(await res.json()); +} + +export async function deleteProject(projectId: string): Promise { + const res = await send(`/api/projects/${projectId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete project"); +} + +export async function reopenProject(projectId: string): Promise { + const res = await send(`/api/projects/${projectId}/reopen`, { method: "POST" }); + return asProject(await jsonOrThrow(res, "Failed to reopen project")); +} + +export async function renameProject(projectId: string, name: string): Promise { + const res = await send(`/api/projects/${projectId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + return asProject(await jsonOrThrow(res, "Failed to rename project")); +} + +async function asFile(path: string, filename: string, type: string, fallback: string): Promise { + const res = await send(path); + if (!res.ok) throw new Error(fallback); + return new File([await res.blob()], filename, { type }); +} + +export async function downloadProjectBom(projectId: string): Promise { + return asFile(`/api/projects/${projectId}/files/bom`, "bom.csv", "text/csv", "Failed to download BOM"); +} + +export async function downloadProjectNetlist(projectId: string): Promise { + return asFile( + `/api/projects/${projectId}/files/netlist`, + "netlist.asc", + "text/plain", + "Failed to download netlist", + ); +} + +export async function fetchProjectDatasheets(projectId: string): Promise> { + const res = await send(`/api/projects/${projectId}/files/datasheets`); + if (!res.ok) return new Set(); + const data = (await res.json()) as { stems?: string[] }; + return new Set(data.stems ?? []); +} + +export async function fetchCollaborators( + projectId: string, +): Promise<{ owner_user_id: string; collaborators: Collaborator[] }> { + const res = await send(`/api/projects/${projectId}/collaborators`); + if (!res.ok) return { owner_user_id: "", collaborators: [] }; + return res.json(); +} + +export async function addCollaborator(projectId: string, email: string): Promise { + const res = await send(`/api/projects/${projectId}/collaborators`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + return jsonOrThrow(res, "Failed to add collaborator"); +} + +export async function removeCollaborator(projectId: string, userId: string): Promise { + const res = await send(`/api/projects/${projectId}/collaborators/${userId}`, { method: "DELETE" }); + if (!res.ok) await fail(res, "Failed to remove collaborator"); +} + +export async function makeCollaboratorOwner(projectId: string, userId: string): Promise { + const res = await send(`/api/projects/${projectId}/collaborators/${userId}/make-owner`, { + method: "POST", + }); + if (!res.ok) await fail(res, "Failed to transfer ownership"); +} + +export interface UploadBomResponse { + path: string; + components: number; + lcsc_resolved: number; + lcsc_detected: boolean; + lcsc_to_mpn: Record; +} + +export async function uploadBom( + projectId: string, + file: File, + referenceColumn?: string, + mpnColumn?: string, +): Promise { + const form = new FormData(); + form.append("file", file); + const qs = new URLSearchParams(); + if (referenceColumn) qs.set("reference_column", referenceColumn); + if (mpnColumn) qs.set("mpn_column", mpnColumn); + const suffix = qs.toString() ? `?${qs}` : ""; + const res = await send(`/api/projects/${projectId}/upload/bom${suffix}`, { + method: "POST", + body: form, + }); + return jsonOrThrow(res, "Failed to upload BOM"); +} + +export interface UploadNetlistResult { + path: string; + parts: number; + nets: number; + format: "pads" | "edif" | "kicad_xml" | "kicad_sexp" | "kicad_sch"; + sub_designs: EdifSubDesign[]; + designator_pins: NetlistPreviewDesignator[]; + pcb_saved?: boolean; + bom_saved?: boolean; + sheets?: number; +} + +export async function uploadPcb( + projectId: string, + file: File, +): Promise<{ path: string; footprints: number; nets: number; segments: number }> { + const form = new FormData(); + form.append("file", file); + const res = await send(`/api/projects/${projectId}/upload/pcb`, { method: "POST", body: form }); + return jsonOrThrow(res, "Failed to upload PCB"); +} + +export async function uploadNetlist(projectId: string, files: File | File[]): Promise { + const list = Array.isArray(files) ? files : [files]; + const form = new FormData(); + const rels = list.map((f) => { + const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath; + return rel && rel.length > 0 ? rel : f.name; + }); + if (list[0]) form.append("file", list[0], list[0].name); + if (list.length > 1) { + for (const f of list) form.append("files", f, f.name); + form.append("paths", JSON.stringify(rels)); + } + const res = await send(`/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form }); + const data = await jsonOrThrow>(res, "Failed to upload netlist"); + return { + path: data.path as string, + parts: data.parts as number, + nets: data.nets as number, + format: data.format as UploadNetlistResult["format"], + sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [], + designator_pins: (data.designator_pins as NetlistPreviewDesignator[] | undefined) ?? [], + pcb_saved: Boolean(data.pcb_saved), + bom_saved: Boolean(data.bom_saved), + sheets: typeof data.sheets === "number" ? data.sheets : undefined, + }; +} + +export async function fetchNetlistSubdesigns( + projectId: string, +): Promise<{ sub_designs: EdifSubDesign[]; selected: string[] | null }> { + const res = await send(`/api/projects/${projectId}/netlist/subdesigns`); + const data = await jsonOrThrow>(res, "Failed to fetch sub-designs"); + return { + sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [], + selected: (data.selected as string[] | null | undefined) ?? null, + }; +} + +export async function updateNetlistSubdesigns( + projectId: string, + selected: string[] | null, +): Promise { + const res = await send(`/api/projects/${projectId}/netlist/subdesigns`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selected }), + }); + return asProject(await jsonOrThrow(res, "Failed to update sub-designs")); +} + +export async function uploadDatasheet( + projectId: string, + mpn: string, + file: File, + alsoFor?: string[], +) { + const form = new FormData(); + form.append("file", file); + let url = `/api/projects/${projectId}/upload/datasheets?mpn=${encodeURIComponent(mpn)}`; + if (alsoFor?.length) url += `&also_for=${alsoFor.map(encodeURIComponent).join(",")}`; + const res = await send(url, { method: "POST", body: form }); + return jsonOrThrow(res, "Failed to upload datasheet"); +} + +export async function checkLibrary( + icMpns: string[], + passiveMpns: string[], + simpleMpns: string[] = [], +): Promise<{ + ic_resolved: string[]; + passive_resolved: string[]; + simple_resolved: string[]; + datasheets_available: string[]; +}> { + const empty = { + ic_resolved: [], + passive_resolved: [], + simple_resolved: [], + datasheets_available: [], + }; + const res = await send("/api/library/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ic_mpns: icMpns, + passive_mpns: passiveMpns, + simple_mpns: simpleMpns, + }), + }); + if (!res.ok) return empty; + return res.json(); +} + +export interface LibraryIC { + mpn: string; + type: "ic"; + subtype: string; + pin_count: number; + has_ratings: boolean; + has_datasheet: boolean; +} + +export interface LibraryPassive { + mpn: string; + type: "passive"; + subtype: string; + description: string; + regex: string; +} + +export interface LibraryPassivePart { + mpn: string; + type: "passive_part"; + specs_type: string; + subtype: string; + param_count: number; + has_datasheet: boolean; +} + +export interface LibrarySimple { + mpn: string; + type: "simple"; + specs_type: string; + subtype: string; + param_count: number; + has_datasheet: boolean; +} + +export interface LibraryDatasheet { + mpn: string; + hash: string | null; + has_extraction: boolean; + has_model: boolean; +} + +export interface LibraryCatalog { + ics: LibraryIC[]; + passives: LibraryPassive[]; + passive_parts?: LibraryPassivePart[]; + simple: LibrarySimple[]; + datasheets: LibraryDatasheet[]; +} + +export async function fetchLibrary(): Promise { + const res = await send("/api/library", { cache: "no-store" }); + return jsonOrThrow(res, "Failed to load component library"); +} + +export async function fetchLibraryDatasheetUrl(mpn: string): Promise { + const res = await send(`/api/library/datasheet/${encodeURIComponent(mpn)}`); + if (!res.ok) return null; + return URL.createObjectURL(await res.blob()); +} + +async function postPipeline(path: string, fallback: string, body?: unknown) { + const res = await send(path, { + method: "POST", + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return jsonOrThrow(res, fallback); +} + +export async function reprocessPipeline(projectId: string, mode: "failed" | "all" = "failed") { + return postPipeline(`/api/pipeline/${projectId}/reprocess`, "Failed to reprocess pipeline", { mode }); +} + +export async function startPipeline(projectId: string) { + return postPipeline(`/api/pipeline/${projectId}/start`, "Failed to start pipeline"); +} + +export async function cancelPipeline(projectId: string) { + return postPipeline(`/api/pipeline/${projectId}/cancel`, "Failed to cancel pipeline"); +} + +export async function restartPipeline(projectId: string) { + return postPipeline(`/api/pipeline/${projectId}/restart`, "Failed to restart pipeline"); +} + +export async function regenPipeline(projectId: string, stages: string[]) { + return postPipeline(`/api/pipeline/${projectId}/regen`, "Failed to start regen", { stages }); +} + +export async function adminMarkProjectComplete(projectId: string) { + return postPipeline( + `/api/admin/projects/${projectId}/mark-complete`, + "Failed to mark project complete", + ); +} + +export function pipelineEventsUrl(projectId: string): string { + return `${ROOT}/api/pipeline/${projectId}/events`; +} + +export async function fetchPipelineStatus(projectId: string) { + const res = await send(`/api/pipeline/${projectId}/status`); + return jsonOrThrow(res, "Failed to fetch pipeline status"); +} + +export async function startPlacementPipeline(projectId: string) { + return postPipeline(`/api/pipeline/${projectId}/placement/start`, "Failed to start placement"); +} + +export async function cancelPlacementPipeline(projectId: string) { + return postPipeline(`/api/pipeline/${projectId}/placement/cancel`, "Failed to cancel placement"); +} + +export function placementEventsUrl(projectId: string): string { + return `${ROOT}/api/pipeline/${projectId}/placement/events`; +} + +export async function startPcbPipeline(projectId: string) { + return postPipeline(`/api/pipeline/${projectId}/pcb/start`, "Failed to start PCB review"); +} + +export async function cancelPcbPipeline(projectId: string) { + return postPipeline(`/api/pipeline/${projectId}/pcb/cancel`, "Failed to cancel PCB review"); +} + +export function pcbEventsUrl(projectId: string): string { + return `${ROOT}/api/pipeline/${projectId}/pcb/events`; +} + +export async function fetchPcbInventory(projectId: string): Promise<{ + nets: Array>; + domains: string[]; + group_count: number; +}> { + const res = await send(`/api/pipeline/${projectId}/pcb/inventory`); + return jsonOrThrow(res, "PCB inventory not found"); +} + +export async function fetchPlacementPlan(projectId: string): Promise { + const res = await send(`/api/pipeline/${projectId}/placement/plan`); + return jsonOrThrow(res, "Placement plan not found"); +} + +export async function fetchPlacementPack(projectId: string): Promise { + const res = await send(`/api/pipeline/${projectId}/placement/pack`); + return jsonOrThrow(res, "Placement pack not found"); +} + +export async function fetchProjectLogs(projectId: string): Promise { + const res = await send(`/api/projects/${projectId}/logs`); + if (!res.ok) return []; + return res.json(); +} + +export async function fetchBomSummary(projectId: string): Promise { + const res = await send(`/api/bom/${projectId}`); + if (!res.ok) return []; + const raw = (await res.json()) as Array>; + return raw.map((r) => ({ + mpn: r.mpn as string | null, + designators: r.designators as string[], + value: r.value as string, + category: r.category as string | null, + specs: r.specs as Record | null, + description: (r.description as string | null) ?? null, + hasDatasheet: Boolean(r.has_datasheet), + })); +} + +export async function fetchDerating(projectId: string): Promise { + const res = await send(`/api/derating/${projectId}`); + if (!res.ok) return []; + return res.json(); +} + +export async function computeImpedance(body: { + mode: "trace" | "stackup"; + kind?: ImpedanceKind; + h: number; + er: number; + t: number; + w?: number | null; + s?: number | null; + target_z?: number | null; +}): Promise { + const res = await send("/api/impedance", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return jsonOrThrow(res, "Impedance compute failed"); +} + +export async function fetchImpedanceNets(projectId: string): Promise { + const res = await send(`/api/projects/${projectId}/impedance/nets`); + return jsonOrThrow(res, "Impedance nets failed"); +} + +export async function analyzeImpedanceNets( + projectId: string, + nets: string[], + pitch_mm?: number, +): Promise { + const res = await send(`/api/projects/${projectId}/impedance/nets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nets, pitch_mm }), + }); + return jsonOrThrow(res, "Impedance nets failed"); +} + +export async function fetchAntennaReport(projectId: string): Promise { + const res = await send(`/api/projects/${projectId}/antenna`); + return jsonOrThrow(res, "Antenna report failed"); +} + +export async function designAntenna( + projectId: string, + body: { + f0_mhz?: number | null; + target_z_ohm?: number; + h?: number | null; + er?: number | null; + t?: number | null; + template?: "ifa" | "meander" | "stub"; + }, +): Promise { + const res = await send(`/api/projects/${projectId}/antenna/design`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return jsonOrThrow(res, "Antenna design failed"); +} + +export async function fetchReport(projectId: string): Promise { + const res = await send(`/api/report/${projectId}`); + if (!res.ok) { + await fail( + res, + res.status === 404 + ? "Report not found — the pipeline has not finished, or it failed before writing a report." + : "Failed to fetch report", + ); + } + return res.json(); +} + +export async function setFindingReview( + projectId: string, + findingId: string, + state: FindingReviewState, + reason: string, + userName: string, +): Promise { + const res = await send( + `/api/report/${projectId}/findings/${encodeURIComponent(findingId)}/review`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state, reason, user_name: userName }), + }, + ); + return jsonOrThrow(res, "Review update failed"); +} + +export async function signReport( + projectId: string, +): Promise<{ sha256: string; user_id: string; timestamp: string }> { + const res = await send(`/api/report/${projectId}/sign`, { method: "POST" }); + return jsonOrThrow(res, "Failed to sign report"); +} + +export async function downloadEcoCsv(projectId: string): Promise { + const res = await send(`/api/report/${projectId}/eco.csv`); + if (!res.ok) throw new Error("Failed to export ECO"); + const url = URL.createObjectURL(await res.blob()); + const a = document.createElement("a"); + a.href = url; + a.download = "periscope-eco.csv"; + a.click(); + URL.revokeObjectURL(url); +} + +export async function addComment( + projectId: string, + findingId: string, + text: string, + userName: string, + mentions: string[], +): Promise { + const res = await send(`/api/report/${projectId}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + finding_id: findingId, + text, + user_name: userName, + mentions, + }), + }); + return jsonOrThrow(res, "Failed to add comment"); +} + +export async function deleteComment(projectId: string, commentId: string): Promise { + const res = await send(`/api/report/${projectId}/comments/${commentId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete comment"); +} + +export async function fetchGraph(projectId: string): Promise { + const res = await send(`/api/graph/${projectId}`); + if (!res.ok) { + await fail( + res, + res.status === 404 + ? "Design graph not found — the pipeline has not finished, or it failed during graph build." + : "Failed to fetch graph", + ); + } + return res.json(); +} + +export interface AdminIC { + mpn: string; + type: "ic"; + subtype: string; + pin_count: number; + has_ratings: boolean; +} + +export interface AdminPassive { + mpn: string; + type: "passive"; + subtype: string; + description: string; + regex: string; +} + +export interface AdminSimple { + mpn: string; + type: "simple"; + specs_type: string; + subtype: string; + param_count: number; +} + +export interface AdminComponents { + ics: AdminIC[]; + passives: AdminPassive[]; + passive_parts?: AdminSimple[]; + simple: AdminSimple[]; +} + +export interface AdminUser { + user_id: string; + project_count: number; + balance: number; + name: string | null; + email: string | null; + image_url: string | null; +} + +export interface AdminUsageProject { + id: string; + name: string; + status: string; + cost_usd: number; + created: string; +} + +export interface AdminUsageUser { + user_id: string; + name: string | null; + email: string | null; + project_count: number; + total_cost_usd: number; + projects: AdminUsageProject[]; +} + +export interface AdminUsage { + grand_total_usd: number; + users: AdminUsageUser[]; +} + +export async function fetchAdminUsage(): Promise { + const res = await send("/api/admin/usage"); + return jsonOrThrow(res, "Failed to fetch usage data"); +} + +export interface AdminProject { + id: string; + name: string; + user_id: string; + status: string; + created: string; + updated: string; + has_bom: boolean; + has_netlist: boolean; + datasheet_count: number; + total_cost_usd: number | null; + pipeline_state: Record | null; + summary: Record | null; + owner_name: string | null; + owner_email: string | null; +} + +export async function fetchAdminProjects(): Promise { + const res = await send("/api/admin/projects"); + return jsonOrThrow(res, "Failed to fetch projects"); +} + +export interface AdminPipelineRun { + project_id: string; + user_id: string; + project_name: string; + started_at: string; + duration_seconds: number; + current_stage: string | null; + current_substep: string | null; + owner_name: string | null; + owner_email: string | null; +} + +export async function fetchAdminRuns(): Promise { + const res = await send("/api/admin/runs"); + return jsonOrThrow(res, "Failed to fetch running pipelines"); +} + +export async function fetchAdminComponents(): Promise { + const res = await send("/api/admin/components", { cache: "no-store" }); + return jsonOrThrow(res, "Failed to fetch components"); +} + +export async function fetchAdminComponentJson( + componentType: "ic" | "passive" | "simple", + name: string, +): Promise> { + const res = await send( + `/api/admin/components/${componentType}/${encodeURIComponent(name)}`, + ); + return jsonOrThrow(res, "Failed to fetch component JSON"); +} + +export async function deleteAdminComponent( + componentType: "ic" | "passive" | "simple", + name: string, +): Promise { + const res = await send( + `/api/admin/components/${componentType}/${encodeURIComponent(name)}`, + { method: "DELETE" }, + ); + if (!res.ok) await fail(res, `Failed to delete component (${res.status})`); +} + +export async function deleteAdminFinding( + projectId: string, + findingId: string, +): Promise<{ + deleted: string; + project_id: string; + remaining: number; + summary: Record; +}> { + const res = await send( + `/api/admin/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}`, + { method: "DELETE" }, + ); + return jsonOrThrow(res, `Failed to delete finding (${res.status})`); +} + +export async function fetchAdminUsers(): Promise { + const res = await send("/api/admin/users"); + return jsonOrThrow(res, "Failed to fetch users"); +} + +export async function searchAdminUsers(email: string): Promise { + const res = await send(`/api/admin/users/search?email=${encodeURIComponent(email)}`); + return jsonOrThrow(res, "Search failed"); +} + +export async function adminAdjustCredits(userId: string, delta: number, note: string): Promise { + const res = await send(`/api/credits/admin/${userId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ delta, note }), + }); + if (!res.ok) await fail(res, "Failed to adjust credits"); +} + +export interface AdminSettings { + default_model_version: string; + min_model_version: string; +} + +export async function fetchAdminSettings(): Promise { + const res = await send("/api/admin/settings"); + return jsonOrThrow(res, "Failed to fetch admin settings"); +} + +export async function setMinModelVersion(version: string): Promise<{ min_model_version: string }> { + const res = await send("/api/admin/settings/min-model-version", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ min_model_version: version }), + }); + return jsonOrThrow(res, "Failed to update model version setting"); +} + +export interface AutoResolveResult { + mpn: string; + status: "resolved" | "failed"; + error?: string; +} + +export async function autoResolveSimple( + items: { mpn: string; component_type: string }[], +): Promise<{ results: AutoResolveResult[] }> { + const res = await send("/api/digikey/auto-resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items }), + }); + return jsonOrThrow(res, "Auto-resolve failed"); +} + +export interface LcscResolvePassiveResponse { + mpn: string; + safe_mpn: string; + model: Record; + cached: boolean; + lcsc_id: string; +} + +export class LcscResolveError extends Error { + status: number; + required: number | null; + available: number | null; + constructor( + message: string, + status: number, + required: number | null, + available: number | null, + ) { + super(message); + this.name = "LcscResolveError"; + this.status = status; + this.required = required; + this.available = available; + } +} + +export async function resolveLcscPassive( + projectId: string, + lcscId: string, +): Promise { + const res = await send(`/api/projects/${projectId}/lcsc/resolve-passive`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lcsc_id: lcscId }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { + detail?: unknown; + } | null; + const detail = body?.detail as + | string + | { reason?: string; required?: number; available?: number } + | undefined; + if ( + res.status === 402 && + detail && + typeof detail === "object" && + detail.reason === "insufficient_credits" + ) { + throw new LcscResolveError( + "Out of credits", + 402, + typeof detail.required === "number" ? detail.required : null, + typeof detail.available === "number" ? detail.available : null, + ); + } + const msg = typeof detail === "string" ? detail : `Resolve failed (${res.status})`; + throw new LcscResolveError(msg, res.status, null, null); + } + return res.json(); +} + +export class DatasheetFetchError extends Error { + url: string | null; + urls: string[]; + source: string | null; + constructor(message: string, url: string | null, source: string | null = null, urls: string[] = []) { + super(message); + this.name = "DatasheetFetchError"; + this.url = url; + this.source = source; + this.urls = urls.length ? urls : url ? [url] : []; + } +} + +/** @deprecated Use DatasheetFetchError */ +export const DigiKeyFetchError = DatasheetFetchError; + +export async function fetchAutoDatasheet( + mpn: string, + lcscId?: string, +): Promise<{ file: File; url: string | null; source: string | null }> { + const params = new URLSearchParams({ mpn }); + if (lcscId) params.set("lcsc", lcscId); + const res = await send(`/api/datasheets/fetch?${params.toString()}`); + if (!res.ok) { + const err = (await res.json().catch(() => ({ + detail: "Fetch failed", + url: null, + source: null, + }))) as { detail?: string; url?: string | null; source?: string | null; urls?: unknown }; + throw new DatasheetFetchError( + err.detail || "Failed to fetch datasheet", + err.url ?? null, + err.source ?? null, + Array.isArray(err.urls) ? err.urls.filter((u): u is string => typeof u === "string") : [], + ); + } + return { + file: new File([await res.blob()], `${mpn}.pdf`, { type: "application/pdf" }), + url: res.headers.get("X-Datasheet-Url"), + source: res.headers.get("X-Datasheet-Source"), + }; +} + +export async function fetchDigikeyDatasheet(mpn: string): Promise<{ file: File; url: string | null }> { + return fetchAutoDatasheet(mpn); +} + +export async function fetchDatasheetUrl(projectId: string, mpn: string): Promise { + const res = await send(`/api/projects/${projectId}/datasheet/${encodeURIComponent(mpn)}`); + if (!res.ok) return null; + return URL.createObjectURL(await res.blob()); +} + +export async function fetchCredits(): Promise { + const res = await send("/api/credits"); + return jsonOrThrow(res, "Failed to fetch credits"); +} + +export interface CreditLedgerPage { + entries: CreditLedgerEntry[]; + total: number; + limit: number; + offset: number; +} + +export async function fetchCreditLedger(limit = 50, offset = 0): Promise { + const res = await send(`/api/credits/ledger?limit=${limit}&offset=${offset}`); + return jsonOrThrow(res, "Failed to fetch ledger"); +} + +export async function reconcileCheckoutSession( + sessionId: string, +): Promise<{ ok: boolean; kind: "topup" | "unknown"; payment_status: string | null }> { + const res = await send("/api/billing/reconcile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId }), + }); + return jsonOrThrow(res, "Failed to reconcile checkout"); +} + +export async function fetchCreditGrants(): Promise { + const res = await send("/api/credits/grants"); + if (!res.ok) return []; + const data = (await res.json()) as { grants?: CreditGrant[] }; + return data.grants ?? []; +} + +export async function createTopUpCheckout( + amountUsd: number, + successUrl: string, + cancelUrl: string, +): Promise { + const res = await send("/api/billing/topup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amount_usd: amountUsd, + success_url: successUrl, + cancel_url: cancelUrl, + }), + }); + const data = await jsonOrThrow<{ url: string }>(res, "Failed to start top-up"); + return data.url; +} + +export async function fetchPipelineEstimate(projectId: string): Promise { + const res = await send(`/api/pipeline/${projectId}/estimate`, { method: "POST" }); + return jsonOrThrow(res, "Failed to fetch estimate"); +} + +export async function fetchAutoTopup(): Promise { + const res = await send("/api/credits/autotopup"); + return jsonOrThrow(res, "Failed to load auto top-up config"); +} + +export async function updateAutoTopup(cfg: { + enabled: boolean; + threshold_credits: number; + amount_usd: number; +}): Promise { + const res = await send("/api/credits/autotopup", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cfg), + }); + return jsonOrThrow(res, "Failed to update auto top-up"); +} + +export async function resumePipeline(projectId: string): Promise { + const res = await send(`/api/pipeline/${projectId}/resume`, { method: "POST" }); + if (!res.ok) await fail(res, "Failed to resume pipeline"); +} + +export async function fetchSurveyStatus(): Promise<{ completed: boolean }> { + const res = await send("/api/survey/status"); + if (!res.ok) return { completed: true }; + return res.json(); +} + +export async function submitSurvey(payload: { + referral_source: string; + user_profile: string; +}): Promise<{ ok: boolean }> { + const res = await send("/api/survey", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) return { ok: false }; + return res.json(); +} + +export interface FeedbackTicket { + ticket_id: string; + user_id: string; + user_name: string | null; + user_email: string | null; + project_id: string | null; + project_name: string | null; + type: "bug" | "rule_feedback" | "feature_request"; + status: "open" | "acknowledged" | "resolved"; + finding_id: string | null; + finding_text: string | null; + finding_designator: string | null; + finding_mpn: string | null; + finding_status: string | null; + message: string; + admin_notes: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateFeedbackPayload { + type: "bug" | "rule_feedback" | "feature_request"; + message: string; + project_id?: string; + project_name?: string; + user_name?: string; + user_email?: string; + finding_id?: string; + finding_text?: string; + finding_designator?: string; + finding_mpn?: string; + finding_status?: string; +} + +export async function submitFeedback(payload: CreateFeedbackPayload): Promise { + const res = await send("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return jsonOrThrow(res, "Failed to submit feedback"); +} + +export async function fetchMyFeedback(status?: string): Promise { + const qs = status ? `?status=${status}` : ""; + const res = await send(`/api/feedback${qs}`); + return jsonOrThrow(res, "Failed to load feedback"); +} + +export async function fetchAdminFeedback(params?: { + project_id?: string; + status?: string; + type?: string; +}): Promise { + const qs = new URLSearchParams(); + if (params?.project_id) qs.set("project_id", params.project_id); + if (params?.status) qs.set("status", params.status); + if (params?.type) qs.set("type", params.type); + const tail = qs.toString(); + const res = await send(`/api/admin/feedback${tail ? `?${tail}` : ""}`); + return jsonOrThrow(res, "Failed to load admin feedback"); +} + +export async function updateAdminFeedback( + ticketId: string, + update: { status?: string; admin_notes?: string }, +): Promise { + const res = await send(`/api/admin/feedback/${ticketId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(update), + }); + return jsonOrThrow(res, "Failed to update feedback"); +} diff --git a/periscope/src/frontend/src/lib/types.ts b/periscope/src/frontend/src/lib/types.ts new file mode 100644 index 0000000..e869505 --- /dev/null +++ b/periscope/src/frontend/src/lib/types.ts @@ -0,0 +1,533 @@ +/** Frontend mirrors of Periscope backend contracts (`periscopex.models` + HTTP). */ + +export type FindingStatus = "ERROR" | "WARNING" | "INFO"; +export type FindingClass = "RULE" | "RISK" | "REVIEW" | "INFO"; +export type DatasheetProvenance = "MANDATORY" | "RECOMMENDED" | "TYPICAL" | "EXAMPLE"; +export type EvidenceStatus = "SUFFICIENT" | "INSUFFICIENT"; +export type FindingReviewState = "open" | "false_positive" | "accepted" | "wontfix"; + +export interface Finding { + finding_id: string | null; + designator: string; + mpn: string; + aspect: string | null; + finding: string; + why: string; + source_page: number | null; + source_quote?: string; + source_designator?: string | null; + status: FindingStatus; + recommendation?: string; + reference: string; + source?: string | null; + net?: string | null; + pins?: string[]; + rule_id?: string | null; + cad_sheet?: string | null; + cad_uuid?: string | null; + variant?: string | null; + facts?: string; + requirement?: string; + inference?: string; + provenance?: DatasheetProvenance | null; + finding_class?: FindingClass | null; + confidence?: number | null; + evidence_status?: EvidenceStatus | null; + calculation?: string; + assumptions?: string[]; + action?: string; + decision_id?: string | null; + suppressed?: boolean; +} + +export interface FindingComment { + comment_id: string; + finding_id: string; + user_id: string; + user_name: string; + text: string; + mentions: string[]; + created_at: string; +} + +export interface FindingReview { + state: FindingReviewState; + reason: string; + user_id: string; + user_name: string; + updated_at: string; +} + +export interface ReportRelease { + sha256: string; + user_id: string; + timestamp: string; +} + +export interface ValidationReport { + project: string; + timestamp: string; + findings: Finding[]; + summary: Record; + coverage: Record; + review_errors?: Record; + not_reviewed?: { designator: string; reason: string }[]; + comments?: Record; + review_states?: Record; + release?: ReportRelease; +} + +export type NetType = "power" | "ground" | "signal" | "unknown"; +export type ComponentType = + | "resistor" + | "capacitor" + | "inductor" + | "ic" + | "connector" + | "crystal" + | "discrete" + | "transformer" + | "fuse" + | "switch" + | "test_point" + | "fiducial" + | "mechanical" + | "unknown"; + +export interface ComponentSpecs { + specs_type: string; + value_formatted?: string; + values?: Record; + [key: string]: unknown; +} + +export interface Component { + reference: string; + value: string; + footprint: string; + component_type: ComponentType; + component_subtype: string | null; + mpn: string | null; + pins: Record; + specs: ComponentSpecs | null; +} + +export interface PinConnection { + component_ref: string; + pin_number: string; + pin_name: string | null; +} + +export interface Net { + name: string; + net_type: NetType; + voltage: number | null; + pins: PinConnection[]; +} + +export interface DesignGraph { + components: Record; + nets: Record; + bom_fields?: Record; + schematic_fields?: Record; + cad_index?: Record; +} + +export interface BomSummaryRow { + mpn: string | null; + designators: string[]; + value: string; + category: string | null; + specs: Record | null; + description: string | null; + hasDatasheet: boolean; +} + +export type ProjectStatus = + | "draft" + | "queued" + | "running" + | "complete" + | "error" + | "cancelled" + | "paused_insufficient_credits" + | "paused_by_user"; + +export interface PauseCheckpoint { + paused_at?: string | null; + paused_stage?: string | null; + last_completed_label?: string | null; + completed_review_refs?: string[]; + pending_review_refs?: string[]; +} + +export interface SkippedComponent { + identifier: string; + stage: string; + error: string; +} + +export interface LcscPayload { + mpn?: string | null; + manufacturer?: string | null; + package?: string | null; + description?: string | null; + category?: string | null; + subcategory?: string | null; +} + +export interface ComponentMpnBuckets { + ic: string[]; + passive: string[]; + simple: string[]; +} + +export interface Project { + id: string; + name: string; + created: string; + status: ProjectStatus; + summary?: Record; + hasNetlist: boolean; + hasBom: boolean; + hasPcb?: boolean; + datasheetCount: number; + skippedComponents?: SkippedComponent[]; + completedReviewRefs?: string[]; + userId?: string; + collaborators?: string[]; + creditsSpent?: number; + totalCostUsd?: number | null; + pauseCheckpoint?: PauseCheckpoint | null; + pauseReason?: string | null; + bomColumns?: { reference: string; mpn: string } | null; + pipelineError?: string | null; + lcscToMpn?: Record | null; + lcscPayloads?: Record | null; + componentMpns?: ComponentMpnBuckets | null; + periscopeVersion?: string | null; + netlistFormat?: "pads" | "edif" | "kicad_xml" | "kicad_sexp" | "kicad_sch" | null; + netlistSubdesigns?: string[] | null; + placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled"; + placementState?: Record | null; + pcbStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled"; + pcbState?: Record | null; +} + +export type RoleHint = + | "decoupling" + | "bulk" + | "load_cap" + | "filter" + | "pullup" + | "series" + | "divider" + | "bridge" + | "crystal" + | "other"; + +export interface PlacementSatellite { + ref: string; + component_type?: string; + component_subtype?: string | null; + nets?: string[]; + hop?: number; + role_hint?: RoleHint; +} + +export interface PlacementIcGroup { + ref: string; + mpn?: string | null; + component_subtype?: string | null; + rank?: number; + nets?: string[]; + satellites: PlacementSatellite[]; + layout_rules?: unknown[]; + assemble_order?: string[]; +} + +export interface PlacementDomain { + domain_id: string; + power_nets: string[]; + ic_refs: string[]; + assemble_order: string[]; +} + +export interface PlacementPlan { + objective?: string; + domains: PlacementDomain[]; + groups: PlacementIcGroup[]; +} + +export interface PlacementProposal { + ref: string; + anchor_ref: string; + rule_kind: string; + max_distance_mm: number; + proposed_x: number; + proposed_y: number; + layer?: string; + basis?: string; +} + +export interface PlacementPack { + objective?: string; + status: "packed" | "skipped"; + skip_reason?: string | null; + placements: PlacementProposal[]; +} + +export interface EdifSubDesign { + id: string | null; + instance_count: number; + designators: string[]; +} + +export interface Collaborator { + user_id: string; + name: string | null; + email: string | null; + image_url: string | null; + role: "owner" | "collaborator"; +} + +export interface ApiLogEntry { + timestamp: string; + stage: string; + identifier: string; + model: string; + input_tokens: number; + output_tokens: number; + duration_ms: number; + stop_reason: string; + skill_id?: string | null; + turns?: number | null; + error?: string | null; + cost_usd?: number | null; +} + +export interface DeratingRow { + designator: string; + mpn: string | null; + value_formatted: string | null; + rated_voltage_v: number | null; + operating_voltage_v: number | null; + operating_voltage_source: string | null; + net_plus: string | null; + net_minus: string | null; + dielectric_category: "ceramic" | "tantalum" | "electrolytic" | null; + dielectric?: string | null; + c_nominal_f?: number | null; + dc_bias_factor?: number | null; + c_eff_f?: number | null; + c_eff_formatted?: string | null; + dc_bias_model?: "stima" | null; + stress?: "PASS" | "MARGIN" | "RISK" | "UNKNOWN"; +} + +export interface DeratingSettings { + ceramic: number; + tantalum: number; + electrolytic: number; +} + +export type ImpedanceKind = "microstrip" | "stripline" | "cpw" | "diff"; + +export interface ImpedanceTraceResult { + kind: ImpedanceKind; + z0?: number | null; + zodd?: number | null; + zeven?: number | null; + zdiff?: number | null; + w_mm?: number | null; + s_mm?: number | null; +} + +export interface ImpedanceTarget { + kind: string; + w_mm: number | null; + s_mm: number | null; + z0: number | null; + zdiff: number | null; + formula: string; +} + +export interface ImpedanceStackupResult { + targets: Record; + kicad_dru: string; +} + +export interface ImpedanceNetRow { + net_name: string; + length_mm?: number; + branch_count?: number; + is_differential?: boolean; + partner_net_name?: string | null; + topologies?: string[]; + z0_min_ohms?: number | null; + z0_max_ohms?: number | null; + z0_avg_ohms?: number | null; + flags?: string[]; + sample_count?: number; + error?: string; +} + +export interface ImpedanceNetsReport { + pitch_mm: number; + nets: ImpedanceNetRow[]; + skipped: string | null; +} + +export interface AntennaVerifyRow { + ic_ref: string; + pin: string; + net: string; + topology: string; + parts: string[]; + target_z_ohm: number; + status: "ok" | "warning" | "info"; + detail: string; + feed_z0?: number | null; + feed_length_mm?: number | null; + marker_ref?: string | null; +} + +export interface AntennaGeometry { + template: "ifa" | "meander" | "stub"; + fit: "ok" | "scaled" | "overflow" | "need_f0"; + segments: { points: number[][]; width_mm: number }[]; + total_length_mm?: number | null; + length_ideal_mm?: number | null; + scale?: number; + svg?: string | null; + kicad_mod?: string | null; + footprint_name?: string | null; + note?: string; + detail?: string; +} + +export interface AntennaDesignRecipe { + status: "ready" | "need_pcb" | "need_stackup" | "need_marker"; + feed_point?: Record | null; + feed_line?: { + kind: string; + target_z_ohm: number; + w_mm?: number | null; + h_mm?: number | null; + er?: number | null; + t_mm?: number | null; + } | null; + radiator?: { + length_mm_suggest?: number | null; + f0_mhz?: number | null; + note?: string; + } | null; + geometry?: AntennaGeometry | null; + zone?: { + net: string; + layer: string; + bbox_mm?: number[] | null; + area_mm2?: number | null; + } | null; + keepout_checklist: string[]; + detail: string; +} + +export interface AntennaReport { + verify: AntennaVerifyRow[]; + design: AntennaDesignRecipe | null; + marker_help: string; +} + +export interface NetlistPreviewDesignator { + ref: string; + pins: { number: string; net_name: string }[]; +} + +export interface PipelineSubstep { + key: string; + label: string; + status: "pending" | "running" | "complete"; + cached?: boolean; +} + +export interface PipelineStep { + title: string; + description: string; + substeps: PipelineSubstep[]; + status: "pending" | "running" | "complete"; + totalNew?: number; +} + +export interface CreditSnapshot { + user_id: string; + balance: number; + plan: string; + last_entry_ts?: string | null; + next_expiry?: string | null; +} + +export interface CreditGrant { + grant_id: string; + user_id: string; + amount_usd: number; + remaining: number; + granted_at: string; + expires_at: string | null; + source: + | "top_up" + | "trial_grant" + | "admin_adjust" + | "refund_system_error" + | "pre_migration"; + stripe_event_id?: string | null; + expired?: boolean; + note?: string | null; +} + +export interface AutoTopupConfig { + enabled: boolean; + threshold_credits: number; + amount_usd: number; + has_payment_method: boolean; + last_attempt_ts?: string | null; + last_attempt_status?: "ok" | "failed" | "pending" | ""; + last_failure_reason?: string | null; +} + +export interface CreditLedgerEntry { + user_id: string; + timestamp: string; + delta: number; + balance_after: number; + reason: string; + run_id?: string | null; + unit_id?: string | null; + stripe_event_id?: string | null; + note?: string | null; +} + +export interface CostItem { + identifier: string; + kind: string; + api_cost_usd: number; + source: "cache_hit" | "api_call" | "api_call_estimated"; + note?: string | null; +} + +export interface CostEstimate { + api_cost_low: number; + api_cost_high: number; + api_cost_mid: number; + credits_low: number; + credits_high: number; + credits_mid: number; + breakdown: CostItem[]; + ic_count: number; + simple_count: number; + passive_count: number; + cached_ic_count: number; + cached_simple_count: number; + cached_passive_count: number; + review_ic_count: number; +} diff --git a/periscope/src/frontend/src/lib/utils.ts b/periscope/src/frontend/src/lib/utils.ts new file mode 100644 index 0000000..8748faa --- /dev/null +++ b/periscope/src/frontend/src/lib/utils.ts @@ -0,0 +1,44 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +import type { Finding, FindingClass, FindingStatus } from "./types"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function groupBy(items: T[], key: (item: T) => string): Record { + const out: Record = {}; + for (const item of items) { + const k = key(item); + (out[k] ??= []).push(item); + } + return out; +} + +const BY_STATUS: Record = { ERROR: 0, WARNING: 1, INFO: 2 }; +const BY_CLASS: Record = { RULE: 0, RISK: 1, REVIEW: 2, INFO: 3 }; + +export function sortFindings(findings: Finding[]): Finding[] { + return [...findings].sort((a, b) => { + const status = BY_STATUS[a.status] - BY_STATUS[b.status]; + if (status) return status; + const ac = BY_CLASS[a.finding_class ?? "INFO"] ?? 9; + const bc = BY_CLASS[b.finding_class ?? "INFO"] ?? 9; + if (ac !== bc) return ac - bc; + return ( + (a.designator || "").localeCompare(b.designator || "") || + (a.rule_id || "").localeCompare(b.rule_id || "") || + (a.finding || "").localeCompare(b.finding || "") + ); + }); +} + +export function getFindingKey(finding: Finding, index: number): string { + return finding.finding_id ?? `${finding.designator}-idx-${index}`; +} + +export function subtypeLabel(subtype: string | null): string { + if (!subtype) return ""; + const last = subtype.split(".").pop() ?? subtype; + return last.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/tests/test_periscope_frontend_lib_rewrite.py b/tests/test_periscope_frontend_lib_rewrite.py new file mode 100644 index 0000000..8e2e4c9 --- /dev/null +++ b/tests/test_periscope_frontend_lib_rewrite.py @@ -0,0 +1,39 @@ +"""Native frontend types and API client live under periscope/src.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "periscope" / "src" / "frontend" / "src" / "lib" +DEP = ROOT / "periscope" / "dependency" / "frontend" / "src" / "lib" + + +def _exports(path: Path) -> set[str]: + text = path.read_text(encoding="utf-8") + names = set(re.findall(r"^export (?:async )?function (\w+)", text, re.M)) + names |= set(re.findall(r"^export (?:interface|type|class|const) (\w+)", text, re.M)) + return names + + +def test_frontend_lib_is_src(): + for name in ("api.ts", "types.ts", "utils.ts"): + path = SRC / name + assert path.is_file(), name + head = path.read_text(encoding="utf-8")[:400] + assert "Native Periscope overlay" not in head + + +def test_api_keeps_page_exports(): + src = _exports(SRC / "api.ts") + dep = _exports(DEP / "api.ts") + missing = dep - src + assert not missing, sorted(missing) + + +def test_types_keep_page_exports(): + src = _exports(SRC / "types.ts") + dep = _exports(DEP / "types.ts") + missing = dep - src + assert not missing, sorted(missing)