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.
148 lines
6.1 KiB
TypeScript
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])
|
|
})
|
|
})
|