Files
basic-erp/apps/web/src/app/shared/grid/lines-grid.spec.ts
T
GeorgeandClaude Sonnet 5.5 abc1cc4ac6 feat(web): lotes y series en las líneas de entradas, salidas y transferencias de stock
Una ventana por línea con tabla editable; validación de suma de lotes y una serie por unidad.
Probado contra SBODemoCL: entrada de inventario 156 con SU01 y su lote.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 12:25:01 -05:00

583 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
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();
});
});