seveibar/nema17-rp2040-stepper-controller

A tscircuit package created by seveibar.

Version
0.3.1
License
unset
Stars
0

tests/unified-usb.test.ts

import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const json: any[] = JSON.parse(readFileSync(process.env.CIRCUIT_JSON_PATH ?? "dist/index/circuit.json", "utf8"));
const components = json.filter(e=>e.type === "source_component");
const ports = json.filter(e=>e.type === "source_port");
function graph() {
  const parent = new Map<string,string>();
  const root = (id:string):string => {if(!parent.has(id))parent.set(id,id);if(parent.get(id)!==id)parent.set(id,root(parent.get(id)!));return parent.get(id)!;};
  const join=(ids:string[])=>{for(const id of ids.slice(1))parent.set(root(id),root(ids[0]));};
  for(const e of json) {
    if(e.type==="source_trace")join([...(e.connected_source_port_ids??[]),...(e.connected_source_net_ids??[])]);
    if(e.type==="source_component_internal_connection")join(e.source_port_ids);
  }
  return {root,join};
}
const {root,join} = graph();
function port(name:string,pin:string):string {
  const c=components.find(c=>c.name===name);if(!c)throw Error(`Missing component ${name}`);
  const p=ports.find(p=>p.source_component_id===c.source_component_id&&(p.name===pin||p.port_hints?.includes(pin)));
  if(!p)throw Error(`Missing port ${name}.${pin}`);return p.source_port_id;
}
const net=(name:string,pin:string)=>root(port(name,pin));
const same=(a:string,ap:string,b:string,bp:string)=>expect(net(a,ap)).toBe(net(b,bp));
const different=(a:string,ap:string,b:string,bp:string)=>expect(net(a,ap)).not.toBe(net(b,bp));

test("compact version shares ground and removes the isolation modules",()=>{
 for(const name of ["U_USB_ISO","U_ISO_POWER","U_HOST_DETECT"])expect(components.some(c=>c.name===name)).toBe(false);
 same("J_LAPTOP","A1B12","J_MOTOR_USB","A1B12");
 same("J_LAPTOP","A1B12","U_LOGIC_BUCK","GND");
});

test("PD-only CH224 data pins are shorted locally, disconnected from both USB buses",()=>{
  same("U_PD","DP","U_PD","DM");
  for(const connector of ["J_LAPTOP","J_MOTOR_USB"])for(const p of ["A6","A7","B6","B7"])different("U_PD","DP",connector,p);
  same("J_MOTOR_USB","A5","U_PD","CC1");same("J_MOTOR_USB","B5","U_PD","CC2");
});

test("both reversible connector orientations reach the intended USB receivers",()=>{
  for(const [positive,negative] of [["A6","A7"],["B6","B7"]]) {
    same("J_LAPTOP",positive,"U_USB_MUX","S0A");same("J_LAPTOP",negative,"U_USB_MUX","S0B");
    same("J_MOTOR_USB",positive,"U_PWR_USB_GUARD","XA");same("J_MOTOR_USB",negative,"U_PWR_USB_GUARD","XB");
  }
  same("U_USB_MUX","XA","R_USB2","pin1");same("U_USB_MUX","XB","R_USB1","pin1");
  same("R_USB2","pin2","U1","USB_DP");same("R_USB1","pin2","U1","USB_DM");
});

test("steady-state switch selections keep the two host buses separate",()=>{
  for(const selected of [0,1]) {
    const g=graph();
    for(const polarity of ["A","B"])g.join([port("U_USB_MUX",`X${polarity}`),port("U_USB_MUX",`S${selected}${polarity}`)]);
    for(const polarity of ["A","B"]) {
      expect(g.root(port("U_USB_MUX",`X${polarity}`))).toBe(g.root(port("U_USB_MUX",`S${selected}${polarity}`)));
      expect(g.root(port("U_USB_MUX",`S0${polarity}`))).not.toBe(g.root(port("U_USB_MUX",`S1${polarity}`)));
    }
  }
  same("U_USB_MUX","SEL","Q_HOST_DETECT","OUT");same("Q_HOST_DETECT","GND","U_USB_MUX","GND");same("Q_HOST_DETECT","IN","J_LAPTOP","A4B9");
  same("R_USB_SELECT","pin1","U_USB_MUX","VCC");same("R_USB_SELECT","pin2","U_USB_MUX","SEL");
});

