diff --git a/src/cli/qr-terminal.ts b/src/cli/qr-terminal.ts index 6d6caf6..f9504e4 100644 --- a/src/cli/qr-terminal.ts +++ b/src/cli/qr-terminal.ts @@ -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); diff --git a/src/cli/terminal_raster.ts b/src/cli/terminal_raster.ts index 79ce48c..5fea2a1 100644 --- a/src/cli/terminal_raster.ts +++ b/src/cli/terminal_raster.ts @@ -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; } diff --git a/src/tests/cli_rendering.test.ts b/src/tests/cli_rendering.test.ts index fce2448..8f0e10d 100644 --- a/src/tests/cli_rendering.test.ts +++ b/src/tests/cli_rendering.test.ts @@ -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'); }); }); diff --git a/src/tests/outer_ec_benefit.test.ts b/src/tests/outer_ec_benefit.test.ts index cddcc1d..cdf4004 100644 --- a/src/tests/outer_ec_benefit.test.ts +++ b/src/tests/outer_ec_benefit.test.ts @@ -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