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 sub-clauses by outline token and marks each level 1. → a. → i.', () => { 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'], ['a', 2, 'Rail transport'], ['i', 3, 'Wagon supply'], ['2', 1, 'Payment'], ]); }); it('cycles markers back to arabic at depth 4 and counts each level on its own', () => { const parsed = parseArticleBody( '1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep', ); expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([ ['1', 1], ['a', 2], ['b', 2], ['i', 3], ['ii', 3], ['1', 4], ]); }); 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, contractStartDate: '1 January 2026', contractEndDate: '31 December 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', cargoTypeName: 'Steel billets', containerType: '—', cargoSummary: 'Steel billets × 2,800', 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('shows the contract validity window in the commercial schedule annex', () => { const html = renderer.render(dynamicView()); expect(html).toContain('Valid from'); expect(html).toContain('Valid until'); expect(html).toContain('1 January 2026'); expect(html).toContain('31 December 2026'); }); it('interpolates the start/end date placeholders inside article text', () => { const view = dynamicView(); expect( interpolateTemplateText( 'In force {{contractStartDate}} to {{contractEndDate}}.', view, ), ).toBe('In force 1 January 2026 to 31 December 2026.'); }); it('shows cargo type and container type in the commercial schedule annex', () => { const html = renderer.render(dynamicView()); expect(html).toContain('Cargo type'); expect(html).toContain('Container type'); expect(html).toContain('Cargo scope'); expect(html).toContain('Steel billets × 2,800'); }); it('interpolates the cargo/container placeholders inside article text', () => { const view = dynamicView(); const body = 'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' + '({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.'; expect(interpolateTemplateText(body, view)).toBe( 'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.', ); }); 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'); }); });