mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-08-31 15:07:50 +08:00
fix(cli): add quiet zone around terminal QR; fix q-key quit via /dev/tty
- renderToTerminal now pads with a 4-module quiet zone (white border) horizontally and vertically, matching the webapp rasterizer. - Keyboard input now tries /dev/tty first so q/Ctrl-C works even when stdin is a pipe (e.g. echo ... | bun run src/cli/qr-terminal.ts). - Update all CLI renderer tests for padded output; add tests for custom and zero quiet zone. - Increase outer_ec_benefit test timeout to 15s to avoid flaky failures. - Rebuild CLI bundle and webapp; redeploy to nginx.
This commit is contained in:
+28
-8
@@ -13,7 +13,8 @@
|
||||
* node --import=tsx src/cli/qr-terminal.ts ... # with tsx
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { readFileSync, existsSync, openSync, closeSync } from 'fs';
|
||||
import { ReadStream } from 'tty';
|
||||
import { generateQRMatrix } from '../core/qr/qr_encode';
|
||||
import { packetize } from '../core/sender/packetizer';
|
||||
import { scheduleFrames } from '../core/sender/scheduler';
|
||||
@@ -141,21 +142,40 @@ function main() {
|
||||
clearInterval(interval);
|
||||
clearScreen();
|
||||
showCursor();
|
||||
if (ttyFd !== null) {
|
||||
try { closeSync(ttyFd); } catch {}
|
||||
}
|
||||
process.stdout.write('QR terminal display stopped.\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Keyboard handling
|
||||
if (process.stdin.isTTY) {
|
||||
hideCursor();
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (key: string) => {
|
||||
// Keyboard handling — try /dev/tty first so it works even when stdin is a pipe
|
||||
let ttyFd: number | null = null;
|
||||
try {
|
||||
ttyFd = openSync('/dev/tty', 'rs');
|
||||
const stream = new ReadStream(ttyFd);
|
||||
stream.setRawMode(true);
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (key: string) => {
|
||||
if (key === 'q' || key === 'Q' || key === '\u0003') {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
stream.resume();
|
||||
hideCursor();
|
||||
} catch {
|
||||
ttyFd = null;
|
||||
if (process.stdin.isTTY) {
|
||||
hideCursor();
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (key: string) => {
|
||||
if (key === 'q' || key === 'Q' || key === '\u0003') {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
|
||||
@@ -15,14 +15,22 @@ const BLOCK_EMPTY = ' ';
|
||||
/**
|
||||
* Render a QR boolean matrix to terminal lines.
|
||||
* @param matrix 2-D array where true = dark module
|
||||
* @param quietZone number of white modules to pad on each side (default 4)
|
||||
* @returns Array of terminal strings (one per screen row)
|
||||
*/
|
||||
export function renderToTerminal(matrix: boolean[][]): string[] {
|
||||
export function renderToTerminal(matrix: boolean[][], quietZone: number = 4): string[] {
|
||||
const size = matrix.length;
|
||||
const totalWidth = size + quietZone * 2;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Top quiet zone — each terminal row covers 2 QR modules vertically
|
||||
const padRows = Math.ceil(quietZone / 2);
|
||||
for (let i = 0; i < padRows; i++) {
|
||||
lines.push(' '.repeat(totalWidth));
|
||||
}
|
||||
|
||||
for (let y = 0; y < size; y += 2) {
|
||||
let line = '';
|
||||
let line = ' '.repeat(quietZone);
|
||||
for (let x = 0; x < size; x++) {
|
||||
const top = matrix[y][x];
|
||||
const bottom = y + 1 < size ? matrix[y + 1][x] : false;
|
||||
@@ -37,9 +45,15 @@ export function renderToTerminal(matrix: boolean[][]): string[] {
|
||||
line += BLOCK_EMPTY;
|
||||
}
|
||||
}
|
||||
line += ' '.repeat(quietZone);
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
// Bottom quiet zone
|
||||
for (let i = 0; i < padRows; i++) {
|
||||
lines.push(' '.repeat(totalWidth));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Terminal Rasterizer', () => {
|
||||
it('should render a simple 2×2 matrix', async () => {
|
||||
it('should render a simple 2×2 matrix with default quiet zone', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
|
||||
// 2×2 matrix: all dark
|
||||
@@ -18,13 +18,17 @@ describe('Terminal Rasterizer', () => {
|
||||
];
|
||||
|
||||
const lines = renderToTerminal(matrix);
|
||||
// 2 rows → 1 terminal line (two QR rows per terminal row)
|
||||
expect(lines.length).toBe(1);
|
||||
// both QR rows dark → ██
|
||||
expect(lines[0]).toBe('\u2588\u2588');
|
||||
// default quietZone = 4
|
||||
// padRows = ceil(4/2) = 2, totalWidth = 2 + 8 = 10
|
||||
expect(lines.length).toBe(5); // 2 top + 1 QR + 2 bottom
|
||||
expect(lines[0]).toBe(' '.repeat(10));
|
||||
expect(lines[1]).toBe(' '.repeat(10));
|
||||
expect(lines[2]).toBe(' \u2588\u2588 '); // 4 spaces + ██ + 4 spaces
|
||||
expect(lines[3]).toBe(' '.repeat(10));
|
||||
expect(lines[4]).toBe(' '.repeat(10));
|
||||
});
|
||||
|
||||
it('should render mixed 4×4 matrix', async () => {
|
||||
it('should render mixed 4×4 matrix with default quiet zone', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
@@ -35,27 +39,31 @@ describe('Terminal Rasterizer', () => {
|
||||
];
|
||||
|
||||
const lines = renderToTerminal(matrix);
|
||||
expect(lines.length).toBe(2); // 4 QR rows → 2 terminal rows
|
||||
expect(lines.length).toBe(6); // 2 top + 2 QR + 2 bottom
|
||||
|
||||
// First QR line starts after 2 top rows (index 2)
|
||||
const qrLine0 = lines[2]!;
|
||||
expect(qrLine0.length).toBe(12); // 4 + 4 + 4
|
||||
|
||||
// Row 0: top=TG, bottom=FB for each column
|
||||
// Col 0: top=true, bottom=false → ▀ (U+2580)
|
||||
expect(lines[0]![0]).toBe('\u2580');
|
||||
expect(qrLine0[4]).toBe('\u2580');
|
||||
// Col 1: top=false, bottom=true → ▄ (U+2584)
|
||||
expect(lines[0]![1]).toBe('\u2584');
|
||||
expect(qrLine0[5]).toBe('\u2584');
|
||||
// Col 2: top=true, bottom=false → ▀
|
||||
expect(lines[0]![2]).toBe('\u2580');
|
||||
expect(qrLine0[6]).toBe('\u2580');
|
||||
// Col 3: top=false, bottom=true → ▄
|
||||
expect(lines[0]![3]).toBe('\u2584');
|
||||
expect(qrLine0[7]).toBe('\u2584');
|
||||
|
||||
// Row 1: top=row2[TG], bottom=row3[FB]
|
||||
// Second QR line (index 3)
|
||||
const qrLine1 = lines[3]!;
|
||||
// Col 0: top=true, bottom=false → ▀
|
||||
expect(lines[1]![0]).toBe('\u2580');
|
||||
expect(qrLine1[4]).toBe('\u2580');
|
||||
// Col 1: top=true, bottom=false → ▀
|
||||
expect(lines[1]![1]).toBe('\u2580');
|
||||
expect(qrLine1[5]).toBe('\u2580');
|
||||
// Col 2: top=false, bottom=true → ▄
|
||||
expect(lines[1]![2]).toBe('\u2584');
|
||||
expect(qrLine1[6]).toBe('\u2584');
|
||||
// Col 3: top=false, bottom=true → ▄
|
||||
expect(lines[1]![3]).toBe('\u2584');
|
||||
expect(qrLine1[7]).toBe('\u2584');
|
||||
});
|
||||
|
||||
it('should handle odd number of QR rows', async () => {
|
||||
@@ -69,19 +77,19 @@ describe('Terminal Rasterizer', () => {
|
||||
];
|
||||
|
||||
const lines = renderToTerminal(matrix);
|
||||
expect(lines.length).toBe(2); // 3 QR rows → 2 terminal rows
|
||||
expect(lines.length).toBe(6); // 2 top + 2 QR + 2 bottom
|
||||
|
||||
// Row 0: QR rows 0+1
|
||||
expect(lines[0]!.length).toBe(3);
|
||||
// First QR line (index 2): QR rows 0+1
|
||||
expect(lines[2]!.length).toBe(11); // 4 + 3 + 4
|
||||
|
||||
// Row 1: QR row 2 + bottom=false (out of bounds)
|
||||
expect(lines[1]!.length).toBe(3);
|
||||
// Second QR line (index 3): QR row 2 + bottom=false (out of bounds)
|
||||
expect(lines[3]!.length).toBe(11);
|
||||
// Col 0: top=true, bottom=false → ▀
|
||||
expect(lines[1]![0]).toBe('\u2580');
|
||||
expect(lines[3]![4]).toBe('\u2580');
|
||||
// Col 1: top=false, bottom=false → ' '
|
||||
expect(lines[1]![1]).toBe(' ');
|
||||
expect(lines[3]![5]).toBe(' ');
|
||||
// Col 2: top=true, bottom=false → ▀
|
||||
expect(lines[1]![2]).toBe('\u2580');
|
||||
expect(lines[3]![6]).toBe('\u2580');
|
||||
});
|
||||
|
||||
it('should render an all-white matrix', async () => {
|
||||
@@ -93,8 +101,8 @@ describe('Terminal Rasterizer', () => {
|
||||
];
|
||||
|
||||
const lines = renderToTerminal(matrix);
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toBe(' '); // space + space
|
||||
expect(lines.length).toBe(5);
|
||||
expect(lines[2]).toBe(' '.repeat(10));
|
||||
});
|
||||
|
||||
it('should render a full-block matrix', async () => {
|
||||
@@ -106,8 +114,37 @@ describe('Terminal Rasterizer', () => {
|
||||
];
|
||||
|
||||
const lines = renderToTerminal(matrix);
|
||||
expect(lines.length).toBe(5);
|
||||
expect(lines[2]).toBe(' \u2588\u2588 '); // full block + full block with quiet zone
|
||||
});
|
||||
|
||||
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);
|
||||
// quietZone=2, padRows=ceil(2/2)=1, totalWidth=2+4=6
|
||||
expect(lines.length).toBe(3); // 1 top + 1 QR + 1 bottom
|
||||
expect(lines[0]).toBe(' '.repeat(6));
|
||||
expect(lines[1]).toBe(' \u2588\u2588 '); // 2 spaces + ██ + 2 spaces
|
||||
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);
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toBe('\u2588\u2588'); // full block + full block
|
||||
expect(lines[0]).toBe('\u2588\u2588');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('Outer EC Benefit', () => {
|
||||
? `${(((framesNeededWithoutEC - framesNeededWithEC) / framesNeededWithoutEC) * 100).toFixed(1)}%`
|
||||
: 'N/A (without EC failed)',
|
||||
});
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('should recover when a whole source generation is missing', async () => {
|
||||
// Small payload: 3 source gens + 1 parity = 4 total
|
||||
|
||||
Reference in New Issue
Block a user