1538 lines
70 KiB
JavaScript
Executable File
1538 lines
70 KiB
JavaScript
Executable File
/**
|
|
* Property-Based Tests for Printing Operations
|
|
*
|
|
* Tests correctness properties related to receipt printing and test print functionality
|
|
* using fast-check for property-based testing.
|
|
*/
|
|
|
|
import * as fc from 'fast-check';
|
|
import { EscPosGenerator } from '../js/escpos_generator.js';
|
|
import {
|
|
BluetoothPrinterManager,
|
|
TimeoutError,
|
|
PrinterNotConnectedError,
|
|
TransmissionError,
|
|
PrinterBusyError
|
|
} from '../js/bluetooth_printer_manager.js';
|
|
import { BluetoothPrinterStorage } from '../js/storage_manager.js';
|
|
|
|
describe('Printing Properties', () => {
|
|
let generator;
|
|
let bluetoothManager;
|
|
let storageManager;
|
|
|
|
beforeEach(() => {
|
|
// Clear localStorage before each test
|
|
localStorage.clear();
|
|
|
|
// Initialize generator
|
|
generator = new EscPosGenerator();
|
|
bluetoothManager = new BluetoothPrinterManager();
|
|
storageManager = new BluetoothPrinterStorage();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up after each test
|
|
localStorage.clear();
|
|
});
|
|
|
|
/**
|
|
* Generate test receipt data (extracted from BluetoothPrinterConfig)
|
|
* This is the same implementation as _generateTestReceiptData in bluetooth_printer_config.js
|
|
* @returns {Object} Test receipt data
|
|
*/
|
|
const generateTestReceiptData = () => {
|
|
return {
|
|
headerData: {
|
|
companyName: 'TEST RECEIPT',
|
|
address: '123 Test Street, Test City',
|
|
phone: 'Tel: +1 234 567 8900',
|
|
taxId: 'Tax ID: TEST123456'
|
|
},
|
|
orderData: {
|
|
orderName: 'TEST-001',
|
|
date: new Date().toLocaleString(),
|
|
cashier: 'Test Cashier',
|
|
customer: 'Test Customer'
|
|
},
|
|
lines: [
|
|
{
|
|
productName: 'Test Product 1',
|
|
quantity: 2,
|
|
price: 10.00,
|
|
total: 20.00
|
|
},
|
|
{
|
|
productName: 'Test Product 2',
|
|
quantity: 1,
|
|
price: 15.50,
|
|
total: 15.50
|
|
},
|
|
{
|
|
productName: 'Test Product 3',
|
|
quantity: 3,
|
|
price: 5.00,
|
|
total: 15.00
|
|
}
|
|
],
|
|
totals: {
|
|
subtotal: 50.50,
|
|
tax: 5.05,
|
|
discount: 0,
|
|
total: 55.55
|
|
},
|
|
paymentData: {
|
|
method: 'Cash',
|
|
amount: 60.00,
|
|
change: 4.45
|
|
},
|
|
footerData: {
|
|
message: 'Thank you for your purchase!\nThis is a test receipt.',
|
|
barcode: null
|
|
}
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Helper function to validate receipt data structure
|
|
* @param {Object} receiptData - Receipt data to validate
|
|
* @returns {boolean} True if valid
|
|
*/
|
|
const isValidReceiptData = (receiptData) => {
|
|
if (!receiptData || typeof receiptData !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
// Check required sections exist
|
|
const requiredSections = ['headerData', 'orderData', 'lines', 'totals', 'paymentData', 'footerData'];
|
|
for (const section of requiredSections) {
|
|
if (!receiptData[section]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check headerData has required fields
|
|
if (!receiptData.headerData.companyName ||
|
|
!receiptData.headerData.address ||
|
|
!receiptData.headerData.phone ||
|
|
!receiptData.headerData.taxId) {
|
|
return false;
|
|
}
|
|
|
|
// Check orderData has required fields
|
|
if (!receiptData.orderData.orderName ||
|
|
!receiptData.orderData.date ||
|
|
!receiptData.orderData.cashier) {
|
|
return false;
|
|
}
|
|
|
|
// Check lines is an array with at least one item
|
|
if (!Array.isArray(receiptData.lines) || receiptData.lines.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Check each line has required fields
|
|
for (const line of receiptData.lines) {
|
|
if (!line.productName ||
|
|
typeof line.quantity !== 'number' ||
|
|
typeof line.price !== 'number' ||
|
|
typeof line.total !== 'number') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check totals has required fields
|
|
if (typeof receiptData.totals.subtotal !== 'number' ||
|
|
typeof receiptData.totals.tax !== 'number' ||
|
|
typeof receiptData.totals.discount !== 'number' ||
|
|
typeof receiptData.totals.total !== 'number') {
|
|
return false;
|
|
}
|
|
|
|
// Check paymentData has required fields
|
|
if (!receiptData.paymentData.method ||
|
|
typeof receiptData.paymentData.amount !== 'number' ||
|
|
typeof receiptData.paymentData.change !== 'number') {
|
|
return false;
|
|
}
|
|
|
|
// Check footerData exists (message and barcode can be null/empty)
|
|
if (!receiptData.footerData || typeof receiptData.footerData.message !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Helper function to check if ESC/POS data is valid
|
|
* @param {Uint8Array} escposData - ESC/POS command data
|
|
* @returns {boolean} True if valid
|
|
*/
|
|
const isValidEscPosData = (escposData) => {
|
|
if (!(escposData instanceof Uint8Array)) {
|
|
return false;
|
|
}
|
|
|
|
if (escposData.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Check for initialization command (ESC @ = 0x1B 0x40)
|
|
let hasInit = false;
|
|
for (let i = 0; i < escposData.length - 1; i++) {
|
|
if (escposData[i] === 0x1B && escposData[i + 1] === 0x40) {
|
|
hasInit = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check for paper cut command (GS V 0 = 0x1D 0x56 0x00)
|
|
let hasCut = false;
|
|
for (let i = 0; i < escposData.length - 2; i++) {
|
|
if (escposData[i] === 0x1D && escposData[i + 1] === 0x56 && escposData[i + 2] === 0x00) {
|
|
hasCut = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return hasInit && hasCut;
|
|
};
|
|
|
|
/**
|
|
* Feature: pos-bluetooth-thermal-printer, Property 6: Receipt printing triggers on sale completion
|
|
*
|
|
* Property: For any completed sale with confirmed payment, receipt data should be
|
|
* sent to the bluetooth printer
|
|
*
|
|
* Validates: Requirements 3.1
|
|
*/
|
|
test('Property 6: Receipt printing triggers on sale completion', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
// Generate random receipt data representing a completed sale
|
|
fc.record({
|
|
headerData: fc.record({
|
|
companyName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
address: fc.string({ minLength: 1, maxLength: 200 }),
|
|
phone: fc.string({ minLength: 1, maxLength: 20 }),
|
|
taxId: fc.string({ minLength: 1, maxLength: 50 })
|
|
}),
|
|
orderData: fc.record({
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
date: fc.integer({ min: Date.parse('2020-01-01'), max: Date.parse('2030-12-31') }).map(ts => new Date(ts).toISOString()),
|
|
cashier: fc.string({ minLength: 1, maxLength: 50 }),
|
|
customer: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: null })
|
|
}),
|
|
lines: fc.array(
|
|
fc.record({
|
|
productName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
quantity: fc.float({ min: Math.fround(0.01), max: Math.fround(1000), noNaN: true }),
|
|
price: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
{ minLength: 1, maxLength: 50 }
|
|
),
|
|
totals: fc.record({
|
|
subtotal: fc.float({ min: Math.fround(0), max: Math.fround(1000000), noNaN: true }),
|
|
tax: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
discount: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0), max: Math.fround(1000000), noNaN: true })
|
|
}),
|
|
paymentData: fc.record({
|
|
method: fc.constantFrom('cash', 'card', 'mobile', 'check'),
|
|
amount: fc.float({ min: Math.fround(0), max: Math.fround(1000000), noNaN: true }),
|
|
change: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
footerData: fc.record({
|
|
message: fc.string({ maxLength: 200 }),
|
|
barcode: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null })
|
|
})
|
|
}),
|
|
(receiptData) => {
|
|
// Verify the receipt data is valid (represents a completed sale)
|
|
expect(isValidReceiptData(receiptData)).toBe(true);
|
|
|
|
// Verify payment is confirmed (payment data exists and has valid amount)
|
|
expect(receiptData.paymentData).toBeDefined();
|
|
expect(receiptData.paymentData.method).toBeTruthy();
|
|
expect(receiptData.paymentData.amount).toBeGreaterThanOrEqual(0);
|
|
|
|
// Verify the sale has at least one line item
|
|
expect(receiptData.lines.length).toBeGreaterThan(0);
|
|
|
|
// Verify the total is calculated
|
|
expect(receiptData.totals.total).toBeGreaterThanOrEqual(0);
|
|
|
|
// When a sale is completed with confirmed payment,
|
|
// the receipt data should be convertible to ESC/POS format
|
|
// (this simulates the print triggering process)
|
|
const escposData = generator.generateReceipt(receiptData);
|
|
|
|
// Verify ESC/POS data was generated (print was triggered)
|
|
expect(escposData).toBeDefined();
|
|
expect(escposData).toBeInstanceOf(Uint8Array);
|
|
expect(escposData.length).toBeGreaterThan(0);
|
|
|
|
// Verify the ESC/POS data is valid (contains init and cut commands)
|
|
expect(isValidEscPosData(escposData)).toBe(true);
|
|
|
|
// Verify all required receipt sections are present in the data
|
|
// (ensures complete receipt is sent to printer)
|
|
expect(receiptData.headerData).toBeDefined();
|
|
expect(receiptData.orderData).toBeDefined();
|
|
expect(receiptData.lines).toBeDefined();
|
|
expect(receiptData.totals).toBeDefined();
|
|
expect(receiptData.paymentData).toBeDefined();
|
|
expect(receiptData.footerData).toBeDefined();
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Receipt printing is triggered only for completed sales
|
|
*
|
|
* Tests that receipt printing requires both line items and payment confirmation
|
|
*/
|
|
test('Property 6a: Receipt printing requires completed sale with payment', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.record({
|
|
hasLines: fc.boolean(),
|
|
hasPayment: fc.boolean(),
|
|
receiptData: fc.record({
|
|
headerData: fc.record({
|
|
companyName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
address: fc.string({ minLength: 1, maxLength: 200 }),
|
|
phone: fc.string({ minLength: 1, maxLength: 20 }),
|
|
taxId: fc.string({ minLength: 1, maxLength: 50 })
|
|
}),
|
|
orderData: fc.record({
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
date: fc.integer({ min: Date.parse('2020-01-01'), max: Date.parse('2030-12-31') }).map(ts => new Date(ts).toISOString()),
|
|
cashier: fc.string({ minLength: 1, maxLength: 50 }),
|
|
customer: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: null })
|
|
}),
|
|
lines: fc.array(
|
|
fc.record({
|
|
productName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
quantity: fc.float({ min: Math.fround(0.01), max: Math.fround(1000), noNaN: true }),
|
|
price: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
{ minLength: 1, maxLength: 10 }
|
|
),
|
|
totals: fc.record({
|
|
subtotal: fc.float({ min: Math.fround(0), max: Math.fround(1000000), noNaN: true }),
|
|
tax: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
discount: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0), max: Math.fround(1000000), noNaN: true })
|
|
}),
|
|
paymentData: fc.record({
|
|
method: fc.constantFrom('cash', 'card', 'mobile'),
|
|
amount: fc.float({ min: Math.fround(0), max: Math.fround(1000000), noNaN: true }),
|
|
change: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
footerData: fc.record({
|
|
message: fc.string({ maxLength: 200 }),
|
|
barcode: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null })
|
|
})
|
|
})
|
|
}),
|
|
({ hasLines, hasPayment, receiptData }) => {
|
|
// Modify receipt data based on flags
|
|
const modifiedReceipt = { ...receiptData };
|
|
|
|
if (!hasLines) {
|
|
modifiedReceipt.lines = [];
|
|
}
|
|
|
|
if (!hasPayment) {
|
|
modifiedReceipt.paymentData = {
|
|
method: '',
|
|
amount: 0,
|
|
change: 0
|
|
};
|
|
}
|
|
|
|
// A sale is only complete if it has both lines and payment
|
|
const isCompleteSale = hasLines && hasPayment;
|
|
|
|
if (isCompleteSale) {
|
|
// For complete sales, receipt should be valid and printable
|
|
expect(isValidReceiptData(modifiedReceipt)).toBe(true);
|
|
|
|
const escposData = generator.generateReceipt(modifiedReceipt);
|
|
expect(escposData).toBeInstanceOf(Uint8Array);
|
|
expect(escposData.length).toBeGreaterThan(0);
|
|
} else {
|
|
// For incomplete sales, receipt data should be invalid
|
|
expect(isValidReceiptData(modifiedReceipt)).toBe(false);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Receipt printing includes all sale information
|
|
*
|
|
* Tests that when printing is triggered, all sale information is included
|
|
*/
|
|
test('Property 6b: Receipt printing includes complete sale information', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.record({
|
|
headerData: fc.record({
|
|
companyName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
address: fc.string({ minLength: 1, maxLength: 200 }),
|
|
phone: fc.string({ minLength: 1, maxLength: 20 }),
|
|
taxId: fc.string({ minLength: 1, maxLength: 50 })
|
|
}),
|
|
orderData: fc.record({
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
date: fc.integer({ min: Date.parse('2020-01-01'), max: Date.parse('2030-12-31') }).map(ts => new Date(ts).toISOString()),
|
|
cashier: fc.string({ minLength: 1, maxLength: 50 }),
|
|
customer: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: null })
|
|
}),
|
|
lines: fc.array(
|
|
fc.record({
|
|
productName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
quantity: fc.float({ min: Math.fround(0.01), max: Math.fround(1000), noNaN: true }),
|
|
price: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
{ minLength: 1, maxLength: 20 }
|
|
),
|
|
totals: fc.record({
|
|
subtotal: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
tax: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
discount: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true })
|
|
}),
|
|
paymentData: fc.record({
|
|
method: fc.constantFrom('cash', 'card', 'mobile'),
|
|
amount: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
change: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
footerData: fc.record({
|
|
message: fc.string({ maxLength: 200 }),
|
|
barcode: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null })
|
|
})
|
|
}),
|
|
(receiptData) => {
|
|
// Verify this is a valid completed sale
|
|
expect(isValidReceiptData(receiptData)).toBe(true);
|
|
|
|
// Generate ESC/POS data (simulating print trigger)
|
|
const escposData = generator.generateReceipt(receiptData);
|
|
expect(escposData).toBeInstanceOf(Uint8Array);
|
|
|
|
// Convert to string to check content inclusion
|
|
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
const receiptText = decoder.decode(escposData);
|
|
|
|
// Verify company information is included
|
|
// (ESC/POS commands may be interspersed, so we check for presence)
|
|
expect(receiptText.includes(receiptData.headerData.companyName) ||
|
|
escposData.length > 100).toBe(true);
|
|
|
|
// Verify order information is present in the data structure
|
|
expect(receiptData.orderData.orderName).toBeTruthy();
|
|
expect(receiptData.orderData.cashier).toBeTruthy();
|
|
|
|
// Verify all line items are present
|
|
expect(receiptData.lines.length).toBeGreaterThan(0);
|
|
receiptData.lines.forEach(line => {
|
|
expect(line.productName).toBeTruthy();
|
|
expect(line.quantity).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Verify totals are present
|
|
expect(receiptData.totals.total).toBeGreaterThan(0);
|
|
|
|
// Verify payment information is present
|
|
expect(receiptData.paymentData.method).toBeTruthy();
|
|
expect(receiptData.paymentData.amount).toBeGreaterThan(0);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Feature: pos-bluetooth-thermal-printer, Property 10: Test print generates sample data
|
|
*
|
|
* Property: For any test print request, the system should generate a valid sample
|
|
* receipt with test data
|
|
*
|
|
* Validates: Requirements 4.3
|
|
*/
|
|
test('Property 10: Test print generates sample data', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 1, max: 100 }), // Number of test iterations
|
|
(iteration) => {
|
|
// Generate test receipt data
|
|
const testReceiptData = generateTestReceiptData();
|
|
|
|
// Verify the test receipt data is valid
|
|
expect(isValidReceiptData(testReceiptData)).toBe(true);
|
|
|
|
// Verify headerData contains test data
|
|
expect(testReceiptData.headerData.companyName).toBeTruthy();
|
|
expect(testReceiptData.headerData.companyName.length).toBeGreaterThan(0);
|
|
expect(testReceiptData.headerData.address).toBeTruthy();
|
|
expect(testReceiptData.headerData.phone).toBeTruthy();
|
|
expect(testReceiptData.headerData.taxId).toBeTruthy();
|
|
|
|
// Verify orderData contains test data
|
|
expect(testReceiptData.orderData.orderName).toBeTruthy();
|
|
expect(testReceiptData.orderData.date).toBeTruthy();
|
|
expect(testReceiptData.orderData.cashier).toBeTruthy();
|
|
|
|
// Verify lines array has at least one item
|
|
expect(testReceiptData.lines.length).toBeGreaterThan(0);
|
|
|
|
// Verify each line has valid data
|
|
testReceiptData.lines.forEach(line => {
|
|
expect(line.productName).toBeTruthy();
|
|
expect(line.productName.length).toBeGreaterThan(0);
|
|
expect(line.quantity).toBeGreaterThan(0);
|
|
expect(line.price).toBeGreaterThanOrEqual(0);
|
|
expect(line.total).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
// Verify totals are valid numbers
|
|
expect(testReceiptData.totals.subtotal).toBeGreaterThanOrEqual(0);
|
|
expect(testReceiptData.totals.tax).toBeGreaterThanOrEqual(0);
|
|
expect(testReceiptData.totals.discount).toBeGreaterThanOrEqual(0);
|
|
expect(testReceiptData.totals.total).toBeGreaterThanOrEqual(0);
|
|
|
|
// Verify paymentData is valid
|
|
expect(testReceiptData.paymentData.method).toBeTruthy();
|
|
expect(testReceiptData.paymentData.amount).toBeGreaterThanOrEqual(0);
|
|
expect(testReceiptData.paymentData.change).toBeGreaterThanOrEqual(0);
|
|
|
|
// Verify footerData exists
|
|
expect(testReceiptData.footerData).toBeDefined();
|
|
expect(typeof testReceiptData.footerData.message).toBe('string');
|
|
|
|
// Verify the test receipt can be converted to ESC/POS
|
|
const escposData = generator.generateReceipt(testReceiptData);
|
|
expect(isValidEscPosData(escposData)).toBe(true);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Test receipt data is consistent across multiple generations
|
|
*
|
|
* Tests that generating test receipt data multiple times produces consistent structure
|
|
* (though values like date may differ)
|
|
*/
|
|
test('Property 10a: Test receipt data structure is consistent', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 2, max: 10 }), // Number of receipts to generate
|
|
(count) => {
|
|
const receipts = [];
|
|
for (let i = 0; i < count; i++) {
|
|
receipts.push(generateTestReceiptData());
|
|
}
|
|
|
|
// Verify all receipts have the same structure
|
|
for (let i = 1; i < receipts.length; i++) {
|
|
const receipt = receipts[i];
|
|
const firstReceipt = receipts[0];
|
|
|
|
// Check same number of lines
|
|
expect(receipt.lines.length).toBe(firstReceipt.lines.length);
|
|
|
|
// Check same structure in all sections
|
|
expect(Object.keys(receipt.headerData).sort()).toEqual(
|
|
Object.keys(firstReceipt.headerData).sort()
|
|
);
|
|
expect(Object.keys(receipt.orderData).sort()).toEqual(
|
|
Object.keys(firstReceipt.orderData).sort()
|
|
);
|
|
expect(Object.keys(receipt.totals).sort()).toEqual(
|
|
Object.keys(firstReceipt.totals).sort()
|
|
);
|
|
expect(Object.keys(receipt.paymentData).sort()).toEqual(
|
|
Object.keys(firstReceipt.paymentData).sort()
|
|
);
|
|
expect(Object.keys(receipt.footerData).sort()).toEqual(
|
|
Object.keys(firstReceipt.footerData).sort()
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Test receipt contains sample/test indicators
|
|
*
|
|
* Tests that test receipts are clearly marked as test data
|
|
*/
|
|
test('Property 10b: Test receipt contains test indicators', () => {
|
|
const testReceiptData = generateTestReceiptData();
|
|
|
|
// Check that test indicators are present in the data
|
|
const dataString = JSON.stringify(testReceiptData).toLowerCase();
|
|
|
|
// Should contain "test" somewhere in the data
|
|
expect(dataString).toContain('test');
|
|
|
|
// Verify specific test markers
|
|
expect(testReceiptData.headerData.companyName.toLowerCase()).toContain('test');
|
|
});
|
|
|
|
/**
|
|
* Property: Test receipt can be successfully converted to ESC/POS
|
|
*
|
|
* Tests that test receipt data can always be converted to valid ESC/POS commands
|
|
*/
|
|
test('Property 10c: Test receipt converts to valid ESC/POS', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 1, max: 50 }),
|
|
(iteration) => {
|
|
// Generate test receipt
|
|
const testReceiptData = generateTestReceiptData();
|
|
|
|
// Convert to ESC/POS
|
|
const escposData = generator.generateReceipt(testReceiptData);
|
|
|
|
// Verify it's valid ESC/POS data
|
|
expect(escposData).toBeInstanceOf(Uint8Array);
|
|
expect(escposData.length).toBeGreaterThan(0);
|
|
expect(isValidEscPosData(escposData)).toBe(true);
|
|
|
|
// Verify all bytes are valid (0-255)
|
|
for (let i = 0; i < escposData.length; i++) {
|
|
expect(escposData[i]).toBeGreaterThanOrEqual(0);
|
|
expect(escposData[i]).toBeLessThanOrEqual(255);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Test receipt has realistic data ranges
|
|
*
|
|
* Tests that test receipt data uses realistic values for quantities, prices, etc.
|
|
*/
|
|
test('Property 10d: Test receipt has realistic data ranges', () => {
|
|
const testReceiptData = generateTestReceiptData();
|
|
|
|
// Check quantities are reasonable (typically 1-10 for test data)
|
|
testReceiptData.lines.forEach(line => {
|
|
expect(line.quantity).toBeGreaterThan(0);
|
|
expect(line.quantity).toBeLessThan(1000); // Reasonable upper bound
|
|
});
|
|
|
|
// Check prices are reasonable
|
|
testReceiptData.lines.forEach(line => {
|
|
expect(line.price).toBeGreaterThan(0);
|
|
expect(line.price).toBeLessThan(1000000); // Reasonable upper bound
|
|
});
|
|
|
|
// Check totals are consistent with lines
|
|
const calculatedSubtotal = testReceiptData.lines.reduce((sum, line) => sum + line.total, 0);
|
|
|
|
// Allow for small floating point differences
|
|
expect(Math.abs(testReceiptData.totals.subtotal - calculatedSubtotal)).toBeLessThan(0.01);
|
|
|
|
// Check payment amount is sufficient
|
|
expect(testReceiptData.paymentData.amount).toBeGreaterThanOrEqual(testReceiptData.totals.total);
|
|
|
|
// Check change is correct
|
|
const expectedChange = testReceiptData.paymentData.amount - testReceiptData.totals.total;
|
|
expect(Math.abs(testReceiptData.paymentData.change - expectedChange)).toBeLessThan(0.01);
|
|
});
|
|
|
|
/**
|
|
* Property: Test receipt includes all required sections
|
|
*
|
|
* Tests that test receipt always includes all mandatory sections
|
|
*/
|
|
test('Property 10e: Test receipt includes all required sections', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 1, max: 50 }),
|
|
(iteration) => {
|
|
const testReceiptData = generateTestReceiptData();
|
|
|
|
// Verify all required sections are present
|
|
expect(testReceiptData).toHaveProperty('headerData');
|
|
expect(testReceiptData).toHaveProperty('orderData');
|
|
expect(testReceiptData).toHaveProperty('lines');
|
|
expect(testReceiptData).toHaveProperty('totals');
|
|
expect(testReceiptData).toHaveProperty('paymentData');
|
|
expect(testReceiptData).toHaveProperty('footerData');
|
|
|
|
// Verify headerData fields
|
|
expect(testReceiptData.headerData).toHaveProperty('companyName');
|
|
expect(testReceiptData.headerData).toHaveProperty('address');
|
|
expect(testReceiptData.headerData).toHaveProperty('phone');
|
|
expect(testReceiptData.headerData).toHaveProperty('taxId');
|
|
|
|
// Verify orderData fields
|
|
expect(testReceiptData.orderData).toHaveProperty('orderName');
|
|
expect(testReceiptData.orderData).toHaveProperty('date');
|
|
expect(testReceiptData.orderData).toHaveProperty('cashier');
|
|
expect(testReceiptData.orderData).toHaveProperty('customer');
|
|
|
|
// Verify lines structure
|
|
expect(Array.isArray(testReceiptData.lines)).toBe(true);
|
|
expect(testReceiptData.lines.length).toBeGreaterThan(0);
|
|
|
|
testReceiptData.lines.forEach(line => {
|
|
expect(line).toHaveProperty('productName');
|
|
expect(line).toHaveProperty('quantity');
|
|
expect(line).toHaveProperty('price');
|
|
expect(line).toHaveProperty('total');
|
|
});
|
|
|
|
// Verify totals fields
|
|
expect(testReceiptData.totals).toHaveProperty('subtotal');
|
|
expect(testReceiptData.totals).toHaveProperty('tax');
|
|
expect(testReceiptData.totals).toHaveProperty('discount');
|
|
expect(testReceiptData.totals).toHaveProperty('total');
|
|
|
|
// Verify paymentData fields
|
|
expect(testReceiptData.paymentData).toHaveProperty('method');
|
|
expect(testReceiptData.paymentData).toHaveProperty('amount');
|
|
expect(testReceiptData.paymentData).toHaveProperty('change');
|
|
|
|
// Verify footerData fields
|
|
expect(testReceiptData.footerData).toHaveProperty('message');
|
|
expect(testReceiptData.footerData).toHaveProperty('barcode');
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Feature: pos-bluetooth-thermal-printer, Property 9: Fallback on print failure
|
|
*
|
|
* Property: For any print operation that fails (timeout, error, or disconnection),
|
|
* the system should automatically trigger the browser print dialog
|
|
*
|
|
* Validates: Requirements 3.4, 8.2, 8.3, 8.4
|
|
*/
|
|
test('Property 9: Fallback on print failure', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
// Generate different failure scenarios
|
|
fc.record({
|
|
failureType: fc.constantFrom('timeout', 'disconnection', 'transmission_error', 'printer_busy'),
|
|
receiptData: fc.record({
|
|
headerData: fc.record({
|
|
companyName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
address: fc.string({ minLength: 1, maxLength: 200 }),
|
|
phone: fc.string({ minLength: 1, maxLength: 20 }),
|
|
taxId: fc.string({ minLength: 1, maxLength: 50 })
|
|
}),
|
|
orderData: fc.record({
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
date: fc.integer({ min: Date.parse('2020-01-01'), max: Date.parse('2030-12-31') }).map(ts => new Date(ts).toISOString()),
|
|
cashier: fc.string({ minLength: 1, maxLength: 50 }),
|
|
customer: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: null })
|
|
}),
|
|
lines: fc.array(
|
|
fc.record({
|
|
productName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
quantity: fc.float({ min: Math.fround(0.01), max: Math.fround(1000), noNaN: true }),
|
|
price: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
{ minLength: 1, maxLength: 10 }
|
|
),
|
|
totals: fc.record({
|
|
subtotal: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
tax: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
discount: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true })
|
|
}),
|
|
paymentData: fc.record({
|
|
method: fc.constantFrom('cash', 'card', 'mobile'),
|
|
amount: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
change: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
footerData: fc.record({
|
|
message: fc.string({ maxLength: 200 }),
|
|
barcode: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null })
|
|
})
|
|
})
|
|
}),
|
|
async ({ failureType, receiptData }) => {
|
|
// Verify receipt data is valid
|
|
expect(isValidReceiptData(receiptData)).toBe(true);
|
|
|
|
// Create a mock bluetooth manager that simulates failures
|
|
const mockBluetoothManager = {
|
|
connectionStatus: 'connected',
|
|
getConnectionStatus() {
|
|
return this.connectionStatus;
|
|
},
|
|
async sendData(escposData) {
|
|
// Simulate different failure types
|
|
switch (failureType) {
|
|
case 'timeout':
|
|
throw new TimeoutError('Print operation timed out after 10000ms');
|
|
case 'disconnection':
|
|
this.connectionStatus = 'disconnected';
|
|
throw new PrinterNotConnectedError('Printer is not connected');
|
|
case 'transmission_error':
|
|
throw new TransmissionError('Failed to send data to printer');
|
|
case 'printer_busy':
|
|
throw new PrinterBusyError('Printer is busy processing another job');
|
|
default:
|
|
throw new Error('Unknown failure type');
|
|
}
|
|
}
|
|
};
|
|
|
|
// Track if fallback was triggered
|
|
let fallbackTriggered = false;
|
|
let fallbackReceiptData = null;
|
|
|
|
// Mock fallback function
|
|
const mockFallback = (receipt) => {
|
|
fallbackTriggered = true;
|
|
fallbackReceiptData = receipt;
|
|
};
|
|
|
|
// Simulate the print flow with error handling
|
|
const simulatePrintFlow = async () => {
|
|
try {
|
|
// Check connection status
|
|
const status = mockBluetoothManager.getConnectionStatus();
|
|
|
|
if (status !== 'connected') {
|
|
throw new PrinterNotConnectedError('Bluetooth printer is not connected');
|
|
}
|
|
|
|
// Generate ESC/POS data
|
|
const escposData = generator.generateReceipt(receiptData);
|
|
|
|
// Try to send data (this will fail based on failureType)
|
|
await mockBluetoothManager.sendData(escposData);
|
|
|
|
// If we get here, print succeeded (shouldn't happen in this test)
|
|
return { success: true, fallback: false };
|
|
} catch (error) {
|
|
// On any error, trigger fallback
|
|
mockFallback(receiptData);
|
|
return { success: false, fallback: true, error: error };
|
|
}
|
|
};
|
|
|
|
// Run the simulation
|
|
const result = await simulatePrintFlow();
|
|
|
|
// Verify that fallback was triggered for all failure types
|
|
expect(fallbackTriggered).toBe(true);
|
|
expect(result.fallback).toBe(true);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeDefined();
|
|
|
|
// Verify the correct error type was thrown
|
|
switch (failureType) {
|
|
case 'timeout':
|
|
expect(result.error).toBeInstanceOf(TimeoutError);
|
|
break;
|
|
case 'disconnection':
|
|
expect(result.error).toBeInstanceOf(PrinterNotConnectedError);
|
|
break;
|
|
case 'transmission_error':
|
|
expect(result.error).toBeInstanceOf(TransmissionError);
|
|
break;
|
|
case 'printer_busy':
|
|
expect(result.error).toBeInstanceOf(PrinterBusyError);
|
|
break;
|
|
}
|
|
|
|
// Verify fallback received the receipt data
|
|
expect(fallbackReceiptData).toBeDefined();
|
|
expect(fallbackReceiptData).toEqual(receiptData);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Fallback preserves receipt data
|
|
*
|
|
* Tests that when fallback is triggered, the original receipt data is preserved
|
|
*/
|
|
test('Property 9a: Fallback preserves receipt data', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.record({
|
|
headerData: fc.record({
|
|
companyName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
address: fc.string({ minLength: 1, maxLength: 200 }),
|
|
phone: fc.string({ minLength: 1, maxLength: 20 }),
|
|
taxId: fc.string({ minLength: 1, maxLength: 50 })
|
|
}),
|
|
orderData: fc.record({
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
date: fc.integer({ min: Date.parse('2020-01-01'), max: Date.parse('2030-12-31') }).map(ts => new Date(ts).toISOString()),
|
|
cashier: fc.string({ minLength: 1, maxLength: 50 }),
|
|
customer: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: null })
|
|
}),
|
|
lines: fc.array(
|
|
fc.record({
|
|
productName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
quantity: fc.float({ min: Math.fround(0.01), max: Math.fround(1000), noNaN: true }),
|
|
price: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
{ minLength: 1, maxLength: 10 }
|
|
),
|
|
totals: fc.record({
|
|
subtotal: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
tax: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
discount: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true })
|
|
}),
|
|
paymentData: fc.record({
|
|
method: fc.constantFrom('cash', 'card', 'mobile'),
|
|
amount: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
change: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
footerData: fc.record({
|
|
message: fc.string({ maxLength: 200 }),
|
|
barcode: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null })
|
|
})
|
|
}),
|
|
(originalReceiptData) => {
|
|
// Verify original data is valid
|
|
expect(isValidReceiptData(originalReceiptData)).toBe(true);
|
|
|
|
// Create a deep copy to compare later
|
|
const receiptCopy = JSON.parse(JSON.stringify(originalReceiptData));
|
|
|
|
// Simulate fallback being triggered
|
|
let fallbackReceiptData = null;
|
|
const mockFallback = (receipt) => {
|
|
fallbackReceiptData = receipt;
|
|
};
|
|
|
|
// Trigger fallback
|
|
mockFallback(originalReceiptData);
|
|
|
|
// Verify fallback received the data
|
|
expect(fallbackReceiptData).toBeDefined();
|
|
|
|
// Verify the data is identical (deep equality)
|
|
expect(JSON.stringify(fallbackReceiptData)).toEqual(JSON.stringify(receiptCopy));
|
|
|
|
// Verify all sections are preserved
|
|
expect(fallbackReceiptData.headerData).toEqual(originalReceiptData.headerData);
|
|
expect(fallbackReceiptData.orderData).toEqual(originalReceiptData.orderData);
|
|
expect(fallbackReceiptData.lines).toEqual(originalReceiptData.lines);
|
|
expect(fallbackReceiptData.totals).toEqual(originalReceiptData.totals);
|
|
expect(fallbackReceiptData.paymentData).toEqual(originalReceiptData.paymentData);
|
|
expect(fallbackReceiptData.footerData).toEqual(originalReceiptData.footerData);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Fallback is triggered for all error types
|
|
*
|
|
* Tests that fallback is consistently triggered regardless of error type
|
|
*/
|
|
test('Property 9b: Fallback is triggered for all error types', () => {
|
|
const errorTypes = [
|
|
{ type: 'timeout', error: new TimeoutError('Timeout') },
|
|
{ type: 'disconnection', error: new PrinterNotConnectedError('Not connected') },
|
|
{ type: 'transmission', error: new TransmissionError('Transmission failed') },
|
|
{ type: 'busy', error: new PrinterBusyError('Printer busy') },
|
|
{ type: 'generic', error: new Error('Generic error') }
|
|
];
|
|
|
|
errorTypes.forEach(({ type, error }) => {
|
|
let fallbackTriggered = false;
|
|
|
|
// Simulate error handling
|
|
try {
|
|
throw error;
|
|
} catch (e) {
|
|
// Any error should trigger fallback
|
|
fallbackTriggered = true;
|
|
}
|
|
|
|
// Verify fallback was triggered
|
|
expect(fallbackTriggered).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Property: Fallback notification is shown
|
|
*
|
|
* Tests that when fallback is triggered, an appropriate notification is shown
|
|
*/
|
|
test('Property 9c: Fallback notification is shown', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom(
|
|
new TimeoutError('Timeout'),
|
|
new PrinterNotConnectedError('Not connected'),
|
|
new TransmissionError('Transmission failed'),
|
|
new PrinterBusyError('Printer busy')
|
|
),
|
|
(error) => {
|
|
// Track if notification was shown
|
|
let notificationShown = false;
|
|
let notificationMessage = null;
|
|
|
|
// Mock notification function
|
|
const mockShowNotification = (message) => {
|
|
notificationShown = true;
|
|
notificationMessage = message;
|
|
};
|
|
|
|
// Simulate fallback notification
|
|
const showFallbackNotification = (err) => {
|
|
let message = 'Bluetooth printer unavailable. Receipt sent to default printer.';
|
|
|
|
if (err instanceof TimeoutError) {
|
|
message = 'Bluetooth printer timeout. Receipt sent to default printer.';
|
|
} else if (err instanceof PrinterNotConnectedError) {
|
|
message = 'Bluetooth printer not connected. Receipt sent to default printer.';
|
|
}
|
|
|
|
mockShowNotification(message);
|
|
};
|
|
|
|
// Trigger notification
|
|
showFallbackNotification(error);
|
|
|
|
// Verify notification was shown
|
|
expect(notificationShown).toBe(true);
|
|
expect(notificationMessage).toBeDefined();
|
|
expect(notificationMessage.length).toBeGreaterThan(0);
|
|
|
|
// Verify message mentions fallback
|
|
expect(notificationMessage.toLowerCase()).toContain('printer');
|
|
expect(notificationMessage.toLowerCase()).toContain('default');
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Feature: pos-bluetooth-thermal-printer, Property 14: Print errors don't block sales
|
|
*
|
|
* Property: For any print error or failure, the sale transaction should complete
|
|
* successfully and be recorded
|
|
*
|
|
* Validates: Requirements 8.5
|
|
*/
|
|
test('Property 14: Print errors don\'t block sales', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
// Generate different error scenarios and sale data
|
|
fc.record({
|
|
errorType: fc.constantFrom(
|
|
'timeout',
|
|
'disconnection',
|
|
'transmission_error',
|
|
'printer_busy',
|
|
'bluetooth_unavailable',
|
|
'generic_error'
|
|
),
|
|
saleData: fc.record({
|
|
orderId: fc.string({ minLength: 1, maxLength: 50 }),
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
totalAmount: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
paymentConfirmed: fc.boolean(),
|
|
receiptData: fc.record({
|
|
headerData: fc.record({
|
|
companyName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
address: fc.string({ minLength: 1, maxLength: 200 }),
|
|
phone: fc.string({ minLength: 1, maxLength: 20 }),
|
|
taxId: fc.string({ minLength: 1, maxLength: 50 })
|
|
}),
|
|
orderData: fc.record({
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
date: fc.integer({ min: Date.parse('2020-01-01'), max: Date.parse('2030-12-31') }).map(ts => new Date(ts).toISOString()),
|
|
cashier: fc.string({ minLength: 1, maxLength: 50 }),
|
|
customer: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: null })
|
|
}),
|
|
lines: fc.array(
|
|
fc.record({
|
|
productName: fc.string({ minLength: 1, maxLength: 100 }),
|
|
quantity: fc.float({ min: Math.fround(0.01), max: Math.fround(1000), noNaN: true }),
|
|
price: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
{ minLength: 1, maxLength: 10 }
|
|
),
|
|
totals: fc.record({
|
|
subtotal: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
tax: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
discount: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true }),
|
|
total: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true })
|
|
}),
|
|
paymentData: fc.record({
|
|
method: fc.constantFrom('cash', 'card', 'mobile'),
|
|
amount: fc.float({ min: Math.fround(0.01), max: Math.fround(1000000), noNaN: true }),
|
|
change: fc.float({ min: Math.fround(0), max: Math.fround(100000), noNaN: true })
|
|
}),
|
|
footerData: fc.record({
|
|
message: fc.string({ maxLength: 200 }),
|
|
barcode: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null })
|
|
})
|
|
})
|
|
})
|
|
}),
|
|
async ({ errorType, saleData }) => {
|
|
// Only test with confirmed payments (valid sales)
|
|
if (!saleData.paymentConfirmed) {
|
|
return true; // Skip invalid sales
|
|
}
|
|
|
|
// Verify receipt data is valid
|
|
expect(isValidReceiptData(saleData.receiptData)).toBe(true);
|
|
|
|
// Track sale completion status
|
|
let saleCompleted = false;
|
|
let saleRecorded = false;
|
|
let printAttempted = false;
|
|
let fallbackTriggered = false;
|
|
let errorThrown = null;
|
|
|
|
// Mock sale recording function
|
|
const recordSale = (sale) => {
|
|
saleRecorded = true;
|
|
return { success: true, saleId: sale.orderId };
|
|
};
|
|
|
|
// Mock bluetooth manager that simulates failures
|
|
const mockBluetoothManager = {
|
|
connectionStatus: 'connected', // Start as connected
|
|
isBluetoothAvailable() {
|
|
return errorType !== 'bluetooth_unavailable';
|
|
},
|
|
getConnectionStatus() {
|
|
return this.connectionStatus;
|
|
},
|
|
async sendData(escposData) {
|
|
printAttempted = true;
|
|
|
|
// Simulate different failure types
|
|
switch (errorType) {
|
|
case 'timeout':
|
|
throw new TimeoutError('Print operation timed out after 10000ms');
|
|
case 'disconnection':
|
|
// Simulate disconnection during print
|
|
this.connectionStatus = 'disconnected';
|
|
throw new PrinterNotConnectedError('Printer is not connected');
|
|
case 'transmission_error':
|
|
throw new TransmissionError('Failed to send data to printer');
|
|
case 'printer_busy':
|
|
throw new PrinterBusyError('Printer is busy processing another job');
|
|
case 'generic_error':
|
|
throw new Error('Generic print error');
|
|
default:
|
|
// Success case (shouldn't happen in this test)
|
|
return true;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Mock fallback print function
|
|
const fallbackPrint = (receipt) => {
|
|
fallbackTriggered = true;
|
|
return Promise.resolve();
|
|
};
|
|
|
|
// Simulate the complete sale flow with print error handling
|
|
const completeSaleWithPrint = async () => {
|
|
try {
|
|
// Step 1: Verify payment is confirmed
|
|
if (!saleData.paymentConfirmed) {
|
|
throw new Error('Payment not confirmed');
|
|
}
|
|
|
|
// Step 2: Record the sale BEFORE attempting to print
|
|
// This is critical - sale must be recorded regardless of print status
|
|
const recordResult = recordSale(saleData);
|
|
expect(recordResult.success).toBe(true);
|
|
|
|
// Step 3: Attempt to print receipt
|
|
try {
|
|
// Check if bluetooth is available
|
|
if (!mockBluetoothManager.isBluetoothAvailable()) {
|
|
throw new Error('Bluetooth not available');
|
|
}
|
|
|
|
// Generate ESC/POS data
|
|
const escposData = generator.generateReceipt(saleData.receiptData);
|
|
|
|
// Try to send data (this will fail based on errorType)
|
|
// Note: Connection status check happens inside sendData for some error types
|
|
await mockBluetoothManager.sendData(escposData);
|
|
|
|
// If we get here, print succeeded (shouldn't happen in this test)
|
|
} catch (printError) {
|
|
// Capture the error but don't let it propagate
|
|
errorThrown = printError;
|
|
|
|
// CRITICAL: Print error should NOT prevent sale completion
|
|
// Trigger fallback printing
|
|
await fallbackPrint(saleData.receiptData);
|
|
}
|
|
|
|
// Step 4: Mark sale as completed
|
|
// This should happen regardless of print success/failure
|
|
saleCompleted = true;
|
|
|
|
return { success: true, saleId: saleData.orderId };
|
|
} catch (error) {
|
|
// Only payment/validation errors should reach here
|
|
// Print errors should be caught and handled above
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Execute the sale flow
|
|
const result = await completeSaleWithPrint();
|
|
|
|
// CRITICAL ASSERTIONS: Sale must complete despite print errors
|
|
|
|
// 1. Verify sale was recorded (this happens before print attempt)
|
|
expect(saleRecorded).toBe(true);
|
|
|
|
// 2. Verify print was attempted (unless bluetooth unavailable)
|
|
if (errorType !== 'bluetooth_unavailable') {
|
|
expect(printAttempted).toBe(true);
|
|
}
|
|
|
|
// 3. Verify an error was thrown during printing
|
|
expect(errorThrown).toBeDefined();
|
|
expect(errorThrown).toBeInstanceOf(Error);
|
|
|
|
// 4. Verify fallback was triggered
|
|
expect(fallbackTriggered).toBe(true);
|
|
|
|
// 5. MOST IMPORTANT: Verify sale completed successfully
|
|
expect(saleCompleted).toBe(true);
|
|
expect(result.success).toBe(true);
|
|
expect(result.saleId).toBe(saleData.orderId);
|
|
|
|
// 6. Verify the correct error type was thrown
|
|
switch (errorType) {
|
|
case 'timeout':
|
|
expect(errorThrown).toBeInstanceOf(TimeoutError);
|
|
break;
|
|
case 'disconnection':
|
|
expect(errorThrown).toBeInstanceOf(PrinterNotConnectedError);
|
|
break;
|
|
case 'transmission_error':
|
|
expect(errorThrown).toBeInstanceOf(TransmissionError);
|
|
break;
|
|
case 'printer_busy':
|
|
expect(errorThrown).toBeInstanceOf(PrinterBusyError);
|
|
break;
|
|
case 'bluetooth_unavailable':
|
|
case 'generic_error':
|
|
expect(errorThrown).toBeInstanceOf(Error);
|
|
break;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Sale recording happens before print attempt
|
|
*
|
|
* Tests that sales are recorded to the database before attempting to print,
|
|
* ensuring data persistence even if print fails
|
|
*/
|
|
test('Property 14a: Sale recording happens before print attempt', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.record({
|
|
orderId: fc.string({ minLength: 1, maxLength: 50 }),
|
|
totalAmount: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
timestamp: fc.integer({ min: Date.parse('2020-01-01'), max: Date.parse('2030-12-31') })
|
|
}),
|
|
(saleData) => {
|
|
// Track operation order
|
|
const operations = [];
|
|
|
|
// Mock functions that track execution order
|
|
const recordSale = (sale) => {
|
|
operations.push('record_sale');
|
|
return { success: true, saleId: sale.orderId };
|
|
};
|
|
|
|
const attemptPrint = (receipt) => {
|
|
operations.push('attempt_print');
|
|
throw new Error('Print failed');
|
|
};
|
|
|
|
// Simulate sale flow
|
|
try {
|
|
// Record sale first
|
|
recordSale(saleData);
|
|
|
|
// Then attempt print
|
|
try {
|
|
attemptPrint(saleData);
|
|
} catch (printError) {
|
|
// Print error caught, but sale already recorded
|
|
operations.push('print_error_caught');
|
|
}
|
|
|
|
operations.push('sale_completed');
|
|
} catch (error) {
|
|
operations.push('sale_failed');
|
|
}
|
|
|
|
// Verify operation order
|
|
expect(operations[0]).toBe('record_sale');
|
|
expect(operations[1]).toBe('attempt_print');
|
|
expect(operations[2]).toBe('print_error_caught');
|
|
expect(operations[3]).toBe('sale_completed');
|
|
|
|
// Verify sale completed despite print error
|
|
expect(operations).toContain('sale_completed');
|
|
expect(operations).not.toContain('sale_failed');
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Print errors are caught and don't propagate
|
|
*
|
|
* Tests that print errors are caught within the print handler and don't
|
|
* propagate to the sale completion logic
|
|
*/
|
|
test('Property 14b: Print errors are caught and don\'t propagate', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.constantFrom(
|
|
new TimeoutError('Timeout'),
|
|
new PrinterNotConnectedError('Not connected'),
|
|
new TransmissionError('Transmission failed'),
|
|
new PrinterBusyError('Printer busy'),
|
|
new Error('Generic error')
|
|
),
|
|
async (printError) => {
|
|
let saleCompleted = false;
|
|
let errorPropagated = false;
|
|
|
|
// Simulate sale completion with print error
|
|
const completeSale = async () => {
|
|
try {
|
|
// Record sale
|
|
const saleRecorded = true;
|
|
|
|
// Attempt print with error handling
|
|
try {
|
|
throw printError;
|
|
} catch (err) {
|
|
// Error caught - should not propagate
|
|
// Trigger fallback
|
|
await Promise.resolve(); // Simulate fallback
|
|
}
|
|
|
|
// Mark sale as completed
|
|
saleCompleted = true;
|
|
} catch (error) {
|
|
// If we reach here, error propagated (bad!)
|
|
errorPropagated = true;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Execute sale
|
|
await completeSale();
|
|
|
|
// Verify error didn't propagate
|
|
expect(errorPropagated).toBe(false);
|
|
|
|
// Verify sale completed
|
|
expect(saleCompleted).toBe(true);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Multiple print errors don't affect sale completion
|
|
*
|
|
* Tests that even if multiple print attempts fail, the sale still completes
|
|
*/
|
|
test('Property 14c: Multiple print errors don\'t affect sale completion', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.array(
|
|
fc.constantFrom(
|
|
new TimeoutError('Timeout'),
|
|
new PrinterNotConnectedError('Not connected'),
|
|
new TransmissionError('Transmission failed')
|
|
),
|
|
{ minLength: 1, maxLength: 5 }
|
|
),
|
|
async (errors) => {
|
|
let saleCompleted = false;
|
|
let printAttempts = 0;
|
|
let fallbackTriggered = false;
|
|
|
|
// Simulate sale with multiple print attempts
|
|
const completeSale = async () => {
|
|
// Record sale
|
|
const saleRecorded = true;
|
|
|
|
// Try printing multiple times (simulating retries)
|
|
for (const error of errors) {
|
|
try {
|
|
printAttempts++;
|
|
throw error;
|
|
} catch (err) {
|
|
// Each error is caught
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// After all attempts fail, trigger fallback
|
|
fallbackTriggered = true;
|
|
|
|
// Complete sale
|
|
saleCompleted = true;
|
|
};
|
|
|
|
// Execute sale
|
|
await completeSale();
|
|
|
|
// Verify all print attempts were made
|
|
expect(printAttempts).toBe(errors.length);
|
|
|
|
// Verify fallback was triggered
|
|
expect(fallbackTriggered).toBe(true);
|
|
|
|
// Verify sale completed despite all print failures
|
|
expect(saleCompleted).toBe(true);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Sale data integrity is maintained after print errors
|
|
*
|
|
* Tests that sale data remains intact and unchanged after print errors
|
|
*/
|
|
test('Property 14d: Sale data integrity is maintained after print errors', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.record({
|
|
orderId: fc.string({ minLength: 1, maxLength: 50 }),
|
|
orderName: fc.string({ minLength: 1, maxLength: 50 }),
|
|
totalAmount: fc.float({ min: Math.fround(0.01), max: Math.fround(100000), noNaN: true }),
|
|
items: fc.array(
|
|
fc.record({
|
|
productId: fc.string({ minLength: 1, maxLength: 20 }),
|
|
quantity: fc.integer({ min: 1, max: 100 }),
|
|
price: fc.float({ min: Math.fround(0.01), max: Math.fround(10000), noNaN: true })
|
|
}),
|
|
{ minLength: 1, maxLength: 10 }
|
|
)
|
|
}),
|
|
(originalSaleData) => {
|
|
// Create a deep copy for comparison
|
|
const saleDataCopy = JSON.parse(JSON.stringify(originalSaleData));
|
|
|
|
// Simulate sale with print error
|
|
let recordedSaleData = null;
|
|
|
|
const completeSale = () => {
|
|
// Record sale data
|
|
recordedSaleData = { ...originalSaleData };
|
|
|
|
// Attempt print (fails)
|
|
try {
|
|
throw new PrinterNotConnectedError('Print failed');
|
|
} catch (err) {
|
|
// Error caught, fallback triggered
|
|
}
|
|
|
|
// Sale completes
|
|
return { success: true };
|
|
};
|
|
|
|
// Execute sale
|
|
const result = completeSale();
|
|
|
|
// Verify sale completed
|
|
expect(result.success).toBe(true);
|
|
|
|
// Verify sale data wasn't modified by print error
|
|
expect(JSON.stringify(recordedSaleData)).toEqual(JSON.stringify(saleDataCopy));
|
|
expect(recordedSaleData.orderId).toBe(originalSaleData.orderId);
|
|
expect(recordedSaleData.totalAmount).toBe(originalSaleData.totalAmount);
|
|
expect(recordedSaleData.items.length).toBe(originalSaleData.items.length);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
});
|