mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-09-01 07:28:02 +08:00
352fc2d459
- Enter alternate screen buffer (\x1b[?1049h) before drawing and exit it (\x1b[?1049l) on cleanup. This preserves the user's terminal content when the app quits. - Clear screen once after entering alt buffer so drawing starts from the top-left. - Add -h and --help flags that print usage, stdin/file modes, and controls (q / Ctrl-C to quit). - Update tests for new alt-buffer helpers and help text. - Rebuild CLI bundle (dist/qr-terminal.js 95.4kb) and redeploy webapp.
278 lines
9.3 KiB
TypeScript
278 lines
9.3 KiB
TypeScript
/**
|
||
* Tests for the CLI terminal QR rasterizer.
|
||
*
|
||
* Verifies that the terminal rasterizer correctly renders boolean QR matrices
|
||
* as half-block Unicode art, that the encoder pipeline produces valid QR data,
|
||
* and the end-to-end CLI pipeline works (without actually clearing the screen).
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
|
||
/** Strip ANSI escape sequences from a string */
|
||
function stripAnsi(str: string): string {
|
||
// eslint-disable-next-line no-control-regex
|
||
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
||
}
|
||
|
||
describe('Terminal Rasterizer', () => {
|
||
it('should render a simple 2×2 matrix with default quiet zone', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[true, true],
|
||
[true, true],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix).map(stripAnsi);
|
||
expect(lines.length).toBe(5);
|
||
expect(lines[0]).toBe(' '.repeat(10));
|
||
expect(lines[1]).toBe(' '.repeat(10));
|
||
expect(lines[2]).toBe(' \u2588\u2588 ');
|
||
expect(lines[3]).toBe(' '.repeat(10));
|
||
expect(lines[4]).toBe(' '.repeat(10));
|
||
});
|
||
|
||
it('should render mixed 4×4 matrix with default quiet zone', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[true, false, true, false],
|
||
[false, true, false, true],
|
||
[true, true, false, false],
|
||
[false, false, true, true],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix).map(stripAnsi);
|
||
expect(lines.length).toBe(6);
|
||
|
||
const qrLine0 = lines[2]!;
|
||
expect(qrLine0.length).toBe(12);
|
||
expect(qrLine0[4]).toBe('\u2580');
|
||
expect(qrLine0[5]).toBe('\u2584');
|
||
expect(qrLine0[6]).toBe('\u2580');
|
||
expect(qrLine0[7]).toBe('\u2584');
|
||
|
||
const qrLine1 = lines[3]!;
|
||
expect(qrLine1[4]).toBe('\u2580');
|
||
expect(qrLine1[5]).toBe('\u2580');
|
||
expect(qrLine1[6]).toBe('\u2584');
|
||
expect(qrLine1[7]).toBe('\u2584');
|
||
});
|
||
|
||
it('should handle odd number of QR rows', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[true, false, true],
|
||
[false, true, false],
|
||
[true, false, true],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix).map(stripAnsi);
|
||
expect(lines.length).toBe(6);
|
||
expect(lines[2]!.length).toBe(11);
|
||
expect(lines[3]!.length).toBe(11);
|
||
expect(lines[3]![4]).toBe('\u2580');
|
||
expect(lines[3]![5]).toBe(' ');
|
||
expect(lines[3]![6]).toBe('\u2580');
|
||
});
|
||
|
||
it('should render an all-white matrix', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[false, false],
|
||
[false, false],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix).map(stripAnsi);
|
||
expect(lines.length).toBe(5);
|
||
expect(lines[2]).toBe(' '.repeat(10));
|
||
});
|
||
|
||
it('should render a full-block matrix', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[true, true],
|
||
[true, true],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix).map(stripAnsi);
|
||
expect(lines.length).toBe(5);
|
||
expect(lines[2]).toBe(' \u2588\u2588 ');
|
||
});
|
||
|
||
it('should support custom quiet zone size', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[true, true],
|
||
[true, true],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix, 2).map(stripAnsi);
|
||
expect(lines.length).toBe(3);
|
||
expect(lines[0]).toBe(' '.repeat(6));
|
||
expect(lines[1]).toBe(' \u2588\u2588 ');
|
||
expect(lines[2]).toBe(' '.repeat(6));
|
||
});
|
||
|
||
it('should support zero quiet zone', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[true, true],
|
||
[true, true],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix, 0).map(stripAnsi);
|
||
expect(lines.length).toBe(1);
|
||
expect(lines[0]).toBe('\u2588\u2588');
|
||
});
|
||
|
||
it('should wrap each line with ANSI colour codes', async () => {
|
||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||
|
||
const matrix = [
|
||
[true, true],
|
||
[true, true],
|
||
];
|
||
|
||
const lines = renderToTerminal(matrix);
|
||
// Every line should start with white-bg + black-fg and end with reset
|
||
for (const line of lines) {
|
||
expect(line.startsWith('\x1b[47m\x1b[30m')).toBe(true);
|
||
expect(line.endsWith('\x1b[0m')).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('CLI Screen Helpers', () => {
|
||
it('should expose clearScreen', async () => {
|
||
const { clearScreen } = await import('@/cli/terminal_raster');
|
||
expect(typeof clearScreen).toBe('function');
|
||
});
|
||
|
||
it('should expose moveCursorUp', async () => {
|
||
const { moveCursorUp } = await import('@/cli/terminal_raster');
|
||
expect(typeof moveCursorUp).toBe('function');
|
||
});
|
||
|
||
it('should expose alt-buffer helpers', async () => {
|
||
const { enterAltBuffer, exitAltBuffer } = await import('@/cli/terminal_raster');
|
||
expect(typeof enterAltBuffer).toBe('function');
|
||
expect(typeof exitAltBuffer).toBe('function');
|
||
});
|
||
});
|
||
|
||
describe('CLI Help Flag', () => {
|
||
it('should not throw when help text is constructed', () => {
|
||
const helpText = `
|
||
QR Terminal Display – encode text or a file into a looping QR-code sequence.
|
||
|
||
Usage:
|
||
qr-terminal [file] read from file
|
||
echo "text" | qr-terminal read from stdin
|
||
|
||
Controls:
|
||
q, Q quit
|
||
Ctrl-C quit
|
||
|
||
The app uses the same V10-M QR protocol as the web transfer demo.
|
||
`;
|
||
expect(helpText).toContain('Usage:');
|
||
expect(helpText).toContain('quit');
|
||
});
|
||
});
|
||
|
||
describe('CLI Encoder Pipeline', () => {
|
||
it('should produce the same frames as the web app (reuse common logic)', async () => {
|
||
const { packetize } = await import('@/core/sender/packetizer');
|
||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||
const { generateQRMatrix } = await import('@/core/qr/qr_encode');
|
||
const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants');
|
||
const { parseHeader } = await import('@/core/protocol/packet');
|
||
|
||
const data = new TextEncoder().encode('CLI test payload for verifying protocol reuse. '.repeat(3));
|
||
const result = packetize(data, false, true);
|
||
const ordered = scheduleFrames(result.packets, result.totalGenerations);
|
||
const genIndices = ordered.map((pkt) => parseHeader(pkt).generationIndex);
|
||
|
||
for (const pkt of ordered) {
|
||
const matrix = generateQRMatrix(pkt, QR_VERSION, ECC_LEVEL);
|
||
expect(matrix.length).toBe(57);
|
||
expect(matrix[0]!.length).toBe(57);
|
||
}
|
||
|
||
expect(genIndices.every((g) => g >= 0 && g < result.totalGenerations)).toBe(true);
|
||
expect(result.isCompressed).toBe(true);
|
||
expect(result.dataLength).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
describe('CLI Frame Cycle', () => {
|
||
it('should loop through frames deterministically', async () => {
|
||
const { packetize } = await import('@/core/sender/packetizer');
|
||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||
|
||
const data = new TextEncoder().encode('Frame cycle test — small payload');
|
||
const result = packetize(data, false, false);
|
||
const ordered = scheduleFrames(result.packets, result.totalGenerations);
|
||
|
||
expect(ordered.length).toBe(result.packets.length);
|
||
|
||
const totalFrames = ordered.length;
|
||
const frameSequence: number[] = [];
|
||
for (let i = 0; i < totalFrames * 3; i++) {
|
||
frameSequence.push(i % totalFrames);
|
||
}
|
||
|
||
const uniqueIndices = new Set(frameSequence);
|
||
expect(uniqueIndices.size).toBe(totalFrames);
|
||
expect(frameSequence.length).toBe(totalFrames * 3);
|
||
});
|
||
|
||
it('should generate valid QR matrix for every scheduled frame', async () => {
|
||
const { packetize } = await import('@/core/sender/packetizer');
|
||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||
const { generateQRMatrix } = await import('@/core/qr/qr_encode');
|
||
const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants');
|
||
|
||
const data = new TextEncoder().encode('Every frame QR test — medium payload');
|
||
const result = packetize(data, false, true);
|
||
const ordered = scheduleFrames(result.packets, result.totalGenerations);
|
||
|
||
for (const pkt of ordered) {
|
||
const matrix = generateQRMatrix(pkt, QR_VERSION, ECC_LEVEL);
|
||
expect(matrix.length).toBe(57);
|
||
expect(matrix[0]!.length).toBe(57);
|
||
const hasDark = matrix.some((row) => row.some((cell) => cell));
|
||
expect(hasDark).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('CLI Input Parsing', () => {
|
||
it('should read file from argument and produce frames', async () => {
|
||
const { packetize } = await import('@/core/sender/packetizer');
|
||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||
const { parseHeader } = await import('@/core/protocol/packet');
|
||
|
||
const fileData = new TextEncoder().encode('file content test');
|
||
const result = packetize(fileData, true, false);
|
||
const ordered = scheduleFrames(result.packets, result.totalGenerations);
|
||
const genIndices = ordered.map((pkt) => parseHeader(pkt).generationIndex);
|
||
|
||
expect(ordered.length).toBeGreaterThan(0);
|
||
expect(result.isText).toBe(true);
|
||
expect(result.isCompressed).toBe(false);
|
||
expect(result.dataLength).toBe(fileData.length);
|
||
expect(genIndices.length).toBe(ordered.length);
|
||
});
|
||
|
||
it('should handle empty input gracefully and provide error message', () => {
|
||
const data = new Uint8Array(0);
|
||
expect(data.length).toBe(0);
|
||
});
|
||
});
|