You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/packages/printing/test/s2-print.test.ts

148 lines
6.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { amountInWordsINR, type BillLine, type BillTotals } from '@sims/domain'
import {
renderInvoiceHtml, type InvoiceDoc,
ean13Modules, ean13Svg, normalizeEan13, renderLabelSheetHtml,
packRasterRow, bitonalFromRGBA, rasterToEscPos,
} from '@sims/printing'
describe('amountInWordsINR — Indian numbering', () => {
it('zero', () => {
expect(amountInWordsINR(0)).toBe('Rupees Zero Only')
})
it('paisa only', () => {
expect(amountInWordsINR(50)).toBe('Fifty Paise Only')
})
it('a single paisa is singular', () => {
expect(amountInWordsINR(1)).toBe('One Paisa Only')
})
it('one lakh rupees', () => {
expect(amountInWordsINR(1_00_000 * 100)).toBe('Rupees One Lakh Only')
})
it('one crore rupees', () => {
expect(amountInWordsINR(1_00_00_000 * 100)).toBe('Rupees One Crore Only')
})
it('mixed rupees and paise', () => {
expect(amountInWordsINR(1431_50)).toBe('Rupees One Thousand Four Hundred Thirty One and Fifty Paise Only')
})
})
const line: BillLine = {
itemId: 'i1', name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS',
unitPricePaise: 14_500, mrpPaise: 15_000, grossPaise: 14_500, discountPaise: 0,
taxablePaise: 12_288, taxRateBp: 1800, cgstPaise: 1_106, sgstPaise: 1_106,
igstPaise: 0, cessPaise: 0, lineTotalPaise: 14_500,
}
const totals: BillTotals = {
grossPaise: 14_500, discountPaise: 0, taxablePaise: 12_288, cgstPaise: 1_106,
sgstPaise: 1_106, igstPaise: 0, cessPaise: 0, roundOffPaise: 0,
payablePaise: 14_500, savingsVsMrpPaise: 500,
}
const invoice: InvoiceDoc = {
seller: { name: 'MegaMart T.Nagar', gstin: '32ABCDE1234F1Z5', stateCode: '32', address: 'T.Nagar, Chennai' },
buyer: { name: 'Lakshmi', phone: '9840012345' },
invoiceNo: 'ST1C2/26-000001', invoiceDate: '2026-07-10',
placeOfSupplyStateCode: '32', lines: [line], totals, paymentLabel: 'CASH',
}
describe('renderInvoiceHtml — A4 GST invoice', () => {
it('contains the key GST-invoice fields', () => {
const html = renderInvoiceHtml(invoice)
expect(html).toContain('Tax Invoice')
expect(html).toContain('MegaMart T.Nagar')
expect(html).toContain('32ABCDE1234F1Z5')
expect(html).toContain('ST1C2/26-000001')
expect(html).toContain('Surf Excel 1kg')
expect(html).toContain('3402') // HSN
expect(html).toContain('CGST')
expect(html).toContain('SGST')
expect(html).toContain('Place of Supply')
expect(html).toContain('Kerala') // state 32 resolved
})
it('renders the amount in words', () => {
const html = renderInvoiceHtml(invoice)
expect(html).toContain('Amount in words')
expect(html).toContain(amountInWordsINR(totals.payablePaise)) // Rupees One Hundred Forty Five Only
})
it('shows IGST (not CGST/SGST) for an inter-state bill', () => {
const inter = renderInvoiceHtml({
...invoice,
totals: { ...totals, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 },
lines: [{ ...line, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 }],
placeOfSupplyStateCode: '29',
})
expect(inter).toContain('IGST')
expect(inter).not.toContain('CGST')
})
it('is a self-contained HTML document', () => {
const html = renderInvoiceHtml(invoice)
expect(html.startsWith('<!DOCTYPE html>')).toBe(true)
expect(html).toContain('</html>')
expect(html).toContain('window.print()')
})
})
describe('ean13 encoding', () => {
it('encodes the standard 95-module structure (all-zero symbol)', () => {
const m = ean13Modules('000000000000') // 12 digits → check digit appended
expect(m).toHaveLength(95)
expect(m.startsWith('101')).toBe(true) // start guard
expect(m.endsWith('101')).toBe(true) // end guard
expect(m.slice(45, 50)).toBe('01010') // centre guard
// first digit 0 → all L; L(0) = 0001101, so the first data element follows the start guard
expect(m.startsWith('101' + '0001101')).toBe(true)
})
it('computes the check digit via ean13CheckDigit for a 12-digit body', () => {
expect(normalizeEan13('890106301435')).toBe('8901063014350')
})
it('rejects a 13-digit code with a bad checksum (strict path)', () => {
expect(() => normalizeEan13('8901063014357')).toThrow(/checksum/)
})
it('still renders an SVG for legacy 13-digit data (lenient path, R18)', () => {
const svg = ean13Svg('8901063014357')
expect(svg.startsWith('<svg')).toBe(true)
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"')
expect(svg.trimEnd().endsWith('</svg>')).toBe(true)
expect(svg).toContain('8901063014357') // human-readable matches stored code
})
})
describe('renderLabelSheetHtml', () => {
it('lays out labels with barcode, name and MRP', () => {
const html = renderLabelSheetHtml(
[{ name: 'Surf Excel 1kg', mrpPaise: 15_000, code13: '8901030704833', pricePaise: 14_500 }],
{ cols: 3, labelWmm: 38, labelHmm: 25 },
)
expect(html).toContain('Surf Excel 1kg')
expect(html).toContain('MRP')
expect(html).toContain('<svg')
expect(html).toContain('Rate') // price differs from MRP
expect(html).toContain('grid-template-columns: repeat(3')
})
it('renders text-only labels when no barcode is given', () => {
const html = renderLabelSheetHtml([{ name: 'Loose Tomato', mrpPaise: 3_200 }], { cols: 2, labelWmm: 50, labelHmm: 30 })
expect(html).toContain('Loose Tomato')
expect(html).not.toContain('<svg')
})
})
describe('raster bit packing', () => {
it('packs a byte MSB-first', () => {
expect(packRasterRow([1, 0, 1, 0, 0, 0, 0, 0])).toEqual([0xa0])
expect(packRasterRow([0, 0, 0, 0, 0, 0, 0, 1])).toEqual([0x01])
})
it('pads a partial trailing byte with zeros', () => {
expect(packRasterRow([1, 1, 1, 1, 1, 1, 1, 1, 1])).toEqual([0xff, 0x80])
})
it('thresholds RGBA to bitonal (dark = printed dot)', () => {
// two pixels: black then white
const data = [0, 0, 0, 255, 255, 255, 255, 255]
const bmp = bitonalFromRGBA(data, 2, 1)
expect([...bmp.bits]).toEqual([1, 0])
})
it('emits a GS v 0 raster command with the right header', () => {
const bytes = rasterToEscPos({ width: 8, height: 1, bits: Uint8Array.from([1, 0, 1, 0, 0, 0, 0, 0]) })
expect([...bytes]).toEqual([0x1d, 0x76, 0x30, 0, 1, 0, 1, 0, 0xa0])
})
})