619 lines
23 KiB
JavaScript
619 lines
23 KiB
JavaScript
/**
|
|
* Property-Based Tests for ESC/POS Command Generation
|
|
*
|
|
* Tests correctness properties related to ESC/POS command generation
|
|
* using fast-check for property-based testing.
|
|
*/
|
|
|
|
import * as fc from 'fast-check';
|
|
import { EscPosGenerator } from '../js/escpos_generator.js';
|
|
|
|
describe('ESC/POS Conversion Properties', () => {
|
|
let generator;
|
|
|
|
beforeEach(() => {
|
|
generator = new EscPosGenerator();
|
|
});
|
|
|
|
/**
|
|
* Generator for receipt data structures
|
|
* Generates valid receipt objects with random data
|
|
*/
|
|
const receiptDataGenerator = () => {
|
|
return 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.date().map(d => d.toISOString()),
|
|
cashier: fc.string({ minLength: 1, maxLength: 50 }),
|
|
customer: fc.option(fc.string({ minLength: 1, maxLength: 100 }))
|
|
}),
|
|
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'),
|
|
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 }))
|
|
})
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Generator for alignment values
|
|
*/
|
|
const alignmentGenerator = () => {
|
|
return fc.constantFrom('left', 'center', 'right');
|
|
};
|
|
|
|
/**
|
|
* Generator for text size values (1-8)
|
|
*/
|
|
const textSizeGenerator = () => {
|
|
return fc.integer({ min: 1, max: 8 });
|
|
};
|
|
|
|
/**
|
|
* Generator for text strings
|
|
*/
|
|
const textGenerator = () => {
|
|
return fc.string({ minLength: 0, maxLength: 200 });
|
|
};
|
|
|
|
/**
|
|
* Helper function to check if a Uint8Array contains a specific byte sequence
|
|
*/
|
|
const containsSequence = (array, sequence) => {
|
|
for (let i = 0; i <= array.length - sequence.length; i++) {
|
|
let found = true;
|
|
for (let j = 0; j < sequence.length; j++) {
|
|
if (array[i + j] !== sequence[j]) {
|
|
found = false;
|
|
break;
|
|
}
|
|
}
|
|
if (found) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Feature: pos-bluetooth-thermal-printer, Property 7: ESC/POS conversion correctness
|
|
*
|
|
* Property: For any receipt data, the ESC/POS generator should produce valid
|
|
* command sequences that include all formatting directives (alignment, size,
|
|
* emphasis, line feeds, cuts)
|
|
*
|
|
* Validates: Requirements 3.2, 7.1, 7.2, 7.3, 7.4
|
|
*/
|
|
test('Property 7: ESC/POS conversion correctness - generates valid command sequences', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
receiptDataGenerator(),
|
|
(receiptData) => {
|
|
// Generate ESC/POS commands
|
|
const result = generator.generateReceipt(receiptData);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify result is not empty
|
|
expect(result.length).toBeGreaterThan(0);
|
|
|
|
// Verify initialization command is present (ESC @ = 0x1B 0x40)
|
|
expect(containsSequence(result, [0x1B, 0x40])).toBe(true);
|
|
|
|
// Verify paper cut command is present (GS V 0 = 0x1D 0x56 0x00)
|
|
expect(containsSequence(result, [0x1D, 0x56, 0x00])).toBe(true);
|
|
|
|
// Verify line feed commands are present (LF = 0x0A)
|
|
expect(result.includes(0x0A)).toBe(true);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Alignment commands are correctly generated
|
|
*
|
|
* Tests that setAlignment produces the correct ESC/POS command for each alignment type
|
|
*
|
|
* Validates: Requirements 7.1
|
|
*/
|
|
test('Property 7a: Alignment commands are correctly generated', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
alignmentGenerator(),
|
|
(alignment) => {
|
|
const result = generator.setAlignment(alignment);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify it starts with ESC 'a' (0x1B 0x61)
|
|
expect(result[0]).toBe(0x1B);
|
|
expect(result[1]).toBe(0x61);
|
|
|
|
// Verify correct alignment value
|
|
if (alignment === 'left') {
|
|
expect(result[2]).toBe(0x00);
|
|
} else if (alignment === 'center') {
|
|
expect(result[2]).toBe(0x01);
|
|
} else if (alignment === 'right') {
|
|
expect(result[2]).toBe(0x02);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Text size commands are correctly generated
|
|
*
|
|
* Tests that setTextSize produces valid ESC/POS commands for all size combinations
|
|
*
|
|
* Validates: Requirements 7.2
|
|
*/
|
|
test('Property 7b: Text size commands are correctly generated', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
textSizeGenerator(),
|
|
textSizeGenerator(),
|
|
(width, height) => {
|
|
const result = generator.setTextSize(width, height);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify it starts with GS '!' (0x1D 0x21)
|
|
expect(result[0]).toBe(0x1D);
|
|
expect(result[1]).toBe(0x21);
|
|
|
|
// Verify size value is within valid range (0x00 to 0x77)
|
|
expect(result[2]).toBeGreaterThanOrEqual(0x00);
|
|
expect(result[2]).toBeLessThanOrEqual(0x77);
|
|
|
|
// Verify the size encoding is correct
|
|
const widthValue = width - 1;
|
|
const heightValue = height - 1;
|
|
const expectedSize = (widthValue << 4) | heightValue;
|
|
expect(result[2]).toBe(expectedSize);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Emphasis commands are correctly generated
|
|
*
|
|
* Tests that setEmphasis produces valid ESC/POS commands for bold and underline
|
|
*
|
|
* Validates: Requirements 7.3
|
|
*/
|
|
test('Property 7c: Emphasis commands are correctly generated', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.boolean(),
|
|
fc.boolean(),
|
|
(bold, underline) => {
|
|
const result = generator.setEmphasis(bold, underline);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify bold command is present
|
|
if (bold) {
|
|
expect(containsSequence(result, [0x1B, 0x45, 0x01])).toBe(true);
|
|
} else {
|
|
expect(containsSequence(result, [0x1B, 0x45, 0x00])).toBe(true);
|
|
}
|
|
|
|
// Verify underline command is present
|
|
if (underline) {
|
|
expect(containsSequence(result, [0x1B, 0x2D, 0x01])).toBe(true);
|
|
} else {
|
|
expect(containsSequence(result, [0x1B, 0x2D, 0x00])).toBe(true);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Feed and cut commands are correctly generated
|
|
*
|
|
* Tests that feedAndCut produces valid ESC/POS commands with line feeds and paper cut
|
|
*
|
|
* Validates: Requirements 7.4
|
|
*/
|
|
test('Property 7d: Feed and cut commands are correctly generated', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 10 }),
|
|
(lines) => {
|
|
const result = generator.feedAndCut(lines);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify paper cut command is present (GS V 0 = 0x1D 0x56 0x00)
|
|
expect(containsSequence(result, [0x1D, 0x56, 0x00])).toBe(true);
|
|
|
|
// Count line feed commands (0x0A)
|
|
let lfCount = 0;
|
|
for (let i = 0; i < result.length; i++) {
|
|
if (result[i] === 0x0A) {
|
|
lfCount++;
|
|
}
|
|
}
|
|
|
|
// Verify correct number of line feeds
|
|
expect(lfCount).toBe(lines);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Text encoding produces non-empty output for non-empty input
|
|
*
|
|
* Tests that encodeText correctly handles text encoding
|
|
*
|
|
* Validates: Requirements 3.2
|
|
*/
|
|
test('Property 7e: Text encoding produces output for non-empty input', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string({ minLength: 1, maxLength: 200 }),
|
|
(text) => {
|
|
const result = generator.encodeText(text);
|
|
|
|
// Verify result is a typed array (use duck typing to avoid constructor issues)
|
|
expect(result).toBeDefined();
|
|
expect(typeof result.length).toBe('number');
|
|
expect(typeof result.buffer).toBe('object');
|
|
|
|
// Verify non-empty text produces non-empty output
|
|
expect(result.length).toBeGreaterThan(0);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Empty text encoding produces empty output
|
|
*
|
|
* Tests that encodeText correctly handles empty strings
|
|
*
|
|
* Validates: Requirements 3.2
|
|
*/
|
|
test('Property 7f: Empty text encoding produces empty output', () => {
|
|
const result = generator.encodeText('');
|
|
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
expect(result.length).toBe(0);
|
|
});
|
|
|
|
/**
|
|
* Property: Receipt generation includes all major sections
|
|
*
|
|
* Tests that generateReceipt includes commands for all provided data sections
|
|
*
|
|
* Validates: Requirements 3.2, 7.1, 7.2, 7.3, 7.4
|
|
*/
|
|
test('Property 7g: Receipt generation includes all major sections', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
receiptDataGenerator(),
|
|
(receiptData) => {
|
|
const result = generator.generateReceipt(receiptData);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify alignment commands are present (at least one)
|
|
const hasAlignment =
|
|
containsSequence(result, [0x1B, 0x61, 0x00]) ||
|
|
containsSequence(result, [0x1B, 0x61, 0x01]) ||
|
|
containsSequence(result, [0x1B, 0x61, 0x02]);
|
|
expect(hasAlignment).toBe(true);
|
|
|
|
// Verify text size commands are present (GS !)
|
|
expect(containsSequence(result, [0x1D, 0x21])).toBe(true);
|
|
|
|
// Verify emphasis commands are present (ESC E)
|
|
expect(containsSequence(result, [0x1B, 0x45])).toBe(true);
|
|
|
|
// Verify line feeds are present
|
|
expect(result.includes(0x0A)).toBe(true);
|
|
|
|
// Verify paper cut is present
|
|
expect(containsSequence(result, [0x1D, 0x56, 0x00])).toBe(true);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Initialize command is correctly generated
|
|
*
|
|
* Tests that initialize produces the correct ESC/POS initialization command
|
|
*
|
|
* Validates: Requirements 3.2
|
|
*/
|
|
test('Property 7h: Initialize command is correctly generated', () => {
|
|
const result = generator.initialize();
|
|
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
expect(result.length).toBe(2);
|
|
expect(result[0]).toBe(0x1B);
|
|
expect(result[1]).toBe(0x40);
|
|
});
|
|
|
|
/**
|
|
* Property: AddLine produces valid command sequence
|
|
*
|
|
* Tests that addLine generates proper ESC/POS commands with formatting
|
|
*
|
|
* Validates: Requirements 3.2, 7.1, 7.2, 7.3
|
|
*/
|
|
test('Property 7i: AddLine produces valid command sequence', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
textGenerator(),
|
|
fc.record({
|
|
align: fc.option(alignmentGenerator()),
|
|
width: fc.option(textSizeGenerator()),
|
|
height: fc.option(textSizeGenerator()),
|
|
bold: fc.option(fc.boolean()),
|
|
underline: fc.option(fc.boolean())
|
|
}),
|
|
(text, options) => {
|
|
const result = generator.addLine(text, options);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify line feed is present
|
|
expect(result.includes(0x0A)).toBe(true);
|
|
|
|
// If alignment specified, verify alignment command
|
|
if (options.align) {
|
|
expect(containsSequence(result, [0x1B, 0x61])).toBe(true);
|
|
}
|
|
|
|
// If size specified, verify size command
|
|
if (options.width || options.height) {
|
|
expect(containsSequence(result, [0x1D, 0x21])).toBe(true);
|
|
}
|
|
|
|
// If emphasis specified, verify emphasis commands
|
|
if (options.bold !== undefined || options.underline !== undefined) {
|
|
expect(containsSequence(result, [0x1B, 0x45])).toBe(true);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Feature: pos-bluetooth-thermal-printer, Property 13: Character encoding correctness
|
|
*
|
|
* Property: For any text in the receipt, the ESC/POS generator should encode it
|
|
* using the configured character set for the printer
|
|
*
|
|
* Validates: Requirements 7.5
|
|
*/
|
|
test('Property 13: Character encoding correctness', () => {
|
|
/**
|
|
* Generator for character sets supported by ESC/POS printers
|
|
*/
|
|
const characterSetGenerator = () => {
|
|
return fc.constantFrom('CP437', 'CP850', 'CP852', 'CP858', 'UTF-8');
|
|
};
|
|
|
|
/**
|
|
* Generator for text with various character types
|
|
* Includes ASCII, extended ASCII, and special characters
|
|
*/
|
|
const mixedTextGenerator = () => {
|
|
return fc.oneof(
|
|
// Basic ASCII text
|
|
fc.string({ minLength: 1, maxLength: 100 }),
|
|
// Text with numbers and symbols
|
|
fc.string({ minLength: 1, maxLength: 100 }).map(s => s + ' $123.45'),
|
|
// Text with common receipt characters
|
|
fc.constantFrom(
|
|
'Total: $99.99',
|
|
'Qty: 5 @ $10.00',
|
|
'Tax (15%): $15.00',
|
|
'Receipt #12345',
|
|
'*** THANK YOU ***',
|
|
'================================',
|
|
'Item Price'
|
|
),
|
|
// Text with extended ASCII characters (common in European languages)
|
|
fc.constantFrom(
|
|
'Café au lait',
|
|
'Crème brûlée',
|
|
'Jalapeño',
|
|
'Naïve',
|
|
'Résumé',
|
|
'Über',
|
|
'Piñata'
|
|
)
|
|
);
|
|
};
|
|
|
|
fc.assert(
|
|
fc.property(
|
|
characterSetGenerator(),
|
|
mixedTextGenerator(),
|
|
(charset, text) => {
|
|
// Configure the generator with the character set
|
|
generator.characterSet = charset;
|
|
|
|
// Encode the text
|
|
const result = generator.encodeText(text);
|
|
|
|
// Verify result is a typed array (use duck typing to avoid constructor issues)
|
|
expect(result).toBeDefined();
|
|
expect(typeof result.length).toBe('number');
|
|
expect(typeof result.buffer).toBe('object');
|
|
|
|
// Verify non-empty text produces non-empty output
|
|
if (text.length > 0) {
|
|
expect(result.length).toBeGreaterThan(0);
|
|
}
|
|
|
|
// Verify all bytes are valid (0-255)
|
|
for (let i = 0; i < result.length; i++) {
|
|
expect(result[i]).toBeGreaterThanOrEqual(0);
|
|
expect(result[i]).toBeLessThanOrEqual(255);
|
|
}
|
|
|
|
// Verify encoding is deterministic (same input produces same output)
|
|
const result2 = generator.encodeText(text);
|
|
expect(result.length).toBe(result2.length);
|
|
for (let i = 0; i < result.length; i++) {
|
|
expect(result[i]).toBe(result2[i]);
|
|
}
|
|
|
|
// Verify the encoding respects the character set configuration
|
|
// The characterSet property should be used by the encoder
|
|
expect(generator.characterSet).toBe(charset);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Property: Character encoding handles empty strings correctly
|
|
*
|
|
* Tests that encodeText returns empty array for empty input
|
|
*
|
|
* Validates: Requirements 7.5
|
|
*/
|
|
test('Property 13a: Character encoding handles empty strings', () => {
|
|
const charsets = ['CP437', 'CP850', 'CP852', 'CP858', 'UTF-8'];
|
|
|
|
charsets.forEach(charset => {
|
|
generator.characterSet = charset;
|
|
const result = generator.encodeText('');
|
|
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
expect(result.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Property: Character encoding handles null/undefined input
|
|
*
|
|
* Tests that encodeText gracefully handles invalid input
|
|
*
|
|
* Validates: Requirements 7.5
|
|
*/
|
|
test('Property 13b: Character encoding handles null/undefined input', () => {
|
|
const charsets = ['CP437', 'CP850', 'CP852', 'CP858', 'UTF-8'];
|
|
|
|
charsets.forEach(charset => {
|
|
generator.characterSet = charset;
|
|
|
|
const resultNull = generator.encodeText(null);
|
|
expect(resultNull).toBeInstanceOf(Uint8Array);
|
|
expect(resultNull.length).toBe(0);
|
|
|
|
const resultUndefined = generator.encodeText(undefined);
|
|
expect(resultUndefined).toBeInstanceOf(Uint8Array);
|
|
expect(resultUndefined.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Property: Character encoding is consistent across receipt generation
|
|
*
|
|
* Tests that the configured character set is used consistently when generating
|
|
* a complete receipt
|
|
*
|
|
* Validates: Requirements 7.5
|
|
*/
|
|
test('Property 13c: Character encoding is consistent in receipt generation', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom('CP437', 'CP850', 'CP852', 'CP858'),
|
|
receiptDataGenerator(),
|
|
(charset, receiptData) => {
|
|
// Configure the generator with the character set
|
|
generator.characterSet = charset;
|
|
|
|
// Generate the receipt
|
|
const result = generator.generateReceipt(receiptData);
|
|
|
|
// Verify result is a Uint8Array
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
|
|
// Verify all bytes are valid
|
|
for (let i = 0; i < result.length; i++) {
|
|
expect(result[i]).toBeGreaterThanOrEqual(0);
|
|
expect(result[i]).toBeLessThanOrEqual(255);
|
|
}
|
|
|
|
// Verify the character set configuration is preserved
|
|
expect(generator.characterSet).toBe(charset);
|
|
|
|
return true;
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
});
|