import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CLIENT_EXTENSIONS } from './client-extensions'; import { fieldGroup, headerKeysOf } from './document-layouts'; import { lineFieldsOf } from './line-columns'; import { DocumentFormPage } from './document-form.page'; import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens'; import { type ScreenExtensions, applyExtensions } from './screen-extensions'; const by = (screens: readonly { path: string }[], path: string) => screens.find((s) => s.path === path)! as (typeof BASE_SCREENS)[number]; const EXT: ScreenExtensions = { '/ventas/facturas': { headerRight: [{ type: 'text', key: 'U_Obra', label: 'Obra' }], footerLeft: [{ type: 'text', key: 'U_Nota', label: 'Nota interna' }], lineColumns: [{ key: 'U_Lote', title: 'Lote', width: '90px' }], listColumns: [{ key: 'U_Obra', title: 'Obra', searchable: true }], }, '/inventario/entradas': { headerLeft: [{ type: 'text', key: 'U_Chofer', label: 'Chofer' }], lineColumns: [{ key: 'U_Origen', title: 'Origen' }] }, }; describe('extensiones por cliente', () => { it('en main no agregan nada: las pantallas son las del base', () => { expect(CLIENT_EXTENSIONS).toEqual({}); expect(DOCUMENT_SCREENS).toEqual(BASE_SCREENS); }); it('agregan campos de cabecera y de pie al final y los envían a SAP', () => { const s = by(applyExtensions(BASE_SCREENS, EXT), '/ventas/facturas'); expect(s.layout.header.right.at(-1)).toMatchObject({ key: 'U_Obra' }); expect(s.layout.footer.left.at(-1)).toMatchObject({ key: 'U_Nota' }); expect(headerKeysOf(s.layout)).toEqual(expect.arrayContaining(['U_Obra', 'U_Nota'])); }); it('agregan columnas de línea al final (después de las base) y las envían; y columnas de lista al final', () => { const s = by(applyExtensions(BASE_SCREENS, EXT), '/ventas/facturas'); const keys = s.layout.columns.map((c) => c.key); expect(keys.at(-1)).toBe('U_Lote'); expect(keys.indexOf('LineTotal')).toBeLessThan(keys.indexOf('U_Lote')); expect(s.layout.lines.fields).toContain('U_Lote'); expect(s.columns.at(-1)).toMatchObject({ key: 'U_Obra' }); }); it('en un documento sin total las columnas van al final', () => { const s = by(applyExtensions(BASE_SCREENS, EXT), '/inventario/entradas'); expect(s.layout.columns.at(-1)).toMatchObject({ key: 'U_Origen' }); }); it('solo cambia la pantalla extendida (aunque comparta layout con otra)', () => { const out = applyExtensions(BASE_SCREENS, EXT); expect(by(out, '/inventario/salidas')).toEqual(by(BASE_SCREENS, '/inventario/salidas')); expect(by(out, '/ventas/ordenes')).toEqual(by(BASE_SCREENS, '/ventas/ordenes')); expect(by(BASE_SCREENS, '/ventas/facturas').layout.columns.map((c) => c.key)).not.toContain('U_Lote'); }); it('una ruta que no existe es un error', () => { expect(() => applyExtensions(BASE_SCREENS, { '/ventas/facturaz': { listColumns: [] } })).toThrow(/no existe la pantalla/); }); }); describe('indicador y folio juntos', () => { it('la factura del base lleva Indicador, prefijo y número en una sola fila, en ese orden', () => { const layout = by(BASE_SCREENS, '/ventas/facturas').layout; const groups = layout.header.right.filter((f) => f.type === 'group'); expect(groups).toHaveLength(1); expect(groups[0].type === 'group' && groups[0].cells.map((c) => c.key)).toEqual(['Indicator', 'FolioPrefixString', 'FolioNumber']); expect(layout.header.left.some((f) => 'key' in f && f.key === 'Indicator')).toBe(false); // ya no va suelto }); it('lo llevan facturas, boletas, reservas y notas de crédito de ventas y compras', () => { for (const path of ['/ventas/facturas', '/ventas/boletas', '/ventas/facturas-reserva', '/ventas/boletas-reserva', '/ventas/notas-credito', '/compras/facturas', '/compras/notas-credito']) { expect(by(BASE_SCREENS, path).layout.header.right.some((f) => f.type === 'group'), path).toBe(true); } expect(by(BASE_SCREENS, '/ventas/ordenes').layout.header.right.some((f) => f.type === 'group')).toBe(false); }); it('una empresa con campos de usuario los sustituye por su propio grupo, también juntos', () => { const screens = applyExtensions(BASE_SCREENS, { '/ventas/facturas': { hideHeaderFields: ['Indicator'], // oculta el grupo completo del base headerRight: [ fieldGroup('Tipo / Serie / Número', [ { kind: 'text', key: 'U_Tipo', label: 'Tipo', width: '60px' }, { kind: 'text', key: 'U_Serie', label: 'Serie', width: '80px' }, { kind: 'text', key: 'U_Numero', label: 'Número' }, ]), ], }, }); const layout = by(screens, '/ventas/facturas').layout; const groups = layout.header.right.filter((f) => f.type === 'group'); expect(groups).toHaveLength(1); expect(groups[0].type === 'group' && groups[0].cells.map((c) => c.key)).toEqual(['U_Tipo', 'U_Serie', 'U_Numero']); expect(headerKeysOf(layout)).toEqual(expect.arrayContaining(['U_Tipo', 'U_Serie', 'U_Numero'])); expect(headerKeysOf(layout)).not.toContain('FolioNumber'); }); }); describe('formulario con campos de cliente', () => { let fixture: ComponentFixture; let ctrl: HttpTestingController; const el = () => fixture.nativeElement as HTMLElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any const page = () => fixture.componentInstance as any; const tick = async () => { fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); }; async function open(doc: Record) { const screen = by( applyExtensions(BASE_SCREENS, { '/ventas/ordenes': { headerLeft: [ { type: 'select', key: 'U_Canal', label: 'Canal', options: [{ value: 'W', label: 'Web' }, { value: 'T', label: 'Tienda' }] }, ], headerRight: [{ type: 'text', key: 'U_Obra', label: 'Obra' }], lineColumns: [{ key: 'U_Lote', title: 'Lote' }], lookupColumns: { partner: [{ key: 'U_Zona', title: 'Zona' }], item: [{ key: 'U_Marca', title: 'Marca' }] }, }, }), '/ventas/ordenes', ); const route = { snapshot: { data: { screen, title: 'Órdenes de venta' }, paramMap: convertToParamMap({ docEntry: '5' }) }, paramMap: of(convertToParamMap({ docEntry: '5' })), }; await TestBed.configureTestingModule({ imports: [DocumentFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); ctrl = TestBed.inject(HttpTestingController); vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); fixture = TestBed.createComponent(DocumentFormPage); await tick(); ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [] }); ctrl.expectOne('/api/sl/Orders/5').flush(doc); await tick(); } const order = { 'odata.etag': 'W/"1"', DocEntry: 5, DocNum: 5, CardCode: 'C1', CardName: 'ACME', DocDate: '2026-09-11T00:00:00Z', DocDueDate: '2026-09-20T00:00:00Z', DiscountPercent: 0, DocumentStatus: 'bost_Open', Cancelled: 'tNO', U_Canal: 'W', U_Obra: 'Torre 3', DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 10, DiscountPercent: 0, TaxCode: '', LineTotal: 10, WarehouseCode: '01', U_Lote: 'L-77' }], }; beforeEach(() => TestBed.resetTestingModule()); it('muestra el campo de usuario, el desplegable y la columna de línea con sus valores de SAP', async () => { await open(order); expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toEqual(expect.arrayContaining(['Canal', 'Obra'])); expect([...el().querySelectorAll('select')].map((s) => s.value)).toContain('W'); expect([...el().querySelectorAll('input')].map((i) => i.value)).toEqual(expect.arrayContaining(['Torre 3', 'L-77'])); }); it('la ventana de artículos de una línea muestra las columnas del documento', async () => { await open(order); const itemBtn = el().querySelector('.pick button') as HTMLButtonElement; itemBtn.click(); await tick(); const req = ctrl.expectOne((r) => r.url === '/api/sl/Items'); expect(req.request.params.get('$select')).toContain('U_Marca'); req.flush({ value: [{ ItemCode: 'A1', ItemName: 'Pieza', U_Marca: 'Acme' }], 'odata.count': '1' }); await tick(); expect([...el().querySelectorAll('.dlg thead th')].map((t) => t.textContent?.trim())).toEqual(expect.arrayContaining(['Código', 'Descripción', 'Marca'])); expect(el().querySelector('.dlg tbody')!.textContent).toContain('Acme'); }); it('al modificar envía solo el campo de usuario que cambió', async () => { await open(order); page().setHeader('U_Canal', 'T'); page().setHeader('U_Obra', 'Torre 4'); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); const req = ctrl.expectOne((r) => r.method === 'PATCH'); expect(req.request.body).toEqual({ U_Canal: 'T', U_Obra: 'Torre 4' }); req.flush(null); await tick(); ctrl.expectOne('/api/sl/Orders/5').flush(order); }); it('al cambiar la columna de línea envía las líneas con el campo de usuario', async () => { await open(order); page().setLines([{ ...page().lines()[0], U_Lote: 'L-80' }]); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); const req = ctrl.expectOne((r) => r.method === 'PATCH'); expect((req.request.body as { DocumentLines: Record[] }).DocumentLines[0]).toMatchObject({ ItemCode: 'A1', U_Lote: 'L-80' }); req.flush(null); await tick(); ctrl.expectOne('/api/sl/Orders/5').flush(order); }); }); describe('columnas de las listas de valores por documento', () => { const find = (screens: readonly { path: string }[], path: string) => by(screens, path).layout; const itemCols = (path: string, screens = BASE_SCREENS) => find(screens, path).columns.find((c) => c.key === 'ItemCode')!.lookup!.config.columns.map((c) => c.key); it('por defecto todos los documentos muestran código y descripción del artículo', () => { expect(itemCols('/ventas/ordenes')).toEqual(['ItemCode', 'ItemName']); expect(find(BASE_SCREENS, '/ventas/ordenes').lookups).toBeUndefined(); }); it('una pantalla del base cambia las columnas de su lista sin afectar a las demás', async () => { const { marketingLayout } = await import('./document-layouts'); const layout = marketingLayout({ partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom: 'sales', lookups: { item: [{ key: 'ItemCode', title: 'Código' }, { key: 'ItemName', title: 'Descripción' }, { key: 'QuantityOnStock', title: 'Stock', type: 'number' }], partner: [{ key: 'CardCode', title: 'Código' }, { key: 'CardName', title: 'Nombre' }, { key: 'Phone1', title: 'Teléfono' }], }, }); expect(layout.columns.find((c) => c.key === 'ItemCode')!.lookup!.config.columns.map((c) => c.key)).toEqual(['ItemCode', 'ItemName', 'QuantityOnStock']); expect(layout.lookups!.partner!.map((c) => c.key)).toEqual(['CardCode', 'CardName', 'Phone1']); // el recurso, el filtro y los campos extra de la lista no cambian const cfg = layout.columns.find((c) => c.key === 'ItemCode')!.lookup!.config; expect(cfg.resource).toBe('Items'); expect(cfg.extraFields).toEqual(expect.arrayContaining(['UoMGroupEntry'])); expect(itemCols('/ventas/facturas')).toEqual(['ItemCode', 'ItemName']); }); it('una extensión de cliente suma columnas al final de las del base', () => { const out = applyExtensions(BASE_SCREENS, { '/ventas/ordenes': { lookupColumns: { item: [{ key: 'U_Marca', title: 'Marca' }], partner: [{ key: 'U_Zona', title: 'Zona' }], warehouse: [{ key: 'U_Sucursal', title: 'Sucursal' }], }, }, }); expect(itemCols('/ventas/ordenes', out)).toEqual(['ItemCode', 'ItemName', 'U_Marca']); const l = find(out, '/ventas/ordenes'); expect(l.lookups!.partner!.map((c) => c.key)).toEqual(['CardCode', 'CardName', 'U_Zona']); expect(l.columns.find((c) => c.key === 'WarehouseCode')!.lookup!.config.columns.map((c) => c.key)).toEqual(['WarehouseCode', 'WarehouseName', 'U_Sucursal']); expect(itemCols('/ventas/facturas', out)).toEqual(['ItemCode', 'ItemName']); }); it('la lista de almacenes se aplica a las dos columnas de almacén de una transferencia', () => { const out = applyExtensions(BASE_SCREENS, { '/inventario/transferencias': { lookupColumns: { warehouse: [{ key: 'U_Sucursal', title: 'Sucursal' }] } } }); const cols = find(out, '/inventario/transferencias').columns.filter((c) => c.lookup && /Warehouse/.test(c.key)); expect(cols).toHaveLength(2); for (const c of cols) expect(c.lookup!.config.columns.at(-1)!.key).toBe('U_Sucursal'); }); }); describe('ocultar y dejar de solo lectura (extensiones de cliente)', () => { const layoutOf = (out: readonly { path: string }[], path: string) => by(out, path).layout; const apply = (e: ScreenExtensions) => applyExtensions(BASE_SCREENS, e); const DIMS_2_5 = ['CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5']; it('un cliente que solo quiere CostingCode oculta las otras dimensiones: no se muestran ni se envían', () => { const l = layoutOf(apply({ '/ventas/facturas': { hideLineColumns: DIMS_2_5 } }), '/ventas/facturas'); const keys = l.columns.map((c) => c.key); expect(keys).toContain('CostingCode'); for (const k of DIMS_2_5) { expect(keys).not.toContain(k); expect(l.lines.fields).not.toContain(k); } expect(l.lines.fields).toContain('CostingCode'); }); it('oculta también las columnas de lectura y de lista, sin tocar las demás pantallas', () => { const out = apply({ '/ventas/facturas': { hideLineColumns: ['BaseType', 'BaseEntry', 'BaseLine', 'AccountCode'], hideListColumns: ['FolioPrefixString'] }, }); expect(layoutOf(out, '/ventas/facturas').columns.map((c) => c.key)).not.toEqual(expect.arrayContaining(['BaseType'])); expect(by(out, '/ventas/facturas').columns.map((c) => c.key)).not.toContain('FolioPrefixString'); expect(by(out, '/ventas/facturas').columns.map((c) => c.key)).toContain('FolioNumber'); expect(layoutOf(out, '/ventas/ordenes').columns.map((c) => c.key)).toEqual(layoutOf(BASE_SCREENS, '/ventas/ordenes').columns.map((c) => c.key)); }); it('oculta campos de cabecera: dejan de mostrarse y de enviarse', () => { const l = layoutOf(apply({ '/ventas/ordenes': { hideHeaderFields: ['Indicator', 'Project', 'partnerTaxId'] } }), '/ventas/ordenes'); const keys = headerKeysOf(l); expect(keys).not.toEqual(expect.arrayContaining(['Indicator'])); expect(keys).not.toContain('Project'); expect(l.header.left.map((f) => ('label' in f ? f.label : ''))).not.toContain('RUT'); expect(keys).toContain('NumAtCard'); }); it('el cliente puede ocultar una columna base y agregar la suya', () => { const l = layoutOf(apply({ '/ventas/ordenes': { hideLineColumns: ['AccountCode'], lineColumns: [{ key: 'U_Lote', title: 'Lote' }] } }), '/ventas/ordenes'); const keys = l.columns.map((c) => c.key); expect(keys).not.toContain('AccountCode'); expect(keys.at(-1)).toBe('U_Lote'); }); it('una columna de solo lectura se ve y no se edita, pero su valor sigue enviándose', () => { const l = layoutOf(apply({ '/ventas/facturas': { readOnlyLineColumns: ['CostingCode', 'ProjectCode'] } }), '/ventas/facturas'); const col = (k: string) => l.columns.find((c) => c.key === k)!; expect(col('CostingCode').locked).toBe(true); expect(col('ProjectCode').locked).toBe(true); expect(col('CostingCode2').locked).toBeUndefined(); expect(l.lines.fields).toEqual(expect.arrayContaining(['CostingCode', 'ProjectCode'])); expect(lineFieldsOf(l.columns)).toEqual(expect.arrayContaining(['CostingCode', 'ProjectCode'])); }); it('un campo de cabecera de solo lectura queda marcado y se sigue enviando', () => { const l = layoutOf(apply({ '/ventas/ordenes': { readOnlyHeaderFields: ['Indicator', 'NumAtCard'] } }), '/ventas/ordenes'); expect(l.lockedKeys).toEqual(expect.arrayContaining(['Indicator', 'NumAtCard'])); expect(headerKeysOf(l)).toEqual(expect.arrayContaining(['Indicator', 'NumAtCard'])); }); it('no se puede ocultar lo obligatorio, y un nombre que no existe es un error', () => { expect(() => apply({ '/ventas/ordenes': { hideLineColumns: ['ItemCode'] } })).toThrow(/obligatoria/); expect(() => apply({ '/ventas/ordenes': { hideLineColumns: ['Quantity'] } })).toThrow(/obligatoria/); expect(() => apply({ '/ventas/ordenes': { hideHeaderFields: ['CardCode'] } })).toThrow(/obligatorio/); expect(() => apply({ '/inventario/entradas': { hideLineColumns: ['WarehouseCode'] } })).toThrow(/obligatoria/); expect(() => apply({ '/ventas/ordenes': { hideLineColumns: ['CostingCode9'] } })).toThrow(/no tiene la columna de línea/); expect(() => apply({ '/ventas/ordenes': { hideHeaderFields: ['Nada'] } })).toThrow(/no tiene el campo de cabecera/); expect(() => apply({ '/ventas/ordenes': { readOnlyLineColumns: ['Nada'] } })).toThrow(/no tiene la columna de línea/); expect(() => apply({ '/ventas/ordenes': { hideListColumns: ['Nada'] } })).toThrow(/no tiene la columna de lista/); }); }); describe('formulario con campos ocultos o de solo lectura', () => { let fixture: ComponentFixture; const el = () => fixture.nativeElement as HTMLElement; const tick = async () => { fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); }; async function openWith(ext: ScreenExtensions) { TestBed.resetTestingModule(); const screen = by(applyExtensions(BASE_SCREENS, ext), '/ventas/ordenes'); const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})), }; await TestBed.configureTestingModule({ imports: [DocumentFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); fixture = TestBed.createComponent(DocumentFormPage); await tick(); } const input = (label: string) => { const f = [...el().querySelectorAll('sbo-field, sbo-text-field, sbo-lookup-field')].find((x) => x.querySelector('label')?.textContent?.trim() === label); return f?.querySelector('input') ?? null; }; it('un campo oculto no aparece en el formulario', async () => { await openWith({ '/ventas/ordenes': { hideHeaderFields: ['Indicator'] } }); expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).not.toContain('Indicador'); expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toContain('Proyecto'); }); it('un campo de solo lectura se ve pero no se puede escribir', async () => { await openWith({ '/ventas/ordenes': { readOnlyHeaderFields: ['Indicator', 'Project'] } }); expect(input('Indicador')!.readOnly).toBe(true); expect(input('Proyecto')!.readOnly).toBe(true); expect(input('Nº referencia')!.readOnly).toBe(false); const buttons = [...el().querySelectorAll('sbo-lookup-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Proyecto')!.querySelector('button.sbo-btn') as HTMLButtonElement; expect(buttons.disabled).toBe(true); }); it('sin extensiones los campos se editan como siempre', async () => { await openWith({}); expect(input('Indicador')!.readOnly).toBe(false); }); }); describe('campos Sí/No (checkbox) en los documentos', () => { it('un campo de usuario de tipo casilla se dibuja como casilla y guarda tYES / tNO', async () => { TestBed.resetTestingModule(); const screen = by( applyExtensions(BASE_SCREENS, { '/ventas/ordenes': { headerLeft: [{ type: 'checkbox', key: 'U_Urgente', label: 'Urgente' }] } }), '/ventas/ordenes', ); const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({ docEntry: '5' }) }, paramMap: of(convertToParamMap({ docEntry: '5' })), }; await TestBed.configureTestingModule({ imports: [DocumentFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); const ctrl = TestBed.inject(HttpTestingController); vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); const fixture = TestBed.createComponent(DocumentFormPage); const tick = async () => { fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); }; await tick(); ctrl.match((r) => r.url === '/api/sl/SalesTaxCodes').forEach((r) => r.flush({ value: [] })); ctrl.match((r) => r.url === '/api/sl/AdditionalExpenses').forEach((r) => r.flush({ value: [] })); ctrl.match((r) => r.url.includes('SeriesService')).forEach((r) => r.flush({ value: [] })); ctrl.expectOne('/api/sl/Orders/5').flush({ 'odata.etag': 'W/"1"', DocEntry: 5, DocNum: 5, CardCode: 'C1', CardName: 'ACME', U_Urgente: 'tNO', DocDate: '2026-09-11T00:00:00Z', DocDueDate: '2026-09-20T00:00:00Z', DocumentStatus: 'bost_Open', Cancelled: 'tNO', DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 10, TaxCode: '', LineTotal: 10 }], }); await tick(); const el = fixture.nativeElement as HTMLElement; const box = [...el.querySelectorAll('sbo-checkbox-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Urgente')!.querySelector('input[type=checkbox]') as HTMLInputElement; expect(box.checked).toBe(false); box.click(); await tick(); (el.querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); expect(ctrl.expectOne((r) => r.method === 'PATCH').request.body).toEqual({ U_Urgente: 'tYES' }); }); });