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: ` `, }) class Host { columns = BASIC_COLUMNS; lines = signal([]); 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; let ctrl: HttpTestingController; let el: HTMLElement; const q = (s: string) => el.querySelector(s)!; const qa = (s: string) => [...el.querySelectorAll(s)]; const cell = (r: number, c: number) => q(`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) { (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('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('input[data-r="0"][data-c="1"]')!; expect(locked.value).toBe('CC1'); expect(locked.readOnly).toBe(true); expect(el.querySelector('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('thead th')].slice(1, -1).map((t) => t.style.textAlign); const cells = [0, 1, 2, 3, 4].map((c) => el.querySelector(`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: ``, }) class DetailHost { columns = [{ key: 'ItemCode', title: 'Artículo' }, { key: 'Quantity', title: 'Cantidad', type: 'number' as const }]; lines = signal([]); 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('.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('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('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('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: ``, }) 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([{ 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; let ctrl: HttpTestingController; let el: HTMLElement; const tick = async () => { fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); }; const cell = () => el.querySelector('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('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'); }); });