302 lines
11 KiB
TypeScript
302 lines
11 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 { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from '../../features/documents/line-columns';
|
|||
|
|
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 = DOCUMENT_LINE_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();
|
|||
|
|
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('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');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|