import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; import * as XLSX from "xlsx"; import { BUDGET_FORMAT_MESSAGE, buildBudgetTemplate, excelFileTypeError, parseBudgetRows, parseMoney, previewBudgetExcel, } from "./budget.ts"; Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => { const parsed = parseBudgetRows([ ["Obra:", "Barda perimetral"], ["BARDAS PERIMETRALES", "", "", "", "", ""], ["CLAVE", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO U", "IMPORTE"], ["PRE01", "TRAZO Y NIVELACION", "ml", 92, 10, 920], ["EXC-001", "EXCAVACION", "M3", 8, 850, 6800], ["", "", "", "", "SUB TOTAL", 398545.97], ["", "", "", "", "16% IVA", 63767.36], ["", "", "", "", "TOTAL", 462313.33], ]); assertEquals(parsed.items.length, 2); assertEquals(parsed.items[0].code, "PRE01"); assertEquals(parsed.items[0].quantity, 92); assertEquals(parsed.items[0].amount, 920); assertEquals(parsed.items[0].chapter, "BARDAS PERIMETRALES"); assertEquals(parsed.items[1].code, "EXC-001"); }); Deno.test("parsea plantilla con columna CAPITULO", () => { const parsed = parseBudgetRows([ ["CAPITULO", "CLAVE", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO", "IMPORTE"], ["Acabados", "ACA01", "Aplanado", "M2", 10, 20, 200], ["Cimentacion", "CIM01", "Zapata", "PZA", 2, 100, 200], ]); assertEquals(parsed.items.map((item) => item.chapter), ["Acabados", "Cimentacion"]); assertEquals(parsed.items[0].amount, 200); }); Deno.test("lee importes con coma o signo", () => { assertEquals(parseMoney("$ 1,500.00"), 1500); assertEquals(parseMoney("201.47"), 201.47); }); Deno.test("parsea Opus/Neodata: agrupadores, continuaciones y totales ignorados", () => { const parsed = parseBudgetRows([ ["Código", "Concepto", "Unidad", "Cantidad", "P. Unitario", "Importe", "%"], ["A", "LOCAL L224", "", "", "", "", ""], ["A001", "PRELIMINARES Y ALBAÑILERÍA", "", "", "", "", ""], ["10301-001", "Trazo y nivelación manual para establecer ejes, banco", "M2", 61.9, 15.53, 961.31, 0.000847], ["", "de nivel y referencias, incluye: materiales, mano de", "", 0, 0, 0, 0], ["", "obra, equipo y herramienta.", "", 0, 0, 0, 0], ["A001", "TOTAL PRELIMINARES Y ALBAÑILERÍA", "", "", "", 123219.19, 0.1], ["", "IVA 16.00%", "", "", "", 181433.44, ""], ["", "TOTAL DEL PRESUPUESTO MOSTRADO:", "", "", "", 1315392.47, ""], ]); assertEquals(parsed.items.length, 1); assertEquals(parsed.groups.map((group) => group.code), ["A", "A001"]); assertEquals(parsed.groups[0].kind, "lote"); assertEquals(parsed.groups[1].parentWbs, parsed.groups[0].wbs); assertEquals(parsed.items[0].code, "10301-001"); assertEquals(parsed.items[0].chapter, "PRELIMINARES Y ALBAÑILERÍA"); assertEquals(parsed.items[0].parentWbs, parsed.groups[1].wbs); assertStringIncludes(parsed.items[0].description, "Trazo y nivelación manual"); assertStringIncludes(parsed.items[0].description, "banco de nivel y referencias"); assertStringIncludes(parsed.items[0].description, "equipo y herramienta"); }); Deno.test("parsea plantilla WBS lote-capitulo-concepto", () => { const parsed = parseBudgetRows([ ["WBS", "TIPO", "CODIGO", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO"], ["1", "lote", "A", "LOCAL L224", "", "", ""], ["1.01", "capitulo", "A001", "Preliminares y albañilería", "", "", ""], ["1.01.01", "concepto", "10301-001", "Trazo y nivelación manual", "M2", 61.9, 15.53], ["1.02", "capitulo", "A002", "Instalación hidrosanitaria", "", "", ""], ["1.02.01", "concepto", "11402-001", "Inodoro", "PZA", 1, 100], ]); assertEquals(parsed.groups.length, 3); assertEquals(parsed.groups[0].kind, "lote"); assertEquals(parsed.groups[1].parentWbs, "1"); assertEquals(parsed.items.length, 2); assertEquals(parsed.items[0].parentWbs, "1.01"); assertEquals(parsed.items[0].chapter, "Preliminares y albañilería"); assertEquals(parsed.items[1].parentWbs, "1.02"); assertEquals(parsed.items[0].amount, 961.31); }); Deno.test("la plantilla es un xlsx valido", () => { const bytes = buildBudgetTemplate(); assertEquals(bytes[0], 0x50); assertEquals(bytes[1], 0x4b); const wb = XLSX.read(bytes, { type: "array" }); assertEquals(wb.SheetNames.includes("PRESUPUESTO"), true); assertEquals(wb.SheetNames.includes("INSTRUCCIONES"), true); }); Deno.test("preview de plantilla no guarda y arma arbol", () => { const preview = previewBudgetExcel(buildBudgetTemplate()); assertEquals(preview.format, "wbs"); assertEquals(preview.errors.length, 0); assertEquals(preview.tree[0]?.code, "A"); assertEquals(preview.tree[0]?.children.length, 2); assertEquals(preview.totals.item_count, 4); }); Deno.test("rechaza archivos que no son Excel por nombre", () => { assertEquals(excelFileTypeError("obra.pdf")?.includes(".xlsx"), true); assertEquals(excelFileTypeError("notas.docx")?.includes("«notas.docx»"), true); assertEquals(excelFileTypeError("presupuesto.xlsx"), null); assertEquals(excelFileTypeError("PRESUPUESTO.XLS"), null); }); Deno.test("preview de PDF explica que no es Excel", () => { const preview = previewBudgetExcel(new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34])); assertEquals(preview.totals.item_count, 0); assertStringIncludes(preview.errors[0]?.messages[0] || "", "PDF"); }); Deno.test("preview de Excel sin columnas de presupuesto explica el formato", () => { const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([["Nombre", "Notas"], ["Juan", "sin partidas"]]), "Hoja1"); const bytes = XLSX.write(wb, { type: "array", bookType: "xlsx" }); const preview = previewBudgetExcel(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)); assertEquals(preview.totals.item_count, 0); assertEquals(preview.errors[0]?.messages[0], BUDGET_FORMAT_MESSAGE); });