import { parseArticleBody, interpolateTemplateText } from './contract-article.util'; import { ContractRendererService } from './contract-renderer.service'; import { getTemplateMeta } from './contract-template.registry'; import type { ContractViewModel } from './contract-view-model.builder'; describe('parseArticleBody', () => { it('numbers each non-empty line as a clause', () => { const parsed = parseArticleBody('First clause.\nSecond clause.\n\nThird clause.'); expect(parsed.paragraph).toBeUndefined(); expect(parsed.clauses.map((c) => c.text)).toEqual([ 'First clause.', 'Second clause.', 'Third clause.', ]); }); it('nests "- " lines as bullets under the previous clause', () => { const parsed = parseArticleBody('Rates are:\n- USD 10 per ton\n- USD 20 per wagon\nPayment in advance.'); expect(parsed.clauses).toHaveLength(2); expect(parsed.clauses[0].bullets).toEqual(['USD 10 per ton', 'USD 20 per wagon']); expect(parsed.clauses[1].text).toBe('Payment in advance.'); }); it('renders a single bare line as a paragraph', () => { const parsed = parseArticleBody('This Agreement becomes effective when signed.'); expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.'); expect(parsed.clauses).toEqual([]); }); it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => { const parsed = parseArticleBody( '1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment', ); expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([ ['1', 1, 'Scope'], ['1.1', 2, 'Rail transport'], ['1.1.1', 3, 'Wagon supply'], ['2', 1, 'Payment'], ]); }); it('clamps a sub-clause with no open parent to the next available level', () => { const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.'); expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([ ['1', 1], ['2', 1], ]); }); it('leaves prose that merely starts with a number un-tokenized', () => { const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.'); expect(parsed.clauses.map((c) => c.text)).toEqual([ '10 tons is the minimum load.', 'Payment in advance.', ]); }); it('keeps a single explicitly numbered line as a clause, not a paragraph', () => { const parsed = parseArticleBody('1. Only clause.'); expect(parsed.paragraph).toBeUndefined(); expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]); }); }); describe('interpolateTemplateText', () => { it('fills placeholders from the view model', () => { expect( interpolateTemplateText('Valid until August 31, {{contractYear}}.', { contractYear: 2026, }), ).toBe('Valid until August 31, 2026.'); }); it('falls back to raw text on malformed placeholders', () => { expect(interpolateTemplateText('Broken {{#if}} tag', {})).toBe('Broken {{#if}} tag'); }); }); describe('dynamic template rendering (edr-dynamic.hbs)', () => { const renderer = new ContractRendererService(); renderer.onModuleInit(); function dynamicView(): ContractViewModel { const meta = getTemplateMeta('IMP_BULK_USD_FORWARDING'); return { bookingId: 'test-id', reference: 'EDR/CT/2026/0042', status: 'CONTRACT_READY', templateKey: 'IMP_BULK_USD_FORWARDING', template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' }, contractDate: '1 January 2026', contractYear: 2026, client: { companyName: 'Abyssinia Trading PLC', companyAddress: 'Bole Sub-city, Addis Ababa', companyLocation: 'Ethiopia', phone: '+251900000000', email: 'test@example.com', tinNumber: '1234567890', vatNumber: 'VAT-001', fanNumber: 'FAN-001', businessLicense: 'BL-001', }, provider: { name: 'Ethio-Djibouti Standard Gauge Railway Share Company', address: 'Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia', phone: '+251 11 872 0000', email: 'info@edr.gov.et', tinNumber: '—', }, schedule: { originLabel: 'Nagad', destinationLabel: 'Galaan Multipurpose Port', tradeDirection: 'IMPORT', freightType: 'BULK', serviceType: 'Rail + clearance', scheduledDate: '—', contractType: 'GENERAL', cargoDescription: 'Steel billets', totalWeightVgm: '—', equipmentReturn: '—', hazardousLabel: 'No', firstMilePickupAddress: '—', lastMileDeliveryAddress: '—', }, pricing: { displayMode: 'UNIT_RATES', unitRates: [ { label: 'Rail transport', unitPrice: 59.4, unit: 'ton', currency: 'USD' }, ], currency: 'USD', equipmentReturn: '—', originLabel: 'Nagad', destinationLabel: 'Galaan Multipurpose Port', } as unknown as ContractViewModel['pricing'], rateSchedule: { freightLanes: [ { route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' }, ], additionalServices: [ { route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' }, ], surcharges: [], isEmpty: false, currencyLabel: 'USD', }, signatures: [], canSignCustomer: false, canSignStaff: false, hasContractDocument: false, hasCustomerSignature: false, hasStaffSignature: false, dynamicTemplate: { code: 'IMPORT_BULK', name: 'Bulk Import Contract', documentTitle: 'Bulk Cargo Transportation and Customs Clearance Services', whereasClauses: ['The Client has agreed to engage the Service Provider.'], articles: [ { id: 'objective', title: 'Objective of the Services', body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance', order: 1, }, { id: 'pricing', title: 'Contract Price and Payment Terms', body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.', order: 2, }, { id: 'duration', title: 'Duration', body: 'Valid until August 31, {{contractYear}}.', order: 3, }, ], }, }; } it('renders numbered dynamic articles with bullets and interpolation', () => { const html = renderer.render(dynamicView()); expect(html).toContain('Bulk Cargo Transportation and Customs Clearance Services'); expect(html).toContain('Article 1'); expect(html).toContain('Objective of the Services'); expect(html).toContain('Rail transport to GMP'); expect(html).toContain('Valid until August 31, 2026.'); expect(html).toContain('Abyssinia Trading PLC'); expect(html).toContain('Annex A — Commercial Schedule'); // Greenish theme marker from styles.hbs expect(html).toContain('#1b9e7a'); }); it('renders the live rate schedule lane under the pricing article', () => { const html = renderer.render(dynamicView()); expect(html).toContain('Rate Schedule'); // Base freight lane pulled from the rate config expect(html).toContain('Nagad → Galaan Multipurpose Port'); expect(html).toContain('USD 100 per wagon'); // Additional-service group expect(html).toContain('First-mile pickup by truck'); }); it('keeps the generic layout when no dynamic template is attached', () => { const view = dynamicView(); delete view.dynamicTemplate; view.template = getTemplateMeta('IMP_BULK_USD_FORWARDING'); const html = renderer.render(view); expect(html).toContain('Article 5: Contract Price'); }); });