2026-10-13 20:17:52 -05:00
import { provideHttpClient } from '@angular/common/http' ;
import { HttpTestingController , provideHttpClientTesting } from '@angular/common/http/testing' ;
import { Component , signal } from '@angular/core' ;
import { type ComponentFixture , TestBed } from '@angular/core/testing' ;
import { beforeEach , describe , expect , it } from 'vitest' ;
2026-10-16 11:07:38 -05:00
import { ITEMS_MULTI_ADD , LINE_COLUMNS , newDocumentLine , recalcLine } from '../../features/documents/line-columns' ;
/** Las ocho columnas básicas de una línea (las posiciones que usan estos tests). */
const BASIC_COLUMNS = [
LINE_COLUMNS . item ,
LINE_COLUMNS . description ,
LINE_COLUMNS . quantity ,
LINE_COLUMNS . warehouse ,
LINE_COLUMNS . price ,
LINE_COLUMNS . discount ,
LINE_COLUMNS . tax ,
LINE_COLUMNS . total ,
];
2026-10-13 20:17:52 -05:00
import type { GridLine } from './grid.types' ;
import { LinesGrid } from './lines-grid' ;
@Component ({
imports : [ LinesGrid ],
template : `
<sbo-lines-grid
[columns]="columns"
[(lines)]="lines"
[readonly]="readonly()"
[newLine]="newLine"
[recalc]="recalc"
[multiAdd]="multi"
/>
` ,
})
class Host {
2026-10-16 11:07:38 -05:00
columns = BASIC_COLUMNS ;
2026-10-13 20:17:52 -05:00
lines = signal < readonly GridLine [] >([]);
readonly = signal ( false );
newLine = newDocumentLine ;
recalc = recalcLine ;
multi = ITEMS_MULTI_ADD ;
}
const line = ( code : string , qty = 1 , price = 100 ) : GridLine => recalcLine ({ ... newDocumentLine (), ItemCode : code , ItemDescription : `Desc ${ code } ` , Quantity : qty , Price : price });
describe ( 'LinesGrid' , () => {
let fixture : ComponentFixture < Host >;
let ctrl : HttpTestingController ;
let el : HTMLElement ;
const q = < T extends HTMLElement >( s : string ) => el . querySelector < T >( s ) ! ;
const qa = ( s : string ) => [... el . querySelectorAll < HTMLElement >( s )];
const cell = ( r : number , c : number ) => q < HTMLInputElement >( `input[data-r=" ${ r } "][data-c=" ${ c } "]` );
const lines = () => fixture . componentInstance . lines ();
const tick = async () => {
fixture . detectChanges ();
await new Promise (( r ) => setTimeout ( r ));
fixture . detectChanges ();
};
const edit = async ( r : number , c : number , text : string ) => {
const input = cell ( r , c );
input . dispatchEvent ( new Event ( 'focus' ));
input . value = text ;
input . dispatchEvent ( new Event ( 'change' ));
input . dispatchEvent ( new Event ( 'blur' ));
await tick ();
};
const key = async ( r : number , c : number , k : string ) => {
cell ( r , c ). dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : k , bubbles : true , cancelable : true }));
await tick ();
};
beforeEach ( async () => {
await TestBed . configureTestingModule ({
imports : [ Host ],
providers : [ provideHttpClient (), provideHttpClientTesting ()],
}). compileComponents ();
ctrl = TestBed . inject ( HttpTestingController );
fixture = TestBed . createComponent ( Host );
el = fixture . nativeElement ;
await tick ();
});
it ( 'sin líneas muestra el aviso y el contador en 0' , () => {
expect ( q ( 'tr.none' ). textContent ). toContain ( 'No hay líneas' );
expect ( q ( '.count' ). textContent ). toContain ( '0 líneas' );
});
it ( 'muestra los encabezados con * en los obligatorios' , () => {
expect ( qa ( 'thead th' ). map (( t ) => t . textContent ? . trim ())). toEqual ([ '#' , 'Nº de artículo' , 'Descripción' , 'Cantidad' , 'Almacén' , 'Precio' , '% Dto.' , 'Impuesto' , 'Total' , 'Quitar' ]);
expect ( qa ( 'thead th.req' ). map (( t ) => t . textContent ? . trim ())). toEqual ([ 'Nº de artículo' , 'Cantidad' ]);
});
describe ( 'añadir y quitar' , () => {
it ( '"+ Añadir línea" crea una línea con sus valores por defecto y enfoca el primer campo editable' , async () => {
( qa ( '.bar .btn.main' )[ 0 ] as HTMLButtonElement ). click ();
await tick ();
expect ( lines ()). toHaveLength ( 1 );
expect ( lines ()[ 0 ]). toMatchObject ({ Quantity : 1 , LineTotal : 0 });
expect ( q ( '.count' ). textContent ). toContain ( '1 línea' );
expect ( document . activeElement ). toBe ( cell ( 0 , 0 ));
});
it ( '"× " quita la línea y renumera' , async () => {
fixture . componentInstance . lines . set ([ line ( 'A' ), line ( 'B' ), line ( 'C' )]);
await tick ();
( qa ( 'td.del button' )[ 1 ] as HTMLButtonElement ). click ();
await tick ();
expect ( lines (). map (( l ) => l [ 'ItemCode' ])). toEqual ([ 'A' , 'C' ]);
expect ( qa ( 'td.rn' ). map (( c ) => c . textContent ? . trim ())). toEqual ([ '1' , '2' ]);
});
});
describe ( 'edición de celdas' , () => {
beforeEach ( async () => {
fixture . componentInstance . lines . set ([ line ( 'A' , 2 , 50 )]);
await tick ();
});
it ( 'muestra números con formato y el total calculado' , () => {
expect ( cell ( 0 , 4 ). value ). toBe ( '50.00' );
expect ( cell ( 0 , 7 ). value ). toBe ( '100.00' );
});
it ( 'editar la cantidad recalcula el total al salir de la celda' , async () => {
await edit ( 0 , 2 , '3' );
expect ( lines ()[ 0 ][ 'Quantity' ]). toBe ( 3 );
expect ( lines ()[ 0 ][ 'LineTotal' ]). toBe ( 150 );
expect ( cell ( 0 , 7 ). value ). toBe ( '150.00' );
});
it ( 'acepta coma decimal y redondea el precio a 2 decimales' , async () => {
await edit ( 0 , 4 , '12,3456' );
expect ( lines ()[ 0 ][ 'Price' ]). toBe ( 12.35 );
expect ( cell ( 0 , 4 ). value ). toBe ( '12.35' );
});
it ( 'el % de descuento afecta al total' , async () => {
await edit ( 0 , 5 , '10' );
expect ( lines ()[ 0 ][ 'LineTotal' ]). toBe ( 90 );
});
it ( 'un texto que no es número conserva el valor anterior' , async () => {
await edit ( 0 , 2 , 'abc' );
expect ( lines ()[ 0 ][ 'Quantity' ]). toBe ( 2 );
expect ( cell ( 0 , 2 ). value ). toBe ( '2' );
});
it ( 'vaciar un número lo deja en null' , async () => {
await edit ( 0 , 4 , '' );
expect ( lines ()[ 0 ][ 'Price' ]). toBeNull ();
expect ( lines ()[ 0 ][ 'LineTotal' ]). toBe ( 0 );
});
it ( 'en foco muestra el valor crudo para editarlo' , async () => {
cell ( 0 , 4 ). dispatchEvent ( new Event ( 'focus' ));
await tick ();
expect ( cell ( 0 , 4 ). value ). toBe ( '50' );
});
it ( 'editar texto (descripción) no cambia otras celdas' , async () => {
await edit ( 0 , 1 , 'Otra descripción' );
expect ( lines ()[ 0 ][ 'ItemDescription' ]). toBe ( 'Otra descripción' );
expect ( lines ()[ 0 ][ 'LineTotal' ]). toBe ( 100 );
});
it ( 'el total es de solo lectura' , () => {
expect ( cell ( 0 , 7 ). readOnly ). toBe ( true );
expect ( qa ( 'td' )[ 8 ]. classList . contains ( 'ro' )). toBe ( true );
});
it ( 'sin cambios no se emite una línea nueva' , async () => {
const before = lines ();
await edit ( 0 , 2 , '2' );
expect ( lines ()). toBe ( before );
});
});
describe ( 'teclado' , () => {
beforeEach ( async () => {
fixture . componentInstance . lines . set ([ line ( 'A' ), line ( 'B' )]);
await tick ();
});
it ( 'Enter baja a la misma columna de la fila siguiente' , async () => {
cell ( 0 , 2 ). focus ();
await key ( 0 , 2 , 'Enter' );
expect ( document . activeElement ). toBe ( cell ( 1 , 2 ));
});
it ( 'Enter en la última fila crea una línea y se queda en esa columna' , async () => {
await key ( 1 , 2 , 'Enter' );
expect ( lines ()). toHaveLength ( 3 );
expect ( document . activeElement ). toBe ( cell ( 2 , 0 ));
});
it ( 'Enter confirma el valor escrito antes de moverse' , async () => {
cell ( 0 , 2 ). dispatchEvent ( new Event ( 'focus' ));
cell ( 0 , 2 ). value = '7' ;
await key ( 0 , 2 , 'Enter' );
expect ( lines ()[ 0 ][ 'Quantity' ]). toBe ( 7 );
});
it ( '↑ y ↓ cambian de fila en la misma columna' , async () => {
cell ( 0 , 1 ). focus ();
await key ( 0 , 1 , 'ArrowDown' );
expect ( document . activeElement ). toBe ( cell ( 1 , 1 ));
await key ( 1 , 1 , 'ArrowUp' );
expect ( document . activeElement ). toBe ( cell ( 0 , 1 ));
await key ( 0 , 1 , 'ArrowUp' ); // ya en la primera: no se mueve
expect ( document . activeElement ). toBe ( cell ( 0 , 1 ));
});
});
describe ( 'listas de valores en celdas' , () => {
beforeEach ( async () => {
fixture . componentInstance . lines . set ([ line ( 'A' , 2 , 50 )]);
await tick ();
});
it ( 'las celdas con lista muestran el botón "…"' , () => {
expect ( qa ( '.pick button' )). toHaveLength ( 3 ); // artículo, almacén, impuesto
});
it ( 'elegir un artículo rellena código y descripción y conserva el resto' , async () => {
( qa ( '.pick button' )[ 0 ] as HTMLButtonElement ). click ();
await tick ();
ctrl
. expectOne (( r ) => r . url === '/api/sl/Items' )
. flush ({ value : [{ ItemCode : 'I00002' , ItemName : 'DVD+R Disc 50-Pack' }], 'odata.count' : '1' });
await tick ();
( q ( '.dlg tbody tr' ) as HTMLElement ). click ();
await tick ();
2026-10-14 14:27:36 -05:00
( q ( '.dlg footer .btn.main' ) as HTMLElement ). click ();
await tick ();
2026-10-13 20:17:52 -05:00
expect ( lines ()[ 0 ]). toMatchObject ({ ItemCode : 'I00002' , ItemDescription : 'DVD+R Disc 50-Pack' , Quantity : 2 , Price : 50 , LineTotal : 100 });
expect ( el . querySelector ( '.dlg' )). toBeNull ();
expect ( document . activeElement ). toBe ( cell ( 0 , 0 ));
});
2026-10-14 14:27:36 -05:00
it ( 'varios artículos: el primero completa la línea y los demás se insertan debajo' , async () => {
fixture . componentInstance . lines . set ([ line ( 'A' , 2 , 50 ), line ( 'Z' , 1 , 10 )]);
await tick ();
( qa ( '.pick button' )[ 0 ] as HTMLButtonElement ). click ();
await tick ();
ctrl . expectOne (( r ) => r . url === '/api/sl/Items' ). flush ({
value : [
{ ItemCode : 'I1' , ItemName : 'Uno' },
{ ItemCode : 'I2' , ItemName : 'Dos' },
],
'odata.count' : '2' ,
});
await tick ();
qa ( '.dlg tbody tr' ). forEach (( r ) => ( r as HTMLElement ). click ());
await tick ();
( q ( '.dlg footer .btn.main' ) as HTMLElement ). click ();
await tick ();
expect ( lines (). map (( l ) => l [ 'ItemCode' ])). toEqual ([ 'I1' , 'I2' , 'Z' ]);
expect ( lines ()[ 0 ]). toMatchObject ({ Quantity : 2 , Price : 50 });
});
2026-10-14 14:39:16 -05:00
async function pickFromList ( buttonIndex : number , url : string , row : Record < string , unknown >) {
( qa ( '.pick button' )[ buttonIndex ] as HTMLButtonElement ). click ();
await tick ();
ctrl . expectOne (( r ) => r . url === url ). flush ({ value : [ row ], 'odata.count' : '1' });
await tick ();
( q ( '.dlg tbody tr' ) as HTMLElement ). click ();
await tick ();
}
describe ( 'almacén e impuesto: lo elegido en la 1.ª fila se copia hacia abajo' , () => {
beforeEach ( async () => {
fixture . componentInstance . lines . set ([ line ( 'A' ), line ( 'B' ), line ( 'C' )]);
await tick ();
});
it ( 'el almacén elegido en la primera fila llega a todas las de abajo' , async () => {
await pickFromList ( 1 , '/api/sl/Warehouses' , { WarehouseCode : '01' , WarehouseName : 'Central' });
expect ( lines (). map (( l ) => l [ 'WarehouseCode' ])). toEqual ([ '01' , '01' , '01' ]);
expect ( lines ()[ 2 ]). toMatchObject ({ ItemCode : 'C' , Quantity : 1 });
});
it ( 'el impuesto elegido en la primera fila llega a todas las de abajo y recalcula' , async () => {
await pickFromList ( 2 , '/api/sl/SalesTaxCodes' , { Code : 'IVA' , Name : 'IVA 19%' , Rate : 19 });
expect ( lines (). map (( l ) => l [ 'TaxCode' ])). toEqual ([ 'IVA' , 'IVA' , 'IVA' ]);
expect ( lines ()[ 1 ]). toMatchObject ({ LineTotal : 100 });
});
it ( 'elegir el almacén en la segunda fila no se replica' , async () => {
await pickFromList ( 4 , '/api/sl/Warehouses' , { WarehouseCode : '02' , WarehouseName : 'Norte' });
expect ( lines (). map (( l ) => l [ 'WarehouseCode' ])). toEqual ([ '' , '02' , '' ]);
});
it ( 'lo que ya tenían las filas de abajo se sustituye al elegir en la primera' , async () => {
fixture . componentInstance . lines . set ([ line ( 'A' ), { ... line ( 'B' ), WarehouseCode : '09' }]);
await tick ();
await pickFromList ( 1 , '/api/sl/Warehouses' , { WarehouseCode : '01' , WarehouseName : 'Central' });
expect ( lines (). map (( l ) => l [ 'WarehouseCode' ])). toEqual ([ '01' , '01' ]);
});
});
2026-10-13 20:17:52 -05:00
it ( 'F4 abre la lista de la celda; una celda sin lista no' , async () => {
await key ( 0 , 3 , 'F4' ); // almacén
ctrl . expectOne (( r ) => r . url === '/api/sl/Warehouses' ). flush ({ value : [], 'odata.count' : '0' });
await tick ();
expect ( q ( '.dlg' )). toBeTruthy ();
q ( '.dlg' ). dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Escape' , bubbles : true }));
await tick ();
await key ( 0 , 1 , 'F4' ); // descripción: sin lista
ctrl . expectNone (( r ) => r . url . startsWith ( '/api/sl/' ));
});
it ( 'elegir un impuesto solo cambia el código' , async () => {
( qa ( '.pick button' )[ 2 ] as HTMLButtonElement ). click ();
await tick ();
ctrl . expectOne (( r ) => r . url === '/api/sl/SalesTaxCodes' ). flush ({ value : [{ Code : 'IVA' , Name : 'IVA' , Rate : 19 }], 'odata.count' : '1' });
await tick ();
( q ( '.dlg tbody tr' ) as HTMLElement ). click ();
await tick ();
expect ( lines ()[ 0 ][ 'TaxCode' ]). toBe ( 'IVA' );
expect ( lines ()[ 0 ][ 'ItemCode' ]). toBe ( 'A' );
});
});
describe ( '"Elegir artículos…" (varias líneas a la vez)' , () => {
it ( 'agrega una línea por artículo marcado, con su total calculado' , async () => {
fixture . componentInstance . lines . set ([ line ( 'A' )]);
await tick ();
( qa ( '.bar .btn' )[ 0 ] as HTMLButtonElement ). click ();
await tick ();
ctrl
. expectOne (( r ) => r . url === '/api/sl/Items' )
. flush ({
value : [
{ ItemCode : 'X1' , ItemName : 'Uno' },
{ ItemCode : 'X2' , ItemName : 'Dos' },
],
'odata.count' : '2' ,
});
await tick ();
const rows = qa ( '.dlg tbody tr' );
rows [ 0 ]. click ();
rows [ 1 ]. click ();
await tick ();
( qa ( '.dlg footer .btn.main' )[ 0 ] as HTMLButtonElement ). click ();
await tick ();
expect ( lines (). map (( l ) => l [ 'ItemCode' ])). toEqual ([ 'A' , 'X1' , 'X2' ]);
expect ( lines ()[ 1 ]). toMatchObject ({ ItemDescription : 'Uno' , Quantity : 1 , LineTotal : 0 });
expect ( el . querySelector ( '.dlg' )). toBeNull ();
});
});
describe ( 'documento cerrado (solo lectura)' , () => {
beforeEach ( async () => {
fixture . componentInstance . lines . set ([ line ( 'A' , 2 , 50 )]);
fixture . componentInstance . readonly . set ( true );
await tick ();
});
it ( 'no hay botones de añadir, quitar ni listas' , () => {
expect ( el . querySelector ( '.bar .btn' )). toBeNull ();
expect ( el . querySelector ( 'td.del' )). toBeNull ();
expect ( el . querySelector ( '.pick' )). toBeNull ();
});
it ( 'todas las celdas son de solo lectura y no se pueden editar' , async () => {
expect ( qa ( 'tbody input' ). every (( i ) => ( i as HTMLInputElement ). readOnly )). toBe ( true );
await edit ( 0 , 2 , '9' );
expect ( lines ()[ 0 ][ 'Quantity' ]). toBe ( 2 );
});
it ( 'sin líneas dice que el documento no tiene líneas' , async () => {
fixture . componentInstance . lines . set ([]);
await tick ();
expect ( q ( 'tr.none' ). textContent ). toContain ( 'no tiene líneas' );
});
});
2026-10-14 11:42:51 -05:00
describe ( 'código escrito a mano' , () => {
it ( 'busca el artículo y completa la descripción como al elegirlo' , async () => {
fixture . componentInstance . lines . set ([{ ... newDocumentLine () }]);
await tick ();
await edit ( 0 , 0 , 'A00001' );
const req = ctrl . expectOne (( r ) => r . url === '/api/sl/Items' && r . params . get ( '$filter' ) === "ItemCode eq 'A00001'" );
expect ( req . request . params . get ( '$top' )). toBe ( '1' );
req . flush ({ value : [{ ItemCode : 'A00001' , ItemName : 'IBM Infoprint 1312' }] });
await tick ();
expect ( lines ()[ 0 ]). toMatchObject ({ ItemCode : 'A00001' , ItemDescription : 'IBM Infoprint 1312' });
});
it ( 'Enter y change sobre la misma celda hacen una sola consulta' , async () => {
fixture . componentInstance . lines . set ([{ ... newDocumentLine () }]);
await tick ();
const input = cell ( 0 , 0 );
input . value = 'A00001' ;
await key ( 0 , 0 , 'Enter' );
input . dispatchEvent ( new Event ( 'change' ));
await tick ();
ctrl . expectOne (( r ) => r . url === '/api/sl/Items' ). flush ({ value : [{ ItemCode : 'A00001' , ItemName : 'X' }] });
});
it ( 'si no existe, avisa y la celda vuelve a su valor' , async () => {
fixture . componentInstance . lines . set ([{ ... newDocumentLine () }]);
const notices : string [] = [];
await tick ();
el . querySelector ( 'sbo-lines-grid' ) ! . dispatchEvent ( new Event ( 'x' )); // sin efecto: solo asegura el DOM
const grid = fixture . debugElement . query (( d ) => d . name === 'sbo-lines-grid' ). componentInstance as LinesGrid ;
grid . notice . subscribe (( n ) => notices . push ( n ));
await edit ( 0 , 0 , 'NOEXISTE' );
ctrl . expectOne (( r ) => r . url === '/api/sl/Items' ). flush ({ value : [] });
await tick ();
expect ( notices ). toEqual ([ 'No existe «NOEXISTE» en artículos.' ]);
expect ( cell ( 0 , 0 ). value ). toBe ( '' );
expect ( lines ()[ 0 ][ 'ItemCode' ]). toBe ( '' );
});
it ( 'vaciar la celda no consulta: limpia el valor' , async () => {
fixture . componentInstance . lines . set ([ line ( 'A1' )]);
await tick ();
await edit ( 0 , 0 , '' );
ctrl . expectNone (( r ) => r . url === '/api/sl/Items' );
expect ( lines ()[ 0 ][ 'ItemCode' ]). toBe ( '' );
});
});
2026-10-13 20:17:52 -05:00
});
2026-10-16 11:09:47 -05:00
describe ( 'LinesGrid: ancho de la tabla' , () => {
it ( 'el ancho mínimo es la suma de los anchos de sus columnas, para no apretarlas' , async () => {
await TestBed . configureTestingModule ({
imports : [ Host ],
providers : [ provideHttpClient (), provideHttpClientTesting ()],
}). compileComponents ();
const fixture = TestBed . createComponent ( Host );
fixture . componentInstance . columns = [
{ key : 'A' , title : 'A' , width : '100px' },
{ key : 'B' , title : 'B' , width : '60px' },
{ key : 'C' , title : 'C' }, // sin ancho: reserva 220
];
fixture . detectChanges ();
await fixture . whenStable ();
const table = ( fixture . nativeElement as HTMLElement ). querySelector < HTMLElement >( 'table.grid' ) ! ;
expect ( parseFloat ( table . style . minWidth )). toBe ( 100 + 60 + 220 + 34 + 30 );
});
});
2026-10-16 11:14:52 -05:00
describe ( 'LinesGrid: columnas bloqueadas' , () => {
it ( 'una columna bloqueada se ve con su valor pero no se puede editar' , async () => {
await TestBed . configureTestingModule ({
imports : [ Host ],
providers : [ provideHttpClient (), provideHttpClientTesting ()],
}). compileComponents ();
const fixture = TestBed . createComponent ( Host );
fixture . componentInstance . columns = [
{ key : 'ItemCode' , title : 'Artículo' },
{ key : 'CostingCode' , title : 'Dim. 1' , locked : true },
];
fixture . componentInstance . lines . set ([{ ItemCode : 'A' , CostingCode : 'CC1' }]);
fixture . detectChanges ();
await fixture . whenStable ();
fixture . detectChanges ();
const el = fixture . nativeElement as HTMLElement ;
const locked = el . querySelector < HTMLInputElement >( 'input[data-r="0"][data-c="1"]' ) ! ;
expect ( locked . value ). toBe ( 'CC1' );
expect ( locked . readOnly ). toBe ( true );
expect ( el . querySelector < HTMLInputElement >( 'input[data-r="0"][data-c="0"]' ) ! . readOnly ). toBe ( false );
});
});
2026-10-16 11:23:44 -05:00
describe ( 'LinesGrid: alineación de las columnas' , () => {
it ( 'por defecto las numéricas van a la derecha y el resto a la izquierda; `align` lo cambia' , async () => {
await TestBed . configureTestingModule ({
imports : [ Host ],
providers : [ provideHttpClient (), provideHttpClientTesting ()],
}). compileComponents ();
const fixture = TestBed . createComponent ( Host );
fixture . componentInstance . columns = [
{ key : 'Texto' , title : 'Texto' },
{ key : 'Cantidad' , title : 'Cantidad' , type : 'number' },
{ key : 'Codigo' , title : 'Código' , align : 'right' },
{ key : 'Centrado' , title : 'Centrado' , align : 'center' },
{ key : 'Importe' , title : 'Importe' , type : 'number' , align : 'left' },
];
fixture . componentInstance . lines . set ([{ Texto : 'a' , Cantidad : 1 , Codigo : 'x' , Centrado : 'c' , Importe : 5 }]);
fixture . detectChanges ();
await fixture . whenStable ();
fixture . detectChanges ();
const el = fixture . nativeElement as HTMLElement ;
const heads = [... el . querySelectorAll < HTMLElement >( 'thead th' )]. slice ( 1 , - 1 ). map (( t ) => t . style . textAlign );
const cells = [ 0 , 1 , 2 , 3 , 4 ]. map (( c ) => el . querySelector < HTMLInputElement >( `input[data-r="0"][data-c=" ${ c } "]` ) ! . style . textAlign );
expect ( heads ). toEqual ([ 'left' , 'right' , 'right' , 'center' , 'left' ]);
expect ( cells ). toEqual ([ 'left' , 'right' , 'right' , 'center' , 'left' ]);
});
});
2026-10-16 12:25:01 -05:00
describe ( 'LinesGrid: detalles de línea (lotes)' , () => {
const BATCHES = {
key : 'BatchNumbers' ,
title : 'Lotes' ,
applies : ( l : GridLine ) => l [ '_manageBatches' ] === true || ( Array . isArray ( l [ 'BatchNumbers' ]) && l [ 'BatchNumbers' ]. length > 0 ),
columns : [
{ key : 'BatchNumber' , title : 'Lote' , type : 'text' as const },
{ key : 'Quantity' , title : 'Cantidad' , type : 'number' as const },
],
summary : ( l : GridLine ) => ( Array . isArray ( l [ 'BatchNumbers' ]) && l [ 'BatchNumbers' ]. length ? ` ${ l [ 'BatchNumbers' ]. length } lote(s)` : 'Indicar lotes' ),
};
@Component ({
imports : [ LinesGrid ],
template : `<sbo-lines-grid [columns]="columns" [(lines)]="lines" [readonly]="readonly()" [newLine]="newLine" [details]="details" />` ,
})
class DetailHost {
columns = [{ key : 'ItemCode' , title : 'Artículo' }, { key : 'Quantity' , title : 'Cantidad' , type : 'number' as const }];
lines = signal < readonly GridLine [] >([]);
readonly = signal ( false );
newLine = () => ({});
details = [ BATCHES ];
}
async function make ( lines : GridLine [], readonly = false ) {
TestBed . resetTestingModule ();
await TestBed . configureTestingModule ({ imports : [ DetailHost ], providers : [ provideHttpClient (), provideHttpClientTesting ()] }). compileComponents ();
const fixture = TestBed . createComponent ( DetailHost );
fixture . componentInstance . lines . set ( lines );
fixture . componentInstance . readonly . set ( readonly );
const tick = async () => {
fixture . detectChanges ();
await new Promise (( r ) => setTimeout ( r ));
fixture . detectChanges ();
};
await tick ();
return { fixture , el : fixture.nativeElement as HTMLElement , tick };
}
it ( 'solo las líneas a las que aplica el detalle muestran su botón' , async () => {
const { el } = await make ([{ ItemCode : 'SU01' , Quantity : 5 , _manageBatches : true }, { ItemCode : 'A1' , Quantity : 1 }]);
const buttons = [... el . querySelectorAll < HTMLButtonElement >( '.detbtn' )];
expect ( buttons . map (( b ) => b . textContent ? . trim ())). toEqual ([ 'Indicar lotes' ]);
expect ( el . querySelector ( 'th.det' ) ? . textContent ). toContain ( 'Lotes / Series' );
});
it ( 'el botón abre la ventana, se añaden filas y al aceptar quedan en la línea' , async () => {
const { fixture , el , tick } = await make ([{ ItemCode : 'SU01' , Quantity : 5 , _manageBatches : true }]);
( el . querySelector ( '.detbtn' ) as HTMLElement ). click ();
await tick ();
expect ( el . querySelector ( 'sbo-line-detail-dialog [role=dialog]' )). toBeTruthy ();
( el . querySelector ( 'sbo-line-detail-dialog footer .btn' ) as HTMLElement ). click (); // + Añadir fila
await tick ();
const inputs = el . querySelectorAll < HTMLInputElement >( 'sbo-line-detail-dialog tbody input' );
inputs [ 0 ]. value = 'LT-9' ;
inputs [ 0 ]. dispatchEvent ( new Event ( 'change' ));
inputs [ 1 ]. value = '5' ;
inputs [ 1 ]. dispatchEvent ( new Event ( 'change' ));
await tick ();
( el . querySelector ( 'sbo-line-detail-dialog footer .btn.main' ) as HTMLElement ). click (); // Aceptar
await tick ();
expect ( el . querySelector ( 'sbo-line-detail-dialog' )). toBeNull ();
expect ( fixture . componentInstance . lines ()[ 0 ][ 'BatchNumbers' ]). toEqual ([{ BatchNumber : 'LT-9' , Quantity : 5 }]);
expect ( el . querySelector ( '.detbtn' ) ? . textContent ? . trim ()). toBe ( '1 lote(s)' );
});
it ( 'cancelar descarta los cambios' , async () => {
const { fixture , el , tick } = await make ([{ ItemCode : 'SU01' , Quantity : 5 , _manageBatches : true }]);
( el . querySelector ( '.detbtn' ) as HTMLElement ). click ();
await tick ();
( el . querySelector ( 'sbo-line-detail-dialog footer .btn' ) as HTMLElement ). click ();
await tick ();
const cancel = [... el . querySelectorAll < HTMLElement >( 'sbo-line-detail-dialog footer .btn' )]. find (( b ) => b . textContent ? . trim () === 'Cancelar' ) ! ;
cancel . click ();
await tick ();
expect ( fixture . componentInstance . lines ()[ 0 ][ 'BatchNumbers' ]). toBeUndefined ();
});
it ( 'en solo lectura se ven los lotes pero no se editan' , async () => {
const { el , tick } = await make ([{ ItemCode : 'SU01' , Quantity : 3 , BatchNumbers : [{ BatchNumber : 'LT001' , Quantity : 3 }] }], true );
expect ( el . querySelector ( '.detbtn' ) ? . textContent ? . trim ()). toBe ( '1 lote(s)' );
( el . querySelector ( '.detbtn' ) as HTMLElement ). click ();
await tick ();
const input = el . querySelector < HTMLInputElement >( 'sbo-line-detail-dialog tbody input' ) ! ;
expect ( input . value ). toBe ( 'LT001' );
expect ( input . readOnly ). toBe ( true );
expect ( el . querySelector ( 'sbo-line-detail-dialog footer .btn.main' )). toBeNull ();
});
});