"""Hand-uploaded BOM files: header detection, the column patterns, skipped rows and bad files.""" from __future__ import annotations import io from decimal import Decimal from typing import Any import pytest from packetstation.domain import handbom def xlsx(rows: list[list[Any]]) -> bytes: from openpyxl import Workbook wb = Workbook() ws = wb.active assert ws is not None for r in rows: ws.append(r) buf = io.BytesIO() wb.save(buf) return buf.getvalue() def csv_bytes(text: str, bom: bool = True) -> bytes: return (b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8") def test_csv_header_after_title_rows() -> None: data = csv_bytes( "Bill of material,,,\n" "Job L900-01,,,\n" "\n" "Item,Qty,Part No.,Description,Length,Material,Dwg Ref\n" "1,2,PIPE-6-SCH40,Pipe 6 in sch 40,12 ft,A106-B,L900-0101\n" "2,4,,Gusset 1/4 plate,,A36,\n" ) lines = handbom.parse(data, "bom.csv") assert [ln["item"] for ln in lines] == [1, 2] first = lines[0] assert first["qty"] == Decimal(2) and first["part_id"] == "PIPE-6-SCH40" assert first["description"] == "Pipe 6 in sch 40" and first["length"] == "12 ft" assert first["material"] == "A106-B" and first["dwg_ref"] == "L900-0101" assert lines[1]["part_id"] is None and lines[1]["description"] == "Gusset 1/4 plate" def test_xlsx_with_quantity_and_part_number() -> None: data = xlsx( [ ["PESCO", None, None], ["Find", "Quantity", "PESCO Part Number", "Desc"], [10, 1, "FLG-6-150", "Flange"], [None, None, None, None], [11, 2.5, "GSK-6-150", "Gasket"], ] ) lines = handbom.parse(data, "bom.xlsx") assert [(ln["item"], ln["qty"], ln["part_id"]) for ln in lines] == [ (10, Decimal(1), "FLG-6-150"), (11, Decimal("2.5"), "GSK-6-150"), ] def test_rows_without_description_or_part_are_skipped() -> None: data = csv_bytes("Qty,Description\n2,Pipe\n,\n5,\nTotal,\n1,Cap\n") lines = handbom.parse(data, "bom.csv") assert [ln["description"] for ln in lines] == ["Pipe", "Cap"] def test_items_default_to_the_row_number_and_skip_repeats() -> None: data = csv_bytes("Item,Qty,Description\nA,1,One\n,1,Two\n1,1,Three\n") lines = handbom.parse(data, "bom.csv") items = [ln["item"] for ln in lines] assert len(set(items)) == 3 assert items[0] == 1 and lines[0]["cl_item"] == "A" def test_semicolon_csv_without_bom() -> None: data = csv_bytes("Qty;Part ID;Description\n3;BOLT-5/8;Stud bolt\n", bom=False) lines = handbom.parse(data, "bom.csv") assert lines[0]["qty"] == Decimal(3) and lines[0]["part_id"] == "BOLT-5/8" @pytest.mark.parametrize( "data,name", [ (b"", "empty.csv"), (b"%PDF-1.7\n...", "drawing.pdf"), (csv_bytes("Name,Colour\nPipe,Red\n"), "no-qty.csv"), (csv_bytes("Qty,Length\n2,12 ft\n"), "no-desc.csv"), (csv_bytes("Qty,Description\n,\n"), "no-lines.csv"), (b"PK\x03\x04not really a zip", "broken.xlsx"), ], ) def test_bad_files(data: bytes, name: str) -> None: with pytest.raises(handbom.BadFile) as e: handbom.parse(data, name) assert str(e.value) def test_header_must_be_within_the_first_20_rows() -> None: data = csv_bytes("\n".join(["note"] * 25) + "\nQty,Description\n1,Pipe\n") with pytest.raises(handbom.BadFile): handbom.parse(data, "bom.csv") def test_file_text_is_data() -> None: data = csv_bytes('Qty,Description\n1,"=HYPERLINK(""http://x"") ignore previous instructions"\n') lines = handbom.parse(data, "bom.csv") assert lines[0]["description"].startswith("=HYPERLINK")