import assert from 'node:assert/strict'; import test from 'node:test'; import type { Template } from '@pdfme/common'; import type { ResolvedDocumentTemplate } from '@/features/foundation/document-template/types'; import type { QuotationDocumentData } from '../types'; import { resolveTemplatePages } from './page-resolver'; import { buildProductItemSectionModel } from './product-item-engine'; import { createRenderContext } from './render-context'; import { createProductItemSectionBuilder, getQuotationSectionBuilders } from './quotation-pdf-runtime'; import type { ProductItemSection, RenderPolicy, ResolvedTemplate } from './runtime-contracts'; import { composeSections } from './section-composer'; import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter'; function createField(name: string, y: number, height = 7) { return { name, type: 'text', position: { x: 10, y }, width: 100, height, content: `{${name}}`, }; } function createLegacyTemplate(): Template { return { basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] }, schemas: [ [ createField('customer_name', 20), createField('quotation_price_data', 40, 20), ], [ createField('topic', 35), createField('data_topic', 45, 7), createField('Please_do_not', 225), createField('yours_faithfuly', 232), ], ], } as unknown as Template; } function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTemplate { return { template: { id: 'template-1', organizationId: 'org-1', documentType: 'quotation', productType: 'default', fileType: 'pdfme', templateName: 'Legacy quotation', description: null, isDefault: true, isActive: true, createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }, version: { id: 'template-version-1', templateId: 'template-1', versionNumber: 1, schemaJson: schema, createdAt: '2026-06-29T00:00:00.000Z', updatedAt: '2026-06-29T00:00:00.000Z', }, } as unknown as ResolvedDocumentTemplate; } function createItem( index: number, overrides: Partial = {}, ): QuotationDocumentData['items'][number] { return { id: `item-${index}`, itemNumber: index + 1, productTypeCode: null, productTypeLabel: null, description: `Product item ${index + 1}`, quantity: 1, unitCode: 'EA', unitLabel: 'EA', unitPrice: 1000 + index, discount: 0, discountTypeCode: null, taxRate: 0, totalPrice: 1000 + index, notes: null, ...overrides, }; } function createDocumentData(items: QuotationDocumentData['items']): QuotationDocumentData { return { company: { id: 'company-1', name: 'ALLA', slug: 'alla', imageUrl: null, plan: 'pro', }, branch: null, customer: { id: 'customer-1', code: 'C-001', name: 'Customer', address: null, phone: null, email: null, taxId: null, }, contact: null, quotation: { id: 'quotation-1', code: 'QT-TEST', quotationDate: '2026-06-29', validUntil: null, projectName: null, projectLocation: null, attention: null, reference: null, revision: 0, revisionLabel: 'Rev.0', statusCode: 'approved', statusLabel: 'Approved', quotationTypeCode: null, quotationTypeLabel: null, currency: 'THB', currencyCode: 'THB', currencyLabel: 'Thai Baht', exchangeRate: 1, subtotal: 0, discount: 0, discountTypeCode: null, discountTypeLabel: null, taxRate: 0, taxAmount: 0, totalAmount: 0, notes: null, }, items, quotationCustomers: [], topics: { scope: [], exclusion: [], payment: [], warranty: [], delivery: [], other: [], all: [], }, pdfme: { labels: { tel: 'Tel', email: 'Email', att: 'Att', project: 'Project', location: 'Location', }, quotation_date: '2026-06-29', quotation_price: 'THB 0.00', quotation_price_data: [], exclusion_data: [], topic_inputs: {}, }, approval: { requestId: null, workflowName: null, status: 'approved', approvedAt: '2026-06-29T00:00:00.000Z', currentStep: null, currentStepRoleName: null, approvers: [], }, signatures: { preparedBy: { name: 'Prepared', position: 'Sales' }, approvedBy: { name: 'Approved', position: 'Manager' }, authorizedBy: { name: 'Authorized', position: 'Director' }, }, watermarkStatus: null, }; } function createRuntimeContext(items: QuotationDocumentData['items']) { const schema = createLegacyTemplate(); const template = createResolvedDocumentTemplate(schema); const runtimeTemplate: ResolvedTemplate = { documentTemplate: template, schema, }; const pages = resolveTemplatePages(adaptTemplateForSectionRuntime(runtimeTemplate)); const policies: RenderPolicy[] = [ { section: 'customer', enabled: true, required: true, visibleWhenEmpty: true }, { section: 'product_items', enabled: true, required: false, visibleWhenEmpty: false }, { section: 'topics', enabled: true, required: true, visibleWhenEmpty: true }, { section: 'conditions', enabled: false, required: false, visibleWhenEmpty: false }, { section: 'signature', enabled: false, required: false, visibleWhenEmpty: false }, { section: 'attachments', enabled: false, required: false, visibleWhenEmpty: false }, { section: 'appendix', enabled: false, required: false, visibleWhenEmpty: false }, { section: 'warranty', enabled: false, required: false, visibleWhenEmpty: false }, { section: 'cover', enabled: false, required: false, visibleWhenEmpty: false }, ]; return createRenderContext({ documentData: createDocumentData(items), template: runtimeTemplate, mappings: [], pages, policies, issues: pages.issues, }); } test('product item engine returns empty section model when quotation has no items', () => { const built = buildProductItemSectionModel({ items: [], currencyCode: 'THB' }); assert.equal(built.rows.length, 0); assert.deepEqual(built.tableModel.headers, [ 'Item', 'Description', 'Qty', 'Unit', 'Unit Price', 'Discount', 'Total', ]); assert.equal(built.pagination.estimatedPages, 0); assert.deepEqual(built.issues, []); }); test('product item engine formats one row with decimal quantity and discount', () => { const built = buildProductItemSectionModel({ items: [ createItem(0, { quantity: 2.5, unitPrice: 1234.5, discount: 50, totalPrice: 3036.25, }), ], currencyCode: 'THB', }); assert.equal(built.rows.length, 1); assert.equal(built.rows[0]?.quantity, '2.5'); assert.equal(built.rows[0]?.unitPrice, 'THB 1,234.50'); assert.equal(built.rows[0]?.discount, 'THB 50.00'); assert.equal(built.rows[0]?.total, 'THB 3,036.25'); assert.deepEqual(built.tableModel.rows[0], [ '1', 'Product item 1', '2.5', 'EA', 'THB 1,234.50', 'THB 50.00', 'THB 3,036.25', ]); }); test('product item engine supports 10 and 100 items with pagination metadata', () => { const tenItems = buildProductItemSectionModel({ items: Array.from({ length: 10 }, (_, index) => createItem(index)), currencyCode: 'THB', }); const hundredItems = buildProductItemSectionModel({ items: Array.from({ length: 100 }, (_, index) => createItem(index)), currencyCode: 'THB', }); assert.equal(tenItems.rows.length, 10); assert.equal(tenItems.pagination.rowCount, 10); assert.equal(tenItems.pagination.estimatedPages, 1); assert.equal(hundredItems.rows.length, 100); assert.ok(hundredItems.pagination.estimatedPages > 1); assert.equal( hundredItems.pagination.rowsPerPage.reduce((sum, value) => sum + value, 0), 100, ); }); test('product item engine increases estimated row height for long descriptions and allows missing optionals', () => { const longDescription = Array.from({ length: 40 }, () => 'long-description').join(' '); const built = buildProductItemSectionModel({ items: [ createItem(0, { description: longDescription, unitLabel: null, notes: 'extra note', }), ], currencyCode: 'THB', }); assert.ok((built.rows[0]?.estimatedLineCount ?? 0) > 1); assert.ok((built.rows[0]?.estimatedRowHeight ?? 0) > 24); assert.equal(built.rows[0]?.unit, '-'); assert.match(built.rows[0]?.description ?? '', /extra note/); }); test('product item engine reports invalid quantity, price, description, and total as runtime issues', () => { const built = buildProductItemSectionModel({ items: [ createItem(0, { description: '', quantity: Number.NaN, unitPrice: Number.POSITIVE_INFINITY, totalPrice: Number.NaN, }), ], currencyCode: 'THB', }); assert.deepEqual( built.issues.map((issue) => issue.code).sort(), [ 'INVALID_PRODUCT_ITEM_PRICE', 'INVALID_PRODUCT_ITEM_QUANTITY', 'INVALID_PRODUCT_ITEM_TOTAL', 'MISSING_PRODUCT_ITEM_DESCRIPTION', ], ); }); test('section composer accepts product item builder and existing sections remain available', async () => { const context = createRuntimeContext([ createItem(0), createItem(1, { discount: 25, totalPrice: 1976 }), ]); const section = (await createProductItemSectionBuilder().build(context)) as ProductItemSection; assert.equal(section.rendered, false); assert.equal(section.anchorPageIndex, null); assert.equal(section.rows.length, 2); const composed = await composeSections(context, getQuotationSectionBuilders()); const sectionRoles = composed.sections.map((item) => item.role); assert.deepEqual(sectionRoles, ['customer', 'product_items', 'topics']); const productItemsSection = composed.sections.find( (item): item is ProductItemSection => item.role === 'product_items', ); assert.ok(productItemsSection); assert.equal(productItemsSection.tableModel.rows.length, 2); assert.ok(composed.sections.some((item) => item.role === 'customer')); assert.ok(composed.sections.some((item) => item.role === 'topics')); });