Files
basic-erp/apps/web/src/app/shared/grid/lines-grid.spec.ts
T

466 lines
18 KiB
TypeScript
Raw Normal View History

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 { 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);
});
});