test("laptop supplies logic and neither input can backfeed the other",()=>{
 same("J_LAPTOP","A4B9","D_LAPTOP_OR","anode");
 same("D_LAPTOP_OR","cathode","U_LOGIC_BUCK","VIN");same("D_PWR_OR","cathode","U_LOGIC_BUCK","VIN");
 same("D_PWR_OR","anode","D_MOTOR_BLOCK","cathode");
  const g=graph();g.join([port("F_MOTOR_VBUS","pin1"),port("F_MOTOR_VBUS","pin2")]);const adj=new Map<string,string[]>();
  for(const name of ["D_LAPTOP_OR","D_PWR_OR","D_MOTOR_BLOCK"]) {
    const a=g.root(port(name,"anode")), k=g.root(port(name,"cathode"));adj.set(a,[...(adj.get(a)??[]),k]);
  }
  const visited=new Set<string>();const visit=(p:string)=>{if(visited.has(p))return;visited.add(p);for(const n of adj.get(p)??[])visit(n);};
  visit(g.root(port("J_LAPTOP","A4B9")));
  expect(visited.has(g.root(port("U_LOGIC_BUCK","VIN")))).toBe(true);
  for(const [c,p] of [["J_MOTOR_USB","A4B9"],["DRIVER","VM"]])expect(visited.has(g.root(port(c,p)))).toBe(false);
  visited.clear();visit(g.root(port("J_MOTOR_USB","A4B9")));
  expect(visited.has(g.root(port("U_LOGIC_BUCK","VIN")))).toBe(true);
  expect(visited.has(g.root(port("J_LAPTOP","A4B9")))).toBe(false);
});

test("PD power cannot directly reach logic or low-voltage ESD rails",()=>{
 same("L_LOGIC_BUCK","pin2","L_AVDD","pin1");
 for(const [c,p] of [["U_PWR_ESD","pin5"],["U_LAPTOP_ESD","pin5"],["L_AVDD","pin1"]])different(c,p,"J_MOTOR_USB","A4B9");
 // Reverse blocking from a powered motor supply toward the laptop.
 different("D_PWR_OR","anode","J_LAPTOP","A4B9");
 different("D_LAPTOP_OR","anode","D_LAPTOP_OR","cathode");
});

test("PCB and component bodies fit the 42 mm NEMA17 face with 31 mm mount spacing",()=>{
 const board=json.find(e=>e.type==="pcb_board");expect(board.width).toBe(42);expect(board.height).toBe(42);
 for(const pc of json.filter(e=>e.type==="pcb_component")) {
  expect(Math.abs(pc.center.x)+pc.width/2).toBeLessThanOrEqual(21.00001);
  expect(Math.abs(pc.center.y)+pc.height/2).toBeLessThanOrEqual(21.00001);
 }
 for(const name of ["J_LAPTOP","J_MOTOR_USB"]) {
  const id=json.find(e=>e.type==="pcb_component"&&e.source_component_id===components.find(c=>c.name===name)?.source_component_id)?.pcb_component_id;
  const courtyard=json.find(e=>e.type==="pcb_courtyard_outline"&&e.pcb_component_id===id);expect(courtyard).toBeDefined();
  for(const p of courtyard.outline){expect(Math.abs(p.x)).toBeLessThanOrEqual(21);expect(Math.abs(p.y)).toBeLessThanOrEqual(21);}
 }
 const mounts=json.filter(e=>e.type==="pcb_component" && components.some(c=>c.source_component_id===e.source_component_id&&/^MH[1-4]$/.test(c.name)));
 expect(mounts.length).toBe(4);for(const m of mounts){expect(Math.abs(m.center.x)).toBe(15.5);expect(Math.abs(m.center.y)).toBe(15.5);}
});

 test("CH9442 protection faces PWR and low-voltage ESD stays behind it",()=>{
  same("U_PWR_USB_GUARD","S0A","U_USB_MUX","S1A");
  same("U_PWR_USB_GUARD","S0B","U_USB_MUX","S1B");
  same("U_PWR_ESD","pin1","U_PWR_USB_GUARD","S0A");
  same("U_PWR_ESD","pin3","U_PWR_USB_GUARD","S0B");
  different("J_MOTOR_USB","A6","U_PWR_ESD","pin1");
  different("J_MOTOR_USB","A7","U_PWR_ESD","pin3");
  for(const p of ["SEL","N_EN"])same("U_PWR_USB_GUARD",p,"U_PWR_USB_GUARD","GND");
  for(const name of ["U_USB_MUX","U_PWR_USB_GUARD"]) {
    same(name,"VCC","L_AVDD","pin1");same(name,"N_EN",name,"GND");
    expect(components.find(c=>c.name===name)?.manufacturer_part_number).toBe("CH9442Q");
  }
});

test("revision netlist has no source creation or connectivity errors",()=>{
  expect(json.filter(e=>e.type.startsWith("source_")&&e.type.endsWith("_error"))).toEqual([]);
});


test("single-sided assembly excludes the encoder and populates only top-side parts",()=>{
 const pcs=json.filter(e=>e.type==="pcb_component");
 const componentName=(pc:any)=>components.find(c=>c.source_component_id===pc.source_component_id)?.name;
 for(const name of ["U_ENCODER","C_ENCODER","C_ENCODER_BULK","R_ENC_SDA","R_ENC_SCL"])
   expect(pcs.find(pc=>componentName(pc)===name)?.do_not_place).toBe(true);
 for(const pc of pcs.filter(pc=>componentName(pc)&&!pc.do_not_place))expect(pc.layer,componentName(pc)).toBe("top");
});