tscircuit/ti
ti tscircuit library
- Version
- 1.0.198
- License
- unset
- Stars
- 0
system-block-ui/src/rendering/ecad-project-export.test.ts
import { describe, expect, test } from "bun:test";
import type { CircuitJson } from "circuit-json";
import { strFromU8, unzipSync } from "fflate";
import {
createAltiumProjectZipBlob,
getAltiumProjectZipFileName,
} from "./export-altium-project";
import {
createKicadProjectZipBlob,
getKicadProjectZipFileName,
} from "./export-kicad-project";
import { evaluateGeneratedTsx } from "./evaluate-schematic";
import { prepareCircuitJsonForEcadExport } from "./prepare-circuit-json-for-ecad-export";
const circuitJson = [
{
type: "pcb_board",
pcb_board_id: "pcb_board_0",
center: { x: 0, y: 0 },
width: 20,
height: 12,
},
{
type: "source_component",
source_component_id: "source_component_0",
name: "R1",
ftype: "simple_resistor",
},
{
type: "source_port",
source_port_id: "source_port_0",
source_component_id: "source_component_0",
pin_number: 1,
name: "pin1",
},
{
type: "source_trace",
source_trace_id: "source_trace_0",
connected_source_port_ids: ["source_port_0"],
name: "SIGNAL",
},
{
type: "pcb_component",
pcb_component_id: "pcb_component_0",
source_component_id: "source_component_0",
center: { x: 0, y: 0 },
width: 2,
height: 1,
layer: "top",
rotation: 0,
},
{
type: "pcb_port",
pcb_port_id: "pcb_port_0",
source_port_id: "source_port_0",
pcb_component_id: "pcb_component_0",
x: -0.5,
y: 0,
layers: ["top"],
},
{
type: "pcb_smtpad",
pcb_smtpad_id: "pcb_smtpad_0",
pcb_component_id: "pcb_component_0",
pcb_port_id: "pcb_port_0",
shape: "rect",
x: -0.5,
y: 0,
width: 0.8,
height: 0.8,
layer: "top",
},
{
type: "schematic_component",
schematic_component_id: "schematic_component_0",
source_component_id: "source_component_0",
center: { x: 0, y: 0 },
size: { width: 2, height: 1 },
symbol_name: "boxresistor",
symbol_display_value: "10kΩ",
},
{
type: "schematic_port",
schematic_port_id: "schematic_port_0",
schematic_component_id: "schematic_component_0",
source_port_id: "source_port_0",
center: { x: -1.5, y: 0 },
facing_direction: "left",
},
{
type: "schematic_trace",
schematic_trace_id: "schematic_trace_0",
source_trace_id: "source_trace_0",
edges: [{ from: { x: -2.5, y: 0 }, to: { x: -1.5, y: 0 } }],
junctions: [],
},
] as unknown as CircuitJson;
const multiSheetCircuitJson = [
{
type: "schematic_sheet",
schematic_sheet_id: "sheet_system_diagram",
name: "system_diagram",
sheet_index: 0,
},
{
type: "schematic_graphic",
schematic_graphic_id: "graphic_system_diagram",
schematic_sheet_id: "sheet_system_diagram",
svg_content: '<svg xmlns="http://www.w3.org/2000/svg" />',
},
{
type: "schematic_component_overlap_warning",
schematic_component_overlap_warning_id: "warning_system_diagram",
schematic_sheet_id: "sheet_system_diagram",
warning_type: "schematic_component_overlap_warning",
message: "Diagnostic-only metadata should not keep this sheet",
schematic_component_ids: ["missing_1", "missing_2"],
},
{
type: "schematic_sheet",
schematic_sheet_id: "sheet_detail",
name: "detail",
sheet_index: 1,
},
{
type: "schematic_sheet",
schematic_sheet_id: "sheet_empty_detail",
name: "empty_detail",
sheet_index: 2,
},
{
type: "schematic_sheet",
schematic_sheet_id: "sheet_mixed_detail",
name: "mixed_graphic_detail",
sheet_index: 3,
},
{
type: "schematic_graphic",
schematic_graphic_id: "graphic_mixed_detail",
schematic_sheet_id: "sheet_mixed_detail",
svg_content: '<svg xmlns="http://www.w3.org/2000/svg" />',
},
{
type: "schematic_text",
schematic_text_id: "text_mixed_detail",
schematic_sheet_id: "sheet_mixed_detail",
text: "Real schematic content",
position: { x: 0, y: 0 },
font_size: 1,
rotation: 0,
anchor: "center",
color: "#000000",
},
...circuitJson.map((element) =>
element.type.startsWith("schematic_")
? { ...element, schematic_sheet_id: "sheet_detail" }
: element,
),
] as unknown as CircuitJson;
const expectZipSignature = async (blob: Blob): Promise<Uint8Array> => {
expect(blob.type).toBe("application/zip");
const bytes = new Uint8Array(await blob.arrayBuffer());
expect([...bytes.slice(0, 4)]).toEqual([0x50, 0x4b, 0x03, 0x04]);
return bytes;
};
describe("ECAD project ZIP exports", () => {
test("creates a real KiCad project archive", async () => {
const blob = await createKicadProjectZipBlob(circuitJson, {
projectName: "demo board",
});
const archive = unzipSync(await expectZipSignature(blob));
expect(Object.keys(archive).sort()).toEqual([
"demo-board.kicad_pcb",
"demo-board.kicad_pro",
"demo-board.kicad_sch",
]);
expect(
strFromU8(archive["demo-board.kicad_pcb"] ?? new Uint8Array()),
).toStartWith("(kicad_pcb");
expect(
strFromU8(archive["demo-board.kicad_sch"] ?? new Uint8Array()),
).toStartWith("(kicad_sch");
expect(
JSON.parse(strFromU8(archive["demo-board.kicad_pro"] ?? new Uint8Array()))
.head.project_name,
).toBe("demo-board");
expect(getKicadProjectZipFileName({ projectName: "demo board" })).toBe(
"demo-board.kicad-project.zip",
);
});
test("preserves evaluated footprint pads in the KiCad PCB", async () => {
const evaluated = await evaluateGeneratedTsx(
`
import "tscircuit"
export default () => (
<board width="10mm" height="10mm" routingDisabled>
<schematicsheet name="fixture" displayName="Fixture" sheetIndex={0} />
<resistor
name="R1"
resistance="10k"
footprint="0402"
pcbX={0}
pcbY={0}
/>
</board>
)
`,
{
mainComponentPath: "footprint-fixture.tsx",
timeoutMs: 15_000,
},
);
expect(
evaluated.circuitJson.filter(
(element) => element.type === "pcb_component",
),
).toHaveLength(1);
expect(
evaluated.circuitJson.filter((element) => element.type === "pcb_smtpad"),
).toHaveLength(2);
const blob = await createKicadProjectZipBlob(evaluated.circuitJson, {
projectName: "footprint-fixture",
});
const archive = unzipSync(await expectZipSignature(blob));
const pcb = strFromU8(
archive["footprint-fixture.kicad_pcb"] ?? new Uint8Array(),
);
expect(pcb.match(/\(footprint\b/g)).toHaveLength(1);
expect(pcb.match(/\(pad\b/g)).toHaveLength(2);
expect(pcb).toContain('(property "Reference" "R1"');
});
test("creates a real native Altium project archive", async () => {
const blob = await createAltiumProjectZipBlob(circuitJson, {
projectName: "demo board",
});
const archive = unzipSync(await expectZipSignature(blob));
expect(Object.keys(archive).sort()).toEqual([
"README.txt",
"demo-board.PcbDoc",
"demo-board.PrjPcb",
"demo-board.SchDoc",
]);
const compoundFileMagic = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
expect([...(archive["demo-board.PcbDoc"]?.slice(0, 8) ?? [])]).toEqual(
compoundFileMagic,
);
expect([...(archive["demo-board.SchDoc"]?.slice(0, 8) ?? [])]).toEqual(
compoundFileMagic,
);
expect(
strFromU8(archive["demo-board.PrjPcb"] ?? new Uint8Array()),
).toContain("demo-board.PcbDoc");
const readme = strFromU8(archive["README.txt"] ?? new Uint8Array());
expect(readme).toContain("schematic-first project");
expect(readme).toContain("it is not a routed system-board layout");
expect(readme).not.toContain("routed Circuit JSON");
expect(getAltiumProjectZipFileName({ projectName: "demo board" })).toBe(
"demo-board.altium-project.zip",
);
});
test("omits only graphic-only System Diagram child sheets", async () => {
const prepared = prepareCircuitJsonForEcadExport(multiSheetCircuitJson);
expect(
prepared
.filter((element) => element.type === "schematic_sheet")
.map((sheet) => sheet.schematic_sheet_id),
).toEqual(["sheet_detail", "sheet_empty_detail", "sheet_mixed_detail"]);
expect(
prepared
.filter((element) => element.type === "schematic_graphic")
.map((graphic) => graphic.schematic_graphic_id),
).toEqual(["graphic_mixed_detail"]);
expect(
prepared.some(
(element) =>
"schematic_sheet_id" in element &&
element.schematic_sheet_id === "sheet_system_diagram",
),
).toBe(false);
expect(
prepared.some(
(element) =>
element.type === "source_component" &&
element.source_component_id === "source_component_0",
),
).toBe(true);
expect(
prepared.some(
(element) =>
element.type === "schematic_text" &&
element.schematic_sheet_id === "sheet_mixed_detail",
),
).toBe(true);
const [kicadBytes, altiumBytes] = await Promise.all([
createKicadProjectZipBlob(multiSheetCircuitJson, {
projectName: "multi-sheet",
}).then(expectZipSignature),
createAltiumProjectZipBlob(multiSheetCircuitJson, {
projectName: "multi-sheet",
}).then(expectZipSignature),
]);
const kicadEntries = Object.keys(unzipSync(kicadBytes)).sort();
const altiumEntries = Object.keys(unzipSync(altiumBytes)).sort();
expect(kicadEntries).toEqual([
"detail.kicad_sch",
"empty_detail.kicad_sch",
"mixed_graphic_detail.kicad_sch",
"multi-sheet.kicad_pcb",
"multi-sheet.kicad_pro",
"multi-sheet.kicad_sch",
]);
expect(kicadEntries).not.toContain("system_diagram.kicad_sch");
expect(altiumEntries).toEqual([
"README.txt",
"multi-sheet-01.SchDoc",
"multi-sheet-02.SchDoc",
"multi-sheet-03.SchDoc",
"multi-sheet.PcbDoc",
"multi-sheet.PrjPcb",
"multi-sheet.SchDoc",
]);
});
test("still exports legacy schematic-only Circuit JSON", async () => {
const schematicOnlyCircuitJson = circuitJson.filter(
(element) => !element.type.startsWith("pcb_"),
);
const [kicadBytes, altiumBytes] = await Promise.all([
createKicadProjectZipBlob(schematicOnlyCircuitJson).then(
expectZipSignature,
),
createAltiumProjectZipBlob(schematicOnlyCircuitJson).then(
expectZipSignature,
),
]);
expect(Object.keys(unzipSync(kicadBytes))).toContain(
"GeneratedSystem.kicad_sch",
);
expect(Object.keys(unzipSync(altiumBytes))).toContain(
"GeneratedSystem.SchDoc",
);
});
test("uses portable filenames for untrusted project names", () => {
expect(getKicadProjectZipFileName({ projectName: "../../CON" })).toBe(
"project-CON.kicad-project.zip",
);
expect(getAltiumProjectZipFileName({ projectName: " " })).toBe(
"GeneratedSystem.altium-project.zip",
);
});
});