630 lines
29 KiB
Nim
630 lines
29 KiB
Nim
|
|
## termimg test — verifies protocol detection, geometry, scaling,
|
|||
|
|
## halfblock, quarterblock, dithering, and aspect-ratio preservation.
|
|||
|
|
##
|
|||
|
|
## Run: nim c -r --path:src tests/test_termimg.nim
|
|||
|
|
|
|||
|
|
import std/[strutils, math]
|
|||
|
|
import termimg
|
|||
|
|
|
|||
|
|
when isMainModule:
|
|||
|
|
echo "=== termimg Advanced Test Suite ==="
|
|||
|
|
|
|||
|
|
# ── Capabilities ──────────────────────────────────────────────
|
|||
|
|
let caps = detectCapabilities()
|
|||
|
|
echo "Terminal: ", caps.columns, "x", caps.rows, " cells"
|
|||
|
|
echo "Cell: ", caps.cell.width, "x", caps.cell.height, " px"
|
|||
|
|
echo "Protocols:"
|
|||
|
|
for proto in [ptKitty, ptIterm2, ptSixel, ptQuarterBlock, ptHalfBlock]:
|
|||
|
|
echo " ", proto, if caps.supports(proto): " ✓" else: " ✗"
|
|||
|
|
|
|||
|
|
# ── Build a test pattern ─────────────────────────────────────
|
|||
|
|
# 32x16 RGBA: gradient bands + sharp edges to test scaling quality
|
|||
|
|
const
|
|||
|
|
pw = 32
|
|||
|
|
ph = 16
|
|||
|
|
var testPixels = newSeq[uint8](pw * ph * 4)
|
|||
|
|
for y in 0 ..< ph:
|
|||
|
|
for x in 0 ..< pw:
|
|||
|
|
let off = (y * pw + x) * 4
|
|||
|
|
let r = uint8(255 * x div (pw - 1))
|
|||
|
|
let g = uint8(0)
|
|||
|
|
let b = uint8(255 * (pw - 1 - x) div (pw - 1))
|
|||
|
|
let brightness = uint8(128 + 127 * y div (ph - 1))
|
|||
|
|
testPixels[off + 0] = uint8((int(r) * int(brightness)) div 255)
|
|||
|
|
testPixels[off + 1] = uint8((int(g) * int(brightness)) div 255)
|
|||
|
|
testPixels[off + 2] = uint8((int(b) * int(brightness)) div 255)
|
|||
|
|
testPixels[off + 3] = 255'u8
|
|||
|
|
|
|||
|
|
# White cross for edge-testing
|
|||
|
|
let cx = pw div 2
|
|||
|
|
let cy = ph div 2
|
|||
|
|
for i in 0 ..< pw:
|
|||
|
|
let offH = (cy * pw + i) * 4
|
|||
|
|
testPixels[offH + 0] = 255; testPixels[offH + 1] = 255
|
|||
|
|
testPixels[offH + 2] = 255; testPixels[offH + 3] = 255
|
|||
|
|
for i in 0 ..< ph:
|
|||
|
|
let offV = (i * pw + cx) * 4
|
|||
|
|
testPixels[offV + 0] = 255; testPixels[offV + 1] = 255
|
|||
|
|
testPixels[offV + 2] = 255; testPixels[offV + 3] = 255
|
|||
|
|
|
|||
|
|
# ── Test 1: Default halfblock ────────────────────────────────
|
|||
|
|
echo "\n── Test 1: Half-block (default, contain fit) ──"
|
|||
|
|
block:
|
|||
|
|
let opts = defaultOptions()
|
|||
|
|
let geo = computeGeometry(pw, ph, caps, opts)
|
|||
|
|
echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight,
|
|||
|
|
" samples (", geo.pixelWidth, "x", geo.pixelHeight, " px)"
|
|||
|
|
let outStr = renderHalfBlock(
|
|||
|
|
ImageData(width: pw, height: ph, data: testPixels),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
let lines = outStr.split("\n")
|
|||
|
|
echo " output: ", lines.len, " lines, ",
|
|||
|
|
(if lines.len > 0: lines[0].len else: 0), " chars first line"
|
|||
|
|
|
|||
|
|
# ── Test 2: Quarter-block thumbnail ──────────────────────────
|
|||
|
|
echo "\n── Test 2: Quarter-block thumbnail ──"
|
|||
|
|
block:
|
|||
|
|
var opts = thumbnailOptions()
|
|||
|
|
let geo = computeGeometry(pw, ph, caps, opts)
|
|||
|
|
echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight, " samples"
|
|||
|
|
let outStr = renderQuarterBlock(
|
|||
|
|
ImageData(width: pw, height: ph, data: testPixels),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight, dither = true)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
let lines = outStr.split("\n")
|
|||
|
|
echo " output: ", lines.len, " lines, ",
|
|||
|
|
(if lines.len > 0: lines[0].len else: 0), " chars first line"
|
|||
|
|
|
|||
|
|
# ── Test 3: Full-density (bdFull) ────────────────────────────
|
|||
|
|
echo "\n── Test 3: Full-density (bdFull, 10 cols) ──"
|
|||
|
|
block:
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.density = bdFull
|
|||
|
|
opts.maxWidth = 10
|
|||
|
|
let geo = computeGeometry(pw, ph, caps, opts)
|
|||
|
|
echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight, " samples"
|
|||
|
|
let outStr = renderHalfBlock(
|
|||
|
|
ImageData(width: pw, height: ph, data: testPixels),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
let lines = outStr.split("\n")
|
|||
|
|
echo " output: ", lines.len, " lines"
|
|||
|
|
|
|||
|
|
# ── Test 4: Half-block with dithering ────────────────────────
|
|||
|
|
echo "\n── Test 4: Half-block with dithering ──"
|
|||
|
|
block:
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.dither = true
|
|||
|
|
opts.maxWidth = 20
|
|||
|
|
let geo = computeGeometry(pw, ph, caps, opts)
|
|||
|
|
echo " geometry: ", geo.columns, " cols x ", geo.sampleHeight, " samples"
|
|||
|
|
let outStr = renderHalfBlock(
|
|||
|
|
ImageData(width: pw, height: ph, data: testPixels),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight, dither = true)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
let lines = outStr.split("\n")
|
|||
|
|
echo " output: ", lines.len, " lines"
|
|||
|
|
|
|||
|
|
# ── Test 5: Aspect ratio preservation ────────────────────────
|
|||
|
|
echo "\n── Test 5: Aspect ratio ──"
|
|||
|
|
block:
|
|||
|
|
let geo = computeGeometry(640, 200, caps, defaultOptions())
|
|||
|
|
let ratio = float(geo.pixelWidth) / float(geo.pixelHeight)
|
|||
|
|
echo " 640x200 → ", geo.pixelWidth, "x", geo.pixelHeight,
|
|||
|
|
" px (ratio ", ratio.formatFloat(ffDecimal, 2), ", expected ~3.2)"
|
|||
|
|
|
|||
|
|
let geo2 = computeGeometry(200, 640, caps, defaultOptions())
|
|||
|
|
let ratio2 = float(geo2.pixelWidth) / float(geo2.pixelHeight)
|
|||
|
|
echo " 200x640 → ", geo2.pixelWidth, "x", geo2.pixelHeight,
|
|||
|
|
" px (ratio ", ratio2.formatFloat(ffDecimal, 2), ", expected ~0.31)"
|
|||
|
|
|
|||
|
|
let geo3 = computeGeometry(500, 500, caps, defaultOptions())
|
|||
|
|
let ratio3 = float(geo3.pixelWidth) / float(geo3.pixelHeight)
|
|||
|
|
echo " 500x500 → ", geo3.pixelWidth, "x", geo3.pixelHeight,
|
|||
|
|
" px (ratio ", ratio3.formatFloat(ffDecimal, 2), ", expected ~1.0)"
|
|||
|
|
|
|||
|
|
# ── Test 6: Fit modes ────────────────────────────────────────
|
|||
|
|
echo "\n── Test 6: Fit modes ──"
|
|||
|
|
block:
|
|||
|
|
for fit in [fmContain, fmStretch, fmWidth, fmOriginal, fmCellExact]:
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.fit = fit
|
|||
|
|
opts.maxWidth = 30
|
|||
|
|
let geo = computeGeometry(pw, ph, caps, opts)
|
|||
|
|
echo " ", fit, ": ", geo.pixelWidth, "x", geo.pixelHeight,
|
|||
|
|
" px, ", geo.columns, " cols, ", geo.sampleHeight, " samples"
|
|||
|
|
|
|||
|
|
# ── Test 7: Width-constrained render ─────────────────────────
|
|||
|
|
echo "\n── Test 7: Width-constrained ──"
|
|||
|
|
block:
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.maxWidth = 8
|
|||
|
|
opts.fit = fmWidth
|
|||
|
|
let geo = computeGeometry(pw, ph, caps, opts)
|
|||
|
|
echo " 32x16 forced to 8 cols → ", geo.columns, " cols x ",
|
|||
|
|
geo.sampleHeight, " samples"
|
|||
|
|
let outStr = renderHalfBlock(
|
|||
|
|
ImageData(width: pw, height: ph, data: testPixels),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
let lines = outStr.split("\n")
|
|||
|
|
echo " output: ", lines.len, " lines"
|
|||
|
|
|
|||
|
|
# ── Test 8: Bilinear scaling ────────────────────────────────
|
|||
|
|
echo "\n── Test 8: Bilinear scaling ──"
|
|||
|
|
block:
|
|||
|
|
let img = ImageData(width: pw, height: ph, data: testPixels)
|
|||
|
|
let scaled = bilinearResizeRgba(img, 64, 32)
|
|||
|
|
let expected = 64 * 32 * 4
|
|||
|
|
echo " ", pw, "x", ph, " → 64x32: ", scaled.len, " bytes (expected ", expected, ")"
|
|||
|
|
doAssert scaled.len == expected
|
|||
|
|
|
|||
|
|
# ── Test 9: Scaling down ─────────────────────────────────────
|
|||
|
|
echo "\n── Test 9: Scaling down ──"
|
|||
|
|
block:
|
|||
|
|
let img = ImageData(width: pw, height: ph, data: testPixels)
|
|||
|
|
let scaled = bilinearResizeRgba(img, 8, 4)
|
|||
|
|
let expected = 8 * 4 * 4
|
|||
|
|
echo " ", pw, "x", ph, " → 8x4: ", scaled.len, " bytes (expected ", expected, ")"
|
|||
|
|
doAssert scaled.len == expected
|
|||
|
|
|
|||
|
|
# ── Test 10: Rgba renderer pipeline ──────────────────────────
|
|||
|
|
echo "\n── Test 10: Full renderImageRgba pipeline ──"
|
|||
|
|
block:
|
|||
|
|
let opts = defaultOptions()
|
|||
|
|
let outStr = renderImageRgba(testPixels, pw, ph, caps, opts)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
let lines = outStr.split("\n")
|
|||
|
|
echo " output: ", lines.len, " lines"
|
|||
|
|
|
|||
|
|
# ── Test 11: thumbnailOptions preset ──────────────────────────
|
|||
|
|
echo "\n── Test 11: thumbnailOptions pipeline ──"
|
|||
|
|
block:
|
|||
|
|
let opts = thumbnailOptions()
|
|||
|
|
let outStr = renderImageRgba(testPixels, pw, ph, caps, opts)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
let lines = outStr.split("\n")
|
|||
|
|
echo " output: ", lines.len, " lines"
|
|||
|
|
|
|||
|
|
# ── Test 12: Zero dimensions ──────────────────────────────────
|
|||
|
|
echo "\n── Test 12: Zero dimensions (should not crash) ──"
|
|||
|
|
block:
|
|||
|
|
let out1 = renderImageRgba(testPixels, 0, ph, caps, defaultOptions())
|
|||
|
|
doAssert out1.len == 0, "zero width should return empty"
|
|||
|
|
let out2 = renderImageRgba(testPixels, pw, 0, caps, defaultOptions())
|
|||
|
|
doAssert out2.len == 0, "zero height should return empty"
|
|||
|
|
let out3 = renderImageRgba(newSeq[uint8](0), 0, 0, caps, defaultOptions())
|
|||
|
|
doAssert out3.len == 0, "zero dims empty data should return empty"
|
|||
|
|
echo " all zero-dim cases returned empty string (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 13: Short RGBA buffer ────────────────────────────────
|
|||
|
|
echo "\n── Test 13: Short RGBA buffer (should not crash) ──"
|
|||
|
|
block:
|
|||
|
|
let shortData = newSeq[uint8](4) # only one pixel claimed as 10x10
|
|||
|
|
let outStr = renderImageRgba(shortData, 10, 10, caps, defaultOptions())
|
|||
|
|
doAssert outStr.len == 0, "short buffer should return empty (invalid input)"
|
|||
|
|
echo " short buffer returned empty (safe): ", outStr.len, " chars (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 14: Negative dimensions ──────────────────────────────
|
|||
|
|
echo "\n── Test 14: Negative dimensions (should not crash) ──"
|
|||
|
|
block:
|
|||
|
|
let out1 = renderImageRgba(testPixels, -1, ph, caps, defaultOptions())
|
|||
|
|
doAssert out1.len == 0
|
|||
|
|
let out2 = renderImageRgba(testPixels, pw, -5, caps, defaultOptions())
|
|||
|
|
doAssert out2.len == 0
|
|||
|
|
echo " all negative-dim cases returned empty string (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 15: Non-terminal environment ─────────────────────────
|
|||
|
|
echo "\n── Test 15: Non-terminal environment (no crash) ──"
|
|||
|
|
block:
|
|||
|
|
let fakeCaps = TerminalCapabilities(
|
|||
|
|
columns: 0, rows: 0,
|
|||
|
|
cell: CellSize(width: 10, height: 20),
|
|||
|
|
protocols: {ptHalfBlock},
|
|||
|
|
)
|
|||
|
|
let geo = computeGeometry(pw, ph, fakeCaps, defaultOptions())
|
|||
|
|
doAssert geo.columns >= 1, "geometry must have at least 1 column"
|
|||
|
|
doAssert geo.sampleHeight >= 1, "geometry must have at least 1 sample row"
|
|||
|
|
echo " fake caps (0x0): ", geo.columns, " cols x ", geo.sampleHeight,
|
|||
|
|
" samples (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 16: Empty raw bytes ──────────────────────────────────
|
|||
|
|
echo "\n── Test 16: Empty raw bytes (should not crash) ──"
|
|||
|
|
block:
|
|||
|
|
let outStr = renderImageRaw(newSeq[uint8](0), 100, 100, caps, defaultOptions())
|
|||
|
|
echo " renderImageRaw returned ", outStr.len, " chars (no crash, pass)"
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# ADVANCED VISUAL TESTS — Gallery, columns, comparison grids
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
# ── Pattern generators ────────────────────────────────────────
|
|||
|
|
proc checkerboard(w, h, csize: int): seq[uint8] =
|
|||
|
|
result = newSeq[uint8](w * h * 4)
|
|||
|
|
for y in 0 ..< h:
|
|||
|
|
for x in 0 ..< w:
|
|||
|
|
let off = (y * w + x) * 4
|
|||
|
|
let bright = if ((x div csize) + (y div csize)) mod 2 == 0: 220 else: 40
|
|||
|
|
result[off + 0] = uint8(bright)
|
|||
|
|
result[off + 1] = uint8(bright)
|
|||
|
|
result[off + 2] = uint8(bright)
|
|||
|
|
result[off + 3] = 255
|
|||
|
|
|
|||
|
|
proc rainbowBars(w, h: int): seq[uint8] =
|
|||
|
|
result = newSeq[uint8](w * h * 4)
|
|||
|
|
for y in 0 ..< h:
|
|||
|
|
for x in 0 ..< w:
|
|||
|
|
let off = (y * w + x) * 4
|
|||
|
|
let band = float(x) / float(w) * 6.0
|
|||
|
|
let iband = int(band)
|
|||
|
|
let frac = band - float(iband)
|
|||
|
|
var r, g, b: float
|
|||
|
|
case iband
|
|||
|
|
of 0: (r, g, b) = (1.0, frac, 0.0)
|
|||
|
|
of 1: (r, g, b) = (1.0 - frac, 1.0, 0.0)
|
|||
|
|
of 2: (r, g, b) = (0.0, 1.0, frac)
|
|||
|
|
of 3: (r, g, b) = (0.0, 1.0 - frac, 1.0)
|
|||
|
|
of 4: (r, g, b) = (frac, 0.0, 1.0)
|
|||
|
|
else: (r, g, b) = (1.0, 0.0, 1.0 - frac)
|
|||
|
|
let ymul = 0.6 + 0.4 * float(y) / float(h)
|
|||
|
|
result[off + 0] = uint8(clamp(int(r * 255.0 * ymul), 0, 255))
|
|||
|
|
result[off + 1] = uint8(clamp(int(g * 255.0 * ymul), 0, 255))
|
|||
|
|
result[off + 2] = uint8(clamp(int(b * 255.0 * ymul), 0, 255))
|
|||
|
|
result[off + 3] = 255
|
|||
|
|
|
|||
|
|
proc targetCircle(w, h: int): seq[uint8] =
|
|||
|
|
result = newSeq[uint8](w * h * 4)
|
|||
|
|
let cx = float(w) / 2.0
|
|||
|
|
let cy = float(h) / 2.0
|
|||
|
|
let maxR = min(cx, cy)
|
|||
|
|
for y in 0 ..< h:
|
|||
|
|
for x in 0 ..< w:
|
|||
|
|
let off = (y * w + x) * 4
|
|||
|
|
let dx = float(x) - cx
|
|||
|
|
let dy = float(y) - cy
|
|||
|
|
let r = sqrt(dx * dx + dy * dy)
|
|||
|
|
let ring = int(r / (maxR / 5.0)) mod 2
|
|||
|
|
if ring == 0:
|
|||
|
|
result[off + 0] = 200; result[off + 1] = 50; result[off + 2] = 50
|
|||
|
|
else:
|
|||
|
|
result[off + 0] = 255; result[off + 1] = 255; result[off + 2] = 255
|
|||
|
|
result[off + 3] = 255
|
|||
|
|
|
|||
|
|
echo "\n═══════════════════════════════════════════════"
|
|||
|
|
echo " ADVANCED VISUAL TESTS"
|
|||
|
|
echo "═══════════════════════════════════════════════"
|
|||
|
|
|
|||
|
|
let patternW = 64
|
|||
|
|
let patternH = 48
|
|||
|
|
let chk = checkerboard(patternW, patternH, 8)
|
|||
|
|
let rnb = rainbowBars(patternW, patternH)
|
|||
|
|
let tgt = targetCircle(patternW, patternH)
|
|||
|
|
|
|||
|
|
# ── Test 17: Gallery — three patterns side-by-side ────────────
|
|||
|
|
echo "\n── Test 17: Gallery (three patterns, same width) ──"
|
|||
|
|
block:
|
|||
|
|
let colW = (caps.columns - 4) div 3
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.maxWidth = colW
|
|||
|
|
opts.fit = fmWidth
|
|||
|
|
let geo = computeGeometry(patternW, patternH, caps, opts)
|
|||
|
|
# Side-by-side: render left, mid, right and concatenate each row
|
|||
|
|
let outChk = renderHalfBlock(
|
|||
|
|
ImageData(width: patternW, height: patternH, data: chk),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight)
|
|||
|
|
let outRnb = renderHalfBlock(
|
|||
|
|
ImageData(width: patternW, height: patternH, data: rnb),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight)
|
|||
|
|
let outTgt = renderHalfBlock(
|
|||
|
|
ImageData(width: patternW, height: patternH, data: tgt),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight)
|
|||
|
|
let linesChk = outChk.split('\n')
|
|||
|
|
let linesRnb = outRnb.split('\n')
|
|||
|
|
let linesTgt = outTgt.split('\n')
|
|||
|
|
for i in 0 ..< min(min(linesChk.len, linesRnb.len), linesTgt.len):
|
|||
|
|
stdout.write(linesChk[i])
|
|||
|
|
stdout.write(" ")
|
|||
|
|
stdout.write(linesRnb[i])
|
|||
|
|
stdout.write(" ")
|
|||
|
|
stdout.write(linesTgt[i])
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
echo " gallery: ", geo.columns, " cols each × ",
|
|||
|
|
linesChk.len, " rows (3 across) (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 18: Column layout — same pattern at 3 densities ──────
|
|||
|
|
echo "\n── Test 18: Column layout (3 densities stacked) ──"
|
|||
|
|
block:
|
|||
|
|
let colW = caps.columns - 2
|
|||
|
|
for density in [bdFull, bdHalf, bdQuarter]:
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.density = density
|
|||
|
|
opts.maxWidth = colW
|
|||
|
|
opts.fit = fmWidth
|
|||
|
|
let geo = computeGeometry(patternW, patternH, caps, opts)
|
|||
|
|
let outStr = renderHalfBlock(
|
|||
|
|
ImageData(width: patternW, height: patternH, data: rnb),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
echo " density=$1: $2 cols × $3 samples (pass)" % [
|
|||
|
|
$density, $geo.columns, $geo.sampleHeight]
|
|||
|
|
echo " column layout: 3 densities stacked (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 19: Fit-mode comparison grid ─────────────────────────
|
|||
|
|
echo "\n── Test 19: Fit-mode comparison grid ──"
|
|||
|
|
block:
|
|||
|
|
let halfW = caps.columns div 2 - 1
|
|||
|
|
for fit in [fmContain, fmWidth, fmOriginal, fmStretch]:
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.fit = fit
|
|||
|
|
opts.maxWidth = halfW
|
|||
|
|
opts.density = bdQuarter
|
|||
|
|
let geo = computeGeometry(patternW, patternH, caps, opts)
|
|||
|
|
let outStr = renderQuarterBlock(
|
|||
|
|
ImageData(width: patternW, height: patternH, data: tgt),
|
|||
|
|
opts.backgroundRgb, geo.columns, geo.sampleHeight,
|
|||
|
|
dither = true)
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
echo " $1: $2 px ($3 cols × $4 samples) (pass)" % [
|
|||
|
|
$fit, $geo.pixelWidth & "x" & $geo.pixelHeight,
|
|||
|
|
$geo.columns, $geo.sampleHeight]
|
|||
|
|
echo " fit-mode grid: 4 fits stacked (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 20: Checkerboard at ultra-wide vs tall ───────────────
|
|||
|
|
echo "\n── Test 20: Extreme aspect ratios (wide vs tall) ──"
|
|||
|
|
block:
|
|||
|
|
var optsW = defaultOptions()
|
|||
|
|
optsW.maxWidth = caps.columns - 2
|
|||
|
|
optsW.fit = fmWidth
|
|||
|
|
let geoW = computeGeometry(patternW, patternH, caps, optsW)
|
|||
|
|
let outW = renderHalfBlock(
|
|||
|
|
ImageData(width: patternW, height: patternH, data: chk),
|
|||
|
|
optsW.backgroundRgb, geoW.columns, geoW.sampleHeight)
|
|||
|
|
stdout.write(outW)
|
|||
|
|
stdout.write("\n\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
# Tall: swap dimensions (64x48 becomes 48x64 via the pattern? no — use a tall pattern)
|
|||
|
|
let tallPixels = checkerboard(patternH, patternW, 6) # 48x64
|
|||
|
|
var optsT = defaultOptions()
|
|||
|
|
optsT.maxHeight = caps.rows - 4
|
|||
|
|
optsT.fit = fmContain
|
|||
|
|
let geoT = computeGeometry(patternH, patternW, caps, optsT)
|
|||
|
|
let outT = renderHalfBlock(
|
|||
|
|
ImageData(width: patternH, height: patternW, data: tallPixels),
|
|||
|
|
optsT.backgroundRgb, geoT.columns, geoT.sampleHeight)
|
|||
|
|
stdout.write(outT)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
echo " wide: ", geoW.columns, " cols × ", geoW.sampleHeight, " samples"
|
|||
|
|
echo " tall: ", geoT.columns, " cols × ", geoT.sampleHeight, " samples (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 21: Pipeline with all three patterns ─────────────────
|
|||
|
|
echo "\n── Test 21: Pipeline gallery (renderImageRgba) ──"
|
|||
|
|
block:
|
|||
|
|
for (name, pix) in [("checkerboard", chk), ("rainbow", rnb), ("target", tgt)]:
|
|||
|
|
let outStr = renderImageRgba(pix, patternW, patternH, caps, thumbnailOptions())
|
|||
|
|
stdout.write(outStr)
|
|||
|
|
stdout.write("\n")
|
|||
|
|
stdout.flushFile()
|
|||
|
|
echo " $1: $2 lines rendered (pass)" % [name, $(outStr.split('\n').len)]
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# WORST-CASE / CORRUPTION TESTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
echo "\n═══════════════════════════════════════════════"
|
|||
|
|
echo " WORST-CASE / CORRUPTION TESTS"
|
|||
|
|
echo "═══════════════════════════════════════════════"
|
|||
|
|
|
|||
|
|
# ── Test 22: Off-by-one data ──────────────────────────────────
|
|||
|
|
echo "\n── Test 22: Off-by-one data (should not crash) ──"
|
|||
|
|
block:
|
|||
|
|
# Exactly 1 byte short (data.len == w*h*4 - 1)
|
|||
|
|
let shortBy1 = newSeq[uint8](pw * ph * 4 - 1)
|
|||
|
|
let out1 = renderImageRgba(shortBy1, pw, ph, caps, defaultOptions())
|
|||
|
|
doAssert out1.len == 0,
|
|||
|
|
"1-byte-short buffer should return empty, got " & $out1.len & " chars"
|
|||
|
|
|
|||
|
|
# Exactly 1 byte extra (data.len == w*h*4 + 1)
|
|||
|
|
let extra1 = newSeq[uint8](pw * ph * 4 + 1)
|
|||
|
|
let out2 = renderImageRgba(extra1, pw, ph, caps, defaultOptions())
|
|||
|
|
# Extra byte is harmless — we only read w*h*4 from front
|
|||
|
|
doAssert out2.len > 0 or caps.protocols == {ptHalfBlock},
|
|||
|
|
"1-byte-extra buffer should render normally (output len=" & $out2.len & ")"
|
|||
|
|
|
|||
|
|
echo " 1 byte short → empty (safe), 1 byte extra → renders (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 23: Tiny images ──────────────────────────────────────
|
|||
|
|
echo "\n── Test 23: Tiny images (1x1, 10x1, 1x10) ──"
|
|||
|
|
block:
|
|||
|
|
# 1x1
|
|||
|
|
let px1 = @[128'u8, 64, 192, 255]
|
|||
|
|
let out1 = renderImageRgba(px1, 1, 1, caps, defaultOptions())
|
|||
|
|
doAssert out1.len > 0 or caps.protocols == {ptHalfBlock},
|
|||
|
|
"1x1 pixel should produce output, got " & $out1.len & " chars"
|
|||
|
|
|
|||
|
|
# 10x1 horizontal strip
|
|||
|
|
var strip = newSeq[uint8](10 * 1 * 4)
|
|||
|
|
for x in 0 ..< 10:
|
|||
|
|
strip[(x) * 4 + 0] = uint8(x * 25)
|
|||
|
|
strip[(x) * 4 + 1] = uint8(255 - x * 25)
|
|||
|
|
strip[(x) * 4 + 2] = 128
|
|||
|
|
strip[(x) * 4 + 3] = 255
|
|||
|
|
let out2 = renderImageRgba(strip, 10, 1, caps, defaultOptions())
|
|||
|
|
doAssert out2.len > 0 or caps.protocols == {ptHalfBlock},
|
|||
|
|
"10x1 strip should produce output, got " & $out2.len & " chars"
|
|||
|
|
|
|||
|
|
# 1x10 vertical strip
|
|||
|
|
var vstrip = newSeq[uint8](1 * 10 * 4)
|
|||
|
|
for y in 0 ..< 10:
|
|||
|
|
let off = y * 4
|
|||
|
|
vstrip[off + 0] = 64
|
|||
|
|
vstrip[off + 1] = uint8(y * 25)
|
|||
|
|
vstrip[off + 2] = 192
|
|||
|
|
vstrip[off + 3] = 255
|
|||
|
|
let out3 = renderImageRgba(vstrip, 1, 10, caps, defaultOptions())
|
|||
|
|
doAssert out3.len > 0 or caps.protocols == {ptHalfBlock},
|
|||
|
|
"1x10 strip should produce output, got " & $out3.len & " chars"
|
|||
|
|
|
|||
|
|
echo " 1x1, 10x1, 1x10 all rendered (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 24: Fully transparent image ──────────────────────────
|
|||
|
|
echo "\n── Test 24: Fully transparent image (alpha=0 everywhere) ──"
|
|||
|
|
block:
|
|||
|
|
var transparent = newSeq[uint8](16 * 16 * 4)
|
|||
|
|
for y in 0 ..< 16:
|
|||
|
|
for x in 0 ..< 16:
|
|||
|
|
let off = (y * 16 + x) * 4
|
|||
|
|
transparent[off + 0] = 255
|
|||
|
|
transparent[off + 1] = 0
|
|||
|
|
transparent[off + 2] = 0
|
|||
|
|
transparent[off + 3] = 0 # fully transparent
|
|||
|
|
let outStr = renderImageRgba(transparent, 16, 16, caps, defaultOptions())
|
|||
|
|
# Transparency composited over white background should produce white-ish output
|
|||
|
|
doAssert outStr.len > 0 or caps.protocols == {ptHalfBlock},
|
|||
|
|
"transparent image should not crash, got " & $outStr.len & " chars"
|
|||
|
|
echo " transparent 16x16 rendered (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 25: Monochrome image ─────────────────────────────────
|
|||
|
|
echo "\n── Test 25: Monochrome image (all same color) ──"
|
|||
|
|
block:
|
|||
|
|
var mono = newSeq[uint8](32 * 24 * 4)
|
|||
|
|
for i in countup(0, mono.len - 1, 4):
|
|||
|
|
mono[i + 0] = 100; mono[i + 1] = 150; mono[i + 2] = 200; mono[i + 3] = 255
|
|||
|
|
let outStr = renderImageRgba(mono, 32, 24, caps, defaultOptions())
|
|||
|
|
let lines = outStr.split('\n')
|
|||
|
|
doAssert lines.len >= 1, "monochrome should produce lines, got " & $lines.len
|
|||
|
|
echo " monochrome 32x24: ", lines.len, " lines (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 26: Extreme opts values ──────────────────────────────
|
|||
|
|
echo "\n── Test 26: Extreme opts (maxWidth=0, maxWidth=-1, maxHeight=0) ──"
|
|||
|
|
block:
|
|||
|
|
var opts0 = defaultOptions()
|
|||
|
|
opts0.maxWidth = 0
|
|||
|
|
let out1 = renderImageRgba(chk, patternW, patternH, caps, opts0)
|
|||
|
|
doAssert out1.len >= 0, "maxWidth=0 should not crash"
|
|||
|
|
|
|||
|
|
var optsNeg = defaultOptions()
|
|||
|
|
optsNeg.maxWidth = -1
|
|||
|
|
let out2 = renderImageRgba(chk, patternW, patternH, caps, optsNeg)
|
|||
|
|
doAssert out2.len >= 0, "maxWidth=-1 should not crash"
|
|||
|
|
|
|||
|
|
var optsH0 = defaultOptions()
|
|||
|
|
optsH0.maxHeight = 0
|
|||
|
|
let out3 = renderImageRgba(chk, patternW, patternH, caps, optsH0)
|
|||
|
|
doAssert out3.len >= 0, "maxHeight=0 should not crash"
|
|||
|
|
|
|||
|
|
echo " all extreme opts returned safely (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 27: Empty protocol set ───────────────────────────────
|
|||
|
|
echo "\n── Test 27: Empty protocol set (selectProtocol) ──"
|
|||
|
|
block:
|
|||
|
|
let emptyCaps = TerminalCapabilities(
|
|||
|
|
columns: 80, rows: 24,
|
|||
|
|
cell: CellSize(width: 10, height: 20),
|
|||
|
|
protocols: {}, # no protocols at all
|
|||
|
|
)
|
|||
|
|
let selected = selectProtocol(emptyCaps, ptHalfBlock)
|
|||
|
|
doAssert selected == ptHalfBlock,
|
|||
|
|
"selectProtocol should fall back to ptHalfBlock, got " & $selected
|
|||
|
|
let selected2 = selectProtocol(emptyCaps, ptAuto)
|
|||
|
|
doAssert selected2 == ptHalfBlock,
|
|||
|
|
"selectProtocol(ptAuto) with empty set should fall back to ptHalfBlock, got " & $selected2
|
|||
|
|
echo " empty protocols fall back to half-block (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 28: 1-pixel-high / 1-pixel-wide aspect extremes ─────
|
|||
|
|
echo "\n── Test 28: Extreme aspect (200x1 and 1x200) ──"
|
|||
|
|
block:
|
|||
|
|
var thinW = newSeq[uint8](200 * 1 * 4)
|
|||
|
|
for x in 0 ..< 200:
|
|||
|
|
thinW[x * 4 + 0] = uint8(x)
|
|||
|
|
thinW[x * 4 + 1] = uint8(255 - x)
|
|||
|
|
thinW[x * 4 + 2] = 128
|
|||
|
|
thinW[x * 4 + 3] = 255
|
|||
|
|
let out1 = renderImageRgba(thinW, 200, 1, caps, defaultOptions())
|
|||
|
|
doAssert out1.len >= 0, "200x1 image should not crash"
|
|||
|
|
|
|||
|
|
var thinH = newSeq[uint8](1 * 200 * 4)
|
|||
|
|
for y in 0 ..< 200:
|
|||
|
|
thinH[y * 4 + 0] = 64
|
|||
|
|
thinH[y * 4 + 1] = uint8(y)
|
|||
|
|
thinH[y * 4 + 2] = 192
|
|||
|
|
thinH[y * 4 + 3] = 255
|
|||
|
|
let out2 = renderImageRgba(thinH, 1, 200, caps, defaultOptions())
|
|||
|
|
doAssert out2.len >= 0, "1x200 image should not crash"
|
|||
|
|
|
|||
|
|
echo " 200x1 and 1x200 rendered without crash (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 29: Zero background and high-dither stress ───────────
|
|||
|
|
echo "\n── Test 29: Zero background + dither on black ──"
|
|||
|
|
block:
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.backgroundRgb = (0'u8, 0'u8, 0'u8) # pure black bg
|
|||
|
|
opts.dither = true
|
|||
|
|
opts.density = bdQuarter
|
|||
|
|
opts.fit = fmContain
|
|||
|
|
let outStr = renderImageRgba(tgt, patternW, patternH, caps, opts)
|
|||
|
|
doAssert outStr.len >= 0, "zero bg + dither should not crash"
|
|||
|
|
echo " zero background + dither on target circles (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 30: Random-stress renders (20 iterations) ────────────
|
|||
|
|
echo "\n── Test 30: Random-stress (20 renders at random sizes / fit modes) ──"
|
|||
|
|
block:
|
|||
|
|
let sizes = [(1,1), (2,2), (3,5), (7,3), (10,10), (16,16),
|
|||
|
|
(32,48), (64,64), (100,50), (50,100)]
|
|||
|
|
let fits = [fmContain, fmStretch, fmWidth, fmOriginal, fmCellExact]
|
|||
|
|
let densities = [bdFull, bdHalf, bdQuarter]
|
|||
|
|
var rng = 42 # deterministic pseudo-random
|
|||
|
|
proc nextRand(maxVal: int): int =
|
|||
|
|
rng = (rng * 1103515245 + 12345) and 0x7fffffff
|
|||
|
|
result = rng mod maxVal
|
|||
|
|
for iter in 0 ..< 20:
|
|||
|
|
let (sw, sh) = sizes[nextRand(sizes.len)]
|
|||
|
|
var pixels = newSeq[uint8](sw * sh * 4)
|
|||
|
|
for i in countup(0, pixels.len - 1, 4):
|
|||
|
|
pixels[i + 0] = uint8(nextRand(256))
|
|||
|
|
pixels[i + 1] = uint8(nextRand(256))
|
|||
|
|
pixels[i + 2] = uint8(nextRand(256))
|
|||
|
|
pixels[i + 3] = 255
|
|||
|
|
var opts = defaultOptions()
|
|||
|
|
opts.fit = fits[nextRand(fits.len)]
|
|||
|
|
opts.density = densities[nextRand(densities.len)]
|
|||
|
|
opts.maxWidth = 10 + nextRand(40)
|
|||
|
|
opts.dither = nextRand(2) == 0
|
|||
|
|
let outStr = renderImageRgba(pixels, sw, sh, caps, opts)
|
|||
|
|
if outStr.len > 0:
|
|||
|
|
discard
|
|||
|
|
echo " 20 random renders completed without crash (pass)"
|
|||
|
|
|
|||
|
|
# ── Test 31: Tiny data + huge claimed dimensions ──────────────
|
|||
|
|
echo "\n── Test 31: Tiny buffer claimed as huge image ──"
|
|||
|
|
block:
|
|||
|
|
let tiny = @[255'u8, 0, 0, 255] # 1 pixel, claim as 1000x1000
|
|||
|
|
let out1 = renderImageRgba(tiny, 1000, 1000, caps, defaultOptions())
|
|||
|
|
doAssert out1.len == 0,
|
|||
|
|
"tiny buffer claimed as huge should return empty, got " & $out1.len & " chars"
|
|||
|
|
let out2 = renderImageRgba(tiny, 999999, 999999, caps, defaultOptions())
|
|||
|
|
doAssert out2.len == 0,
|
|||
|
|
"tiny buffer claimed as absurdly huge should return empty, got " & $out2.len & " chars"
|
|||
|
|
echo " tiny data + huge/silly dimensions → empty (pass)"
|
|||
|
|
|
|||
|
|
echo "\n=== All 31 tests passed ==="
|