diff --git a/cli/search/format-jlcpcb-search-result.ts b/cli/search/format-jlcpcb-search-result.ts new file mode 100644 index 000000000..586ca3a22 --- /dev/null +++ b/cli/search/format-jlcpcb-search-result.ts @@ -0,0 +1,53 @@ +export type JlcpcbSearchResult = { + lcsc: number | string + mfr?: string | null + package?: string | null + description?: string | null + stock?: number | null + price?: number | null +} + +const normalizeDisplayText = (value?: string | null) => value?.trim() ?? "" + +const hasSameDisplayText = (left?: string | null, right?: string | null) => + normalizeDisplayText(left).toLocaleLowerCase() === + normalizeDisplayText(right).toLocaleLowerCase() + +const getJlcpcbDisplayDetails = (comp: JlcpcbSearchResult) => { + const manufacturer = normalizeDisplayText(comp.mfr) + const description = normalizeDisplayText(comp.description) + + if ( + manufacturer && + description && + hasSameDisplayText(manufacturer, description) + ) { + return [manufacturer] + } + + return [manufacturer, description].filter(Boolean) +} + +const normalizeJlcpcbPartNumber = (lcsc: number | string) => { + const rawPartNumber = String(lcsc).trim() + + return rawPartNumber.replace(/^jlcpcb:/i, "").replace(/^c/i, "") +} + +export const getJlcpcbSearchResultIdentifier = (lcsc: number | string) => + `jlcpcb:C${normalizeJlcpcbPartNumber(lcsc)}` + +export const formatJlcpcbSearchResult = ( + comp: JlcpcbSearchResult, + idx: number, +) => { + const detailParts = getJlcpcbDisplayDetails(comp) + const stockSuffix = + typeof comp.stock === "number" + ? ` (stock: ${comp.stock.toLocaleString("en-US")})` + : "" + + return `${idx + 1}. ${getJlcpcbSearchResultIdentifier(comp.lcsc)}${ + detailParts.length ? ` - ${detailParts.join(" - ")}` : "" + }${stockSuffix}` +} diff --git a/cli/search/register.ts b/cli/search/register.ts index 38433a4d1..badf71def 100644 --- a/cli/search/register.ts +++ b/cli/search/register.ts @@ -1,8 +1,12 @@ +import { getQueryFromParts } from "cli/utils/get-query-from-parts" import type { Command } from "commander" -import { getRegistryApiKy } from "lib/registry-api/get-ky" import Fuse from "fuse.js" import kleur from "kleur" -import { getQueryFromParts } from "cli/utils/get-query-from-parts" +import { getRegistryApiKy } from "lib/registry-api/get-ky" +import { + type JlcpcbSearchResult, + formatJlcpcbSearchResult, +} from "./format-jlcpcb-search-result" export const registerSearch = (program: Command) => { program @@ -46,14 +50,7 @@ export const registerSearch = (program: Command) => { }> } = { packages: [] } - let jlcResults: Array<{ - lcsc: number - mfr: string - package: string - description: string - stock: number - price: number - }> = [] + let jlcResults: JlcpcbSearchResult[] = [] let kicadResults: string[] = [] @@ -68,9 +65,7 @@ export const registerSearch = (program: Command) => { } if (searchJlc) { - const jlcSearchUrl = - "https://jlcsearch.tscircuit.com/api/search?limit=10&q=" + - encodeURIComponent(query) + const jlcSearchUrl = `https://jlcsearch.tscircuit.com/api/search?limit=10&q=${encodeURIComponent(query)}` const jlcResponse = await fetch(jlcSearchUrl).then((r) => r.json()) jlcResults = jlcResponse?.components ?? [] } @@ -189,9 +184,7 @@ export const registerSearch = (program: Command) => { ) jlcResults.forEach((comp, idx) => { - console.log( - `${idx + 1}. ${comp.mfr} (C${comp.lcsc}) - ${comp.description} (stock: ${comp.stock.toLocaleString("en-US")})`, - ) + console.log(formatJlcpcbSearchResult(comp, idx)) }) } console.log("\n") diff --git a/tests/cli/search/search-jlcpcb-command.test.ts b/tests/cli/search/search-jlcpcb-command.test.ts new file mode 100644 index 000000000..650f1a4b3 --- /dev/null +++ b/tests/cli/search/search-jlcpcb-command.test.ts @@ -0,0 +1,54 @@ +import { afterEach, expect, test } from "bun:test" +import type { JlcpcbSearchResult } from "cli/search/format-jlcpcb-search-result" +import { registerSearch } from "cli/search/register" +import { Command } from "commander" + +const originalFetch = globalThis.fetch +const originalConsoleLog = console.log + +afterEach(() => { + globalThis.fetch = originalFetch + console.log = originalConsoleLog +}) + +test("search --jlcpcb prints prefixed JLCPCB identifiers", async () => { + const output: string[] = [] + + console.log = (...args: unknown[]) => { + output.push(args.join(" ")) + } + + const components: JlcpcbSearchResult[] = [ + { + lcsc: 2040, + mfr: "RP2040", + description: "RP2040", + stock: 123456, + package: "QFN-56", + price: 0.75, + }, + ] + + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + + expect(url).toContain("https://jlcsearch.tscircuit.com/api/search") + expect(url).toContain("q=RP2040") + + return new Response(JSON.stringify({ components }), { + headers: { + "Content-Type": "application/json", + }, + }) + }) as typeof fetch + + const program = new Command() + registerSearch(program) + + await program.parseAsync(["search", "--jlcpcb", "RP2040"], { + from: "user", + }) + + expect(output).toContain("Found 1 component(s) in JLC search:") + expect(output).toContain("1. jlcpcb:C2040 - RP2040 (stock: 123,456)") +}) diff --git a/tests/cli/search/search-jlcpcb-format.test.ts b/tests/cli/search/search-jlcpcb-format.test.ts new file mode 100644 index 000000000..5a607e6fe --- /dev/null +++ b/tests/cli/search/search-jlcpcb-format.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test" +import { + formatJlcpcbSearchResult, + getJlcpcbSearchResultIdentifier, +} from "cli/search/format-jlcpcb-search-result" + +test("formatJlcpcbSearchResult prefixes JLCPCB identifiers", () => { + const output = formatJlcpcbSearchResult( + { + lcsc: 2040, + mfr: "RP2040", + package: "QFN-56", + description: "RP2040", + stock: 123456, + price: 0.75, + }, + 0, + ) + + expect(output).toBe("1. jlcpcb:C2040 - RP2040 (stock: 123,456)") +}) + +test("getJlcpcbSearchResultIdentifier normalizes existing prefixes", () => { + expect(getJlcpcbSearchResultIdentifier("2040")).toBe("jlcpcb:C2040") + expect(getJlcpcbSearchResultIdentifier("C2040")).toBe("jlcpcb:C2040") + expect(getJlcpcbSearchResultIdentifier("jlcpcb:C2040")).toBe("jlcpcb:C2040") +}) + +test("formatJlcpcbSearchResult keeps distinct descriptions after the identifier", () => { + const output = formatJlcpcbSearchResult( + { + lcsc: 5555, + mfr: "SN74LVC1G00", + package: "SOT-23-5", + description: "Single 2-input NAND gate", + stock: 42, + price: 0.03, + }, + 1, + ) + + expect(output).toBe( + "2. jlcpcb:C5555 - SN74LVC1G00 - Single 2-input NAND gate (stock: 42)", + ) +}) + +test("formatJlcpcbSearchResult compares repeated labels case-insensitively", () => { + const output = formatJlcpcbSearchResult( + { + lcsc: "C2040", + mfr: " RP2040 ", + description: "rp2040", + stock: undefined, + }, + 0, + ) + + expect(output).toBe("1. jlcpcb:C2040 - RP2040") +})