684 lines
29 KiB
TypeScript
684 lines
29 KiB
TypeScript
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';
|
||
import { AccountCodesService } from '../../core/sl/account-codes';
|
||
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,
|
||
];
|
||
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 {
|
||
columns = BASIC_COLUMNS;
|
||
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();
|
||
(q('.dlg footer .btn.main') as HTMLElement).click();
|
||
await tick();
|
||
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));
|
||
});
|
||
|
||
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 });
|
||
});
|
||
|
||
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']);
|
||
});
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
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('');
|
||
});
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
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']);
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
|
||
describe('LinesGrid: dónde va la columna de lotes y series', () => {
|
||
@Component({
|
||
imports: [LinesGrid],
|
||
template: `<sbo-lines-grid [columns]="columns" [(lines)]="lines" [newLine]="newLine" [details]="details" />`,
|
||
})
|
||
class PlaceHost {
|
||
columns = [
|
||
{ key: 'ItemCode', title: 'Artículo' },
|
||
{ key: 'Quantity', title: 'Cantidad', type: 'number' as const },
|
||
{ key: 'Price', title: 'Precio', type: 'number' as const },
|
||
{ key: 'CostingCode', title: 'Dim. 1' },
|
||
];
|
||
lines = signal<readonly GridLine[]>([{ ItemCode: 'SU01', Quantity: 1, _manageBatches: true }]);
|
||
newLine = () => ({});
|
||
details = [{ key: 'BatchNumbers', title: 'Lotes', applies: () => true, columns: [], summary: () => 'Indicar lotes' }];
|
||
}
|
||
|
||
it('va justo después de la cantidad, no al final tras todas las demás columnas', async () => {
|
||
await TestBed.configureTestingModule({ imports: [PlaceHost], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
|
||
const fixture = TestBed.createComponent(PlaceHost);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const el = fixture.nativeElement as HTMLElement;
|
||
const heads = [...el.querySelectorAll('thead th')].map((t) => t.textContent?.trim());
|
||
expect(heads.slice(1, 5)).toEqual(['Artículo', 'Cantidad', 'Lotes / Series', 'Precio']);
|
||
const cells = [...el.querySelectorAll('tbody tr:first-child td')];
|
||
expect(cells[3].classList.contains('det')).toBe(true);
|
||
expect(cells[3].textContent?.trim()).toBe('Indicar lotes');
|
||
// los índices de columna (teclado) no cuentan la columna de lotes
|
||
expect(el.querySelector('input[data-r="0"][data-c="2"]')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe('LinesGrid: celdas de cuenta', () => {
|
||
let fixture: ComponentFixture<Host>;
|
||
let ctrl: HttpTestingController;
|
||
let el: HTMLElement;
|
||
const tick = async () => {
|
||
fixture.detectChanges();
|
||
await new Promise((r) => setTimeout(r));
|
||
fixture.detectChanges();
|
||
};
|
||
const cell = () => el.querySelector<HTMLInputElement>('input[data-r="0"][data-c="1"]')!;
|
||
|
||
beforeEach(async () => {
|
||
await TestBed.configureTestingModule({
|
||
imports: [Host],
|
||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||
}).compileComponents();
|
||
ctrl = TestBed.inject(HttpTestingController);
|
||
TestBed.inject(AccountCodesService); // la app lo instancia en su marco principal
|
||
fixture = TestBed.createComponent(Host);
|
||
el = fixture.nativeElement;
|
||
fixture.componentInstance.columns = [LINE_COLUMNS.item, LINE_COLUMNS.account];
|
||
});
|
||
|
||
it('una línea con la cuenta interna pide su código con formato y lo muestra', async () => {
|
||
fixture.componentInstance.lines.set([{ ItemCode: 'S1', AccountCode: '_SYS00000002407' }]);
|
||
await tick();
|
||
const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts' && r.params.get('$filter') === "Code eq '_SYS00000002407'");
|
||
req.flush({ value: [{ Code: '_SYS00000002407', FormatCode: '1071111010' }] });
|
||
await tick();
|
||
expect(cell().value).toBe('1071111010');
|
||
expect(fixture.componentInstance.lines()[0]['AccountCode']).toBe('_SYS00000002407'); // lo guardado sigue siendo el interno
|
||
});
|
||
|
||
it('escribir el código con formato guarda el interno de la cuenta', async () => {
|
||
fixture.componentInstance.lines.set([{ ItemCode: 'S1' }]);
|
||
await tick();
|
||
cell().value = '1071111010';
|
||
cell().dispatchEvent(new Event('change'));
|
||
await tick();
|
||
const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts' && (r.params.get('$filter') ?? '').includes("FormatCode eq '1071111010'"));
|
||
req.flush({ value: [{ Code: '_SYS00000002407', FormatCode: '1071111010', Name: 'Banco' }] });
|
||
await tick();
|
||
expect(fixture.componentInstance.lines()[0]['AccountCode']).toBe('_SYS00000002407');
|
||
expect(cell().value).toBe('1071111010');
|
||
});
|
||
});
|
||
|
||
describe('LinesGrid: columna de fecha', () => {
|
||
it('muestra la fecha de SAP sin hora, en un campo de fecha, y guarda la que se elige', async () => {
|
||
await TestBed.configureTestingModule({ imports: [Host], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
|
||
const fixture = TestBed.createComponent(Host);
|
||
fixture.componentInstance.columns = [{ key: 'ItemCode', title: 'Artículo' }, { key: 'RequiredDate', title: 'Fecha necesaria', type: 'date' }];
|
||
fixture.componentInstance.lines.set([{ ItemCode: 'A', RequiredDate: '2026-10-01T00:00:00Z' }]);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const input = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[data-r="0"][data-c="1"]')!;
|
||
expect(input.type).toBe('date');
|
||
expect(input.value).toBe('2026-10-01');
|
||
input.value = '2026-11-15';
|
||
input.dispatchEvent(new Event('change'));
|
||
fixture.detectChanges();
|
||
expect(fixture.componentInstance.lines()[0]['RequiredDate']).toBe('2026-11-15');
|
||
});
|
||
});
|