El cliente ya veía el roadmap en pantalla pero no tenía cómo pasárselo a alguien que no entra al portal. El .xlsx se genera con archive/zip + encoding/xml de la stdlib en vez de sumar una dependencia de Excel para una sola pantalla. Un CSV renombrado no servía: en Excel en español el separador y los acentos se rompen, y esto es un archivo que el cliente le entrega a un tercero. Detalles que hacen que Excel lo acepte y se lea bien: inline strings (sin tabla sharedStrings, así ningún índice puede apuntar a la cadena equivocada), encabezado en negrita y congelado, anchos de columna según el contenido, y nombre de hoja saneado (Excel rechaza el archivo si pasa de 31 caracteres o trae : \ / ? * [ ]). El endpoint repite el chequeo de acceso de PortalProyecto: sin eso, cualquiera con sesión de portal se bajaba el cronograma de otro cliente adivinando el slug. Verificado abriendo el archivo generado con openpyxl: 6 partes, CRC OK, XML bien formado en todas, acentos y CJK intactos, negrita y panel congelado donde corresponde. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
92 lines
2.6 KiB
Go
92 lines
2.6 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/xml"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// El .xlsx se arma a mano, así que hay que confirmar que es un zip válido con
|
|
// todas las partes y XML bien formado — Excel no perdona ninguna de las dos.
|
|
func TestEscribirXLSX(t *testing.T) {
|
|
filas := [][]string{
|
|
{"#", "Fase", "Descripción", "Estado"},
|
|
{"1", "Diseño & maquetación", "Wireframes <UX>", "Completado"},
|
|
{"2", "Integración API", "Conectar \"pasarela\" de pago", "En progreso"},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := EscribirXLSX(&buf, "Cronograma", filas); err != nil {
|
|
t.Fatalf("EscribirXLSX: %v", err)
|
|
}
|
|
|
|
z, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
|
if err != nil {
|
|
t.Fatalf("no es un zip válido: %v", err)
|
|
}
|
|
requeridas := []string{
|
|
"[Content_Types].xml", "_rels/.rels", "xl/workbook.xml",
|
|
"xl/_rels/workbook.xml.rels", "xl/styles.xml", "xl/worksheets/sheet1.xml",
|
|
}
|
|
presentes := map[string]string{}
|
|
for _, f := range z.File {
|
|
rc, _ := f.Open()
|
|
b, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
presentes[f.Name] = string(b)
|
|
}
|
|
for _, r := range requeridas {
|
|
if _, ok := presentes[r]; !ok {
|
|
t.Errorf("falta la parte %s", r)
|
|
}
|
|
}
|
|
// Todo XML debe parsear: un & o un < sin escapar rompe el archivo entero.
|
|
for nombre, cuerpo := range presentes {
|
|
d := xml.NewDecoder(strings.NewReader(cuerpo))
|
|
for {
|
|
_, err := d.Token()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("XML inválido en %s: %v", nombre, err)
|
|
}
|
|
}
|
|
}
|
|
hoja := presentes["xl/worksheets/sheet1.xml"]
|
|
if !strings.Contains(hoja, "Diseño & maquetación") {
|
|
t.Error("el & del texto no quedó escapado")
|
|
}
|
|
if !strings.Contains(hoja, "Wireframes <UX>") {
|
|
t.Error("los < > del texto no quedaron escapados")
|
|
}
|
|
if !strings.Contains(hoja, `<pane ySplit="1"`) {
|
|
t.Error("falta el encabezado congelado")
|
|
}
|
|
if !strings.Contains(hoja, `r="D3"`) {
|
|
t.Error("la última celda no quedó en la posición esperada (D3)")
|
|
}
|
|
}
|
|
|
|
func TestColumna(t *testing.T) {
|
|
casos := map[int]string{0: "A", 1: "B", 25: "Z", 26: "AA", 27: "AB", 51: "AZ", 52: "BA"}
|
|
for i, esperado := range casos {
|
|
if got := columna(i); got != esperado {
|
|
t.Errorf("columna(%d) = %q, esperaba %q", i, got, esperado)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Excel rechaza el archivo si el nombre de hoja pasa de 31 chars o trae : \ / ? * [ ]
|
|
func TestLimpiarNombreHoja(t *testing.T) {
|
|
if got := limpiarNombreHoja("Plan/Cron*ograma"); strings.ContainsAny(got, `:\/?*[]`) {
|
|
t.Errorf("quedaron caracteres prohibidos: %q", got)
|
|
}
|
|
largo := strings.Repeat("a", 50)
|
|
if got := limpiarNombreHoja(largo); len([]rune(got)) != 31 {
|
|
t.Errorf("no se recortó a 31: %d", len([]rune(got)))
|
|
}
|
|
}
|