Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
54e85dd7fThis file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect } from 'vitest';
import {
parseMarkdownTables,
isHeader,
parseBomTable,
parseCanonicalRows,
parsePinoutRows,
normalizeValue,
normalizeFootprint,
pinoutColumnReport,
} from '../src/memory/bom-table.js';
describe('parseMarkdownTables', () => {
it('parses a well-formed table, skipping the separator row', () => {
const md = [
'# Bill of Materials',
'',
'| Refdes | Value | Footprint | MPN | Rationale |',
'|---|---|---|---|---|',
'| R1 | 10k | Resistor_SMD:R_0603_1608Metric | UNVERIFIED | extracted from schematic |',
'| U1 | ATmega328 | Package_QFP:TQFP-32 | ATMEGA328-AU | extracted from schematic |',
].join('\n');
const rows = parseMarkdownTables(md);
// header + 2 data rows; separator is dropped
expect(rows).toHaveLength(3);
expect(rows[1]!.cells[0]).toBe('R1');
expect(rows[2]!.cells[0]).toBe('U1');
});
it('parses multiple tables in one document', () => {
const md = [
'| Refdes | Value |',
'|---|---|',
'| R1 | 10k |',
'',
'| Refdes | Pin | Name | Net | Notes |',
'|---|---|---|---|---|',
'| U1 | 5 | GPIO14 | KEY_DAH | |',
].join('\n');
const rows = parseMarkdownTables(md);
expect(rows).toHaveLength(4);
});
it('ignores prose lines that do not start with a pipe', () => {
const md = 'Every part: refdes, MPN, value, package.\n\n| Refdes | Value |\n|---|---|\n| R1 | 10k |\n';
const rows = parseMarkdownTables(md);
expect(rows).toHaveLength(2); // header + one data row
});
it('returns an empty array for a document with no tables', () => {
expect(parseMarkdownTables('# Just prose\n\nNo tables here.\n')).toEqual([]);
});
it('does not throw on a malformed / ragged table', () => {
const md = [
'| Refdes | Value | Footprint | MPN | Rationale |',
'|---|---|---|---|---|',
'| R1 | 10k |', // short row, missing trailing cells
'| | 47k | Resistor_SMD:R_0603_1608Metric | | |', // missing refdes
'||||', // degenerate row
].join('\n');
expect(() => parseMarkdownTables(md)).not.toThrow();
const rows = parseMarkdownTables(md);
expect(rows.length).toBeGreaterThan(0);
});
});
describe('parseCanonicalRows (I5: ignore auxiliary tables)', () => {
it('returns only the Refdes-headed parts table, ignoring a second summary table', () => {
const md = [
'# BOM',
'',
'| Refdes | Value | Footprint | MPN | Rationale |',
'|--------|-------|-----------|-----|-----------|',
'| R1 | 5.1k | R_0603 | UNVERIFIED | Rd |',
'| D1 | Red | LED_0603 | UNVERIFIED | indicator |',
'',
'## Quiescent-current roll-up',
'',
'| Item | Current |',
'|------|---------|',
'| LED branch | 0.9 mA |',
'| Total | 0.9 mA |',
].join('\n');
const rows = parseCanonicalRows(md);
// Only R1 and D1 — NOT "LED branch"/"Total" from the roll-up table.
expect(rows.map((r) => r.cells[0])).toEqual(['R1', 'D1']);
});
it('returns only the Pin-headed table for PINOUT with a net-legend table', () => {
const md = [
'| Refdes | Pin | Name | Net | Notes |',
'|--------|-----|------|-----|-------|',
'| J1 | A5 | CC1 | CC1 | |',
'',
'| Net | Meaning |',
'|-----|---------|',
'| VBUS | 5V input |',
].join('\n');
const rows = parseCanonicalRows(md);
expect(rows.map((r) => r.cells[0])).toEqual(['J1']);
});
it('ignores a document whose only table has no Refdes/Pin header', () => {
const md = '| Item | Current |\n|------|---------|\n| Total | 1 mA |\n';
expect(parseCanonicalRows(md)).toEqual([]);
});
it('concatenates two parts tables that both carry a Refdes header', () => {
const md = [
'| Refdes | Value |',
'|--------|-------|',
'| R1 | 5.1k |',
'',
'| Refdes | Value |',
'|--------|-------|',
'| C1 | 10uF |',
].join('\n');
expect(parseCanonicalRows(md).map((r) => r.cells[0])).toEqual(['R1', 'C1']);
});
});
describe('isHeader', () => {
it('recognizes a BOM header row (Refdes)', () => {
expect(isHeader({ cells: ['Refdes', 'Value', 'Footprint', 'MPN', 'Rationale'] })).toBe(true);
});
it('recognizes a PINOUT header row (Pin)', () => {
expect(isHeader({ cells: ['Refdes', 'Pin', 'Name', 'Net', 'Notes'] })).toBe(true);
});
it('is case-insensitive', () => {
expect(isHeader({ cells: ['REFDES', 'Value'] })).toBe(true);
});
it('returns false for a data row', () => {
expect(isHeader({ cells: ['R1', '10k', 'Resistor_SMD:R_0603_1608Metric', 'UNVERIFIED', ''] })).toBe(false);
});
});
describe('parseBomTable', () => {
const header = '| Refdes | Value | Footprint | MPN | Rationale |\n|---|---|---|---|---|';
it('parses a well-formed BOM into typed rows', () => {
const md = `${header}\n| R1 | 10k | Resistor_SMD:R_0603_1608Metric | UNVERIFIED | extracted from schematic |\n`;
const rows = parseBomTable(md);
expect(rows).toEqual([
{
refdes: 'R1',
value: '10k',
footprint: 'Resistor_SMD:R_0603_1608Metric',
mpn: 'UNVERIFIED',
flags: ['UNVERIFIED'],
},
]);
});
it('flags a row with a real MPN as unflagged', () => {
const md = `${header}\n| U1 | ATmega328 | Package_QFP:TQFP-32 | ATMEGA328-AU | verified against datasheet |\n`;
const rows = parseBomTable(md);
expect(rows[0]!.flags).toEqual([]);
expect(rows[0]!.mpn).toBe('ATMEGA328-AU');
});
it('flags a row with no MPN column value as MISSING_MPN', () => {
const md = `${header}\n| R2 | 4k7 | Resistor_SMD:R_0603_1608Metric | | |\n`;
const rows = parseBomTable(md);
expect(rows[0]!.flags).toEqual(['MISSING_MPN']);
expect(rows[0]!.mpn).toBeUndefined();
});
it('drops rows with no refdes rather than throwing', () => {
const md = `${header}\n| | 10k | Resistor_SMD:R_0603_1608Metric | UNVERIFIED | |\n| R1 | 10k | Resistor_SMD:R_0603_1608Metric | UNVERIFIED | |\n`;
const rows = parseBomTable(md);
expect(rows).toHaveLength(1);
expect(rows[0]!.refdes).toBe('R1');
});
it('handles a short/ragged row without throwing', () => {
const md = `${header}\n| R3 | 1k |\n`;
expect(() => parseBomTable(md)).not.toThrow();
const rows = parseBomTable(md);
expect(rows[0]).toEqual({
refdes: 'R3',
value: '1k',
footprint: undefined,
mpn: undefined,
flags: ['MISSING_MPN'],
});
});
it('returns an empty array for a BOM with header only, no data rows', () => {
expect(parseBomTable(header)).toEqual([]);
});
});
describe('normalizeValue (#I11 — semantic value equality, not byte-exact)', () => {
const eq = (a: string, b: string): boolean => normalizeValue(a) === normalizeValue(b);
it('folds ≥/>= and ≤/<= so they are not drift', () => {
expect(eq('Ihold≥3A, Vmax≥6V', 'Ihold>=3A, Vmax>=6V')).toBe(true);
expect(eq('Iout≤1A', 'Iout<=1A')).toBe(true);
});
it('treats the inch mark " as equivalent to in', () => {
expect(eq('2x2 0.1" header', '2x2 0.1in header')).toBe(true);
});
it('folds the ohm sign and the micro sign', () => {
expect(eq('5.1kΩ', '5.1kohm')).toBe(true);
expect(eq('10µF', '10uF')).toBe(true);
expect(eq('10μF', '10uF')).toBe(true); // greek mu vs micro sign
});
it('is case- and space-insensitive', () => {
expect(eq('10 K', '10k')).toBe(true);
expect(eq('4K7', '4k7')).toBe(true);
});
it('still flags a genuinely different value', () => {
expect(eq('10k', '4k7')).toBe(false);
expect(eq('Ihold≥3A', 'Ihold≥5A')).toBe(false);
});
});
describe('normalizeFootprint (F6 — encoding/space folded, case preserved)', () => {
const eq = (a: string, b: string): boolean => normalizeFootprint(a) === normalizeFootprint(b);
it('folds spacing but is case-sensitive (a footprint library id is case-significant)', () => {
expect(eq('Resistor_SMD:R_0603_1608Metric', 'Resistor_SMD:R_0603_1608Metric ')).toBe(true);
// Unlike normalizeValue, case differences are NOT folded away.
expect(eq('Resistor_SMD:R_0603', 'resistor_smd:r_0603')).toBe(false);
expect(normalizeValue('Resistor_SMD:R_0603')).toBe(normalizeValue('resistor_smd:r_0603'));
});
});
describe('parsePinoutRows (#I12 — header-name column resolution)', () => {
it('resolves the Net column on a 3-column Refdes|Pin|Net table', () => {
const md = `| Refdes | Pin | Net |\n|---|---|---|\n| J1 | A4 | \`VBUS\` |\n| J1 | A1 | \`GND\` |\n`;
// The old fixed-index reader took net from column 3 (absent here) and
// reported every pin as "NC"; by header name the net is column 2.
expect(parsePinoutRows(md)).toEqual([
{ ref: 'J1', pin: 'A4', net: 'VBUS' },
{ ref: 'J1', pin: 'A1', net: 'GND' },
]);
});
it('resolves the Net column on the 5-column scaffold table too', () => {
const md = `| Refdes | Pin | Name | Net | Notes |\n|---|---|---|---|---|\n| F1 | 1 | IN | VBUS | fuse in |\n`;
expect(parsePinoutRows(md)).toEqual([{ ref: 'F1', pin: '1', net: 'VBUS' }]);
});
it('strips markdown backticks so `VBUS` matches the schematic net VBUS', () => {
const md = `| Refdes | Pin | Net |\n|---|---|---|\n| C1 | 2 | \`GND\` |\n`;
expect(parsePinoutRows(md)[0]!.net).toBe('GND');
});
it('ignores a supporting Net|Role legend table (no Pin column)', () => {
const md =
`| Net | Role |\n|---|---|\n| VBUS | raw 5V |\n\n` +
`| Refdes | Pin | Net |\n|---|---|---|\n| J1 | A4 | VBUS |\n`;
expect(parsePinoutRows(md)).toEqual([{ ref: 'J1', pin: 'A4', net: 'VBUS' }]);
});
});
describe('pinoutColumnReport (#I12 — explicit missing-column signal)', () => {
it('reports a full Refdes|Pin|Net table as complete', () => {
const r = pinoutColumnReport('| Refdes | Pin | Net |\n|---|---|---|\n| J1 | A4 | VBUS |\n');
expect(r).toEqual({ hasTable: true, pin: true, net: true, refdes: true });
});
it('flags a pin table that omits the Net column (the false-NC case)', () => {
// A Refdes|Pin table with no Net column: drift should say so once, not report
// every pin as a false NC mismatch.
const r = pinoutColumnReport('| Refdes | Pin |\n|---|---|\n| J1 | A4 |\n');
expect(r.hasTable).toBe(true);
expect(r.pin).toBe(true);
expect(r.net).toBe(false);
});
it('reports no canonical table for a doc with only a legend table', () => {
const r = pinoutColumnReport('| Net | Role |\n|---|---|\n| VBUS | raw 5V |\n');
expect(r.hasTable).toBe(false);
expect(r.net).toBe(false);
});
});
describe('optional outer pipes (GitHub-flavored markdown)', () => {
const pinout = [
'# Pinout',
'',
'Refdes | Pin | Net',
'--- | --- | ---',
'U1 | 1 | 3V3',
'U1 | 5 | KEY_DAH',
].join('\n');
const bom = [
'# Bill of Materials',
'',
'Refdes | Value | Footprint | MPN | Rationale',
'--- | --- | --- | --- | ---',
'R1 | 10k | Resistor_SMD:R_0603_1608Metric | UNVERIFIED | from schematic',
].join('\n');
it('reads a pin table written without the outer pipes', () => {
const rows = parsePinoutRows(pinout);
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({ ref: 'U1', pin: '1', net: '3V3' });
expect(rows[1]).toEqual({ ref: 'U1', pin: '5', net: 'KEY_DAH' });
});
it('reports the columns of a pin table written without the outer pipes', () => {
expect(pinoutColumnReport(pinout)).toEqual({ hasTable: true, pin: true, net: true, refdes: true });
});
it('reads a BOM table written without the outer pipes', () => {
const rows = parseBomTable(bom);
expect(rows).toHaveLength(1);
expect(rows[0]!.refdes).toBe('R1');
expect(rows[0]!.value).toBe('10k');
expect(rows[0]!.footprint).toBe('Resistor_SMD:R_0603_1608Metric');
});
it('still reads the piped form, and both forms agree', () => {
const piped = [
'| Refdes | Pin | Net |',
'| --- | --- | --- |',
'| U1 | 1 | 3V3 |',
'| U1 | 5 | KEY_DAH |',
].join('\n');
expect(parsePinoutRows(piped)).toEqual(parsePinoutRows(pinout));
});
it('a prose line carrying a pipe below a table is not read as a pin', () => {
const md = [
'| Refdes | Pin | Net |',
'|---|---|---|',
'| U1 | 1 | 3V3 |',
'Legend: A | B',
].join('\n');
expect(parsePinoutRows(md)).toEqual([{ ref: 'U1', pin: '1', net: '3V3' }]);
});
it('a prose line carrying a pipe below an un-piped table is not read as a part', () => {
const md = [bom, 'Legend: UNVERIFIED | needs a datasheet check'].join('\n');
expect(parseBomTable(md).map((r) => r.refdes)).toEqual(['R1']);
});
it('the export reader does not read a pipe-bearing prose line as a part either', () => {
const md = [bom, 'Legend: UNVERIFIED | needs a datasheet check'].join('\n');
const flat = parseMarkdownTables(md).filter((r) => !isHeader(r));
expect(flat.map((r) => r.cells[0])).toEqual(['R1']);
});
it('the export reader ignores a prose line whose cell count matches the header', () => {
// The quiet variant: a matching cell count puts a real value in the MPN
// column, so the phantom row reaches the ordering CSV without a warning.
const md = [bom, '', 'Second-source options: Yageo | Vishay | KOA | Panasonic'].join('\n');
const flat = parseMarkdownTables(md).filter((r) => !isHeader(r));
expect(flat.map((r) => r.cells[0])).toEqual(['R1']);
});
it('a prose line carrying a pipe does not hide the table under it', () => {
const md = ['Nets are named `A | B` in the legend.', '| Refdes | Pin | Net |', '|---|---|---|', '| U1 | 1 | 3V3 |'].join('\n');
expect(parsePinoutRows(md)).toEqual([{ ref: 'U1', pin: '1', net: '3V3' }]);
});
it('ignores a pipe-free document', () => {
expect(parseCanonicalRows('# Just prose\n\nNo tables here.\n')).toEqual([]);
});
it('does not read an example table inside a fenced code block', () => {
const md = [
'| Refdes | Pin | Net |',
'|---|---|---|',
'| U1 | 1 | 3V3 |',
'',
'```markdown',
'Refdes | Pin | Net',
'--- | --- | ---',
'U9 | 42 | EXAMPLE_NET',
'```',
].join('\n');
expect(parsePinoutRows(md)).toEqual([{ ref: 'U1', pin: '1', net: '3V3' }]);
});
it('the check reader and the export reader agree on an un-piped BOM', () => {
const checked = parseBomTable(bom);
const flat = parseMarkdownTables(bom).filter((r) => !isHeader(r));
expect(checked.map((r) => r.refdes)).toEqual(flat.map((r) => r.cells[0]));
expect(checked.map((r) => r.refdes)).toEqual(['R1']);
});
it('a shorter or different fence inside a block does not end it early', () => {
const b3 = '`'.repeat(3);
const b4 = '`'.repeat(4);
const md = [
'| Refdes | Pin | Net |',
'|---|---|---|',
'| U1 | 1 | 3V3 |',
'',
b4 + 'markdown',
'Example, do not parse:',
b3,
'Refdes | Pin | Net',
'--- | --- | ---',
'U9 | 42 | LEAKED',
'',
b4,
].join('\n');
expect(parsePinoutRows(md).map((r) => r.ref)).toEqual(['U1']);
});
it('an over-indented delimiter inside a block is content, not a closer', () => {
const b3 = '`'.repeat(3);
const md = [
'| Refdes | Pin | Net |',
'|---|---|---|',
'| U1 | 1 | 3V3 |',
'',
b3 + 'markdown',
' ' + b3,
'Refdes | Pin | Net',
'--- | --- | ---',
'U9 | 42 | LEAKED',
'',
b3,
].join('\n');
expect(parsePinoutRows(md).map((r) => r.ref)).toEqual(['U1']);
// The export reader has no header filter, so it must be checked separately.
expect(parseMarkdownTables(md).filter((r) => !isHeader(r)).map((r) => r.cells[0])).toEqual(['U1']);
});
it('still treats a legally indented fence as a fence', () => {
const b3 = '`'.repeat(3);
const md = [
'| Refdes | Pin | Net |',
'|---|---|---|',
'| U1 | 1 | 3V3 |',
'',
' ' + b3 + 'markdown',
'Refdes | Pin | Net',
'--- | --- | ---',
'U5 | 5 | EXAMPLE',
'',
' ' + b3,
].join('\n');
expect(parsePinoutRows(md).map((r) => r.ref)).toEqual(['U1']);
});
it('handles CRLF documents', () => {
const b3 = '`'.repeat(3);
const md = [
'| Refdes | Pin | Net |',
'|---|---|---|',
'| U1 | 1 | 3V3 |',
'',
b3 + 'markdown',
'Refdes | Pin | Net',
'--- | --- | ---',
'U9 | 42 | LEAKED',
'',
b3,
].join('\r\n');
expect(parsePinoutRows(md).map((r) => r.ref)).toEqual(['U1']);
});
it('a tilde fence does not close a backtick fence', () => {
const b3 = '`'.repeat(3);
const md = [
'| Refdes | Pin | Net |',
'|---|---|---|',
'| U1 | 1 | 3V3 |',
'',
b3 + 'markdown',
'~~~',
'Refdes | Pin | Net',
'--- | --- | ---',
'U8 | 99 | LEAKED',
'',
b3,
].join('\n');
expect(parsePinoutRows(md).map((r) => r.ref)).toEqual(['U1']);
});
});