rizin/test/unit/test_cons_histogram.c
NOT XVilka c9a372c333
librz/cons/histogram: revamp visual horizontal histogram (#6365, #4431) (#6440)
This rewrites the look of the interactive horizontal histogram in Rizin -
the output of `p==v`, `p==ev`, `p==mv`, `p==0v`, `p==fv`, `p==pv`, `p==zv`,
`p==Sv` and the analysis-based interactive variants. It closes #6365 and
#4431 in full, and now sits on top of the recently merged static-histogram
PR (#6427) so it reuses the new RzHistogramOptions struct and its helpers.

Five sub-tasks from #6365:
  - Context-aware vertical ruler with up to 5 anchor labels (top, bottom,
    quarter, half, three-quarter) honouring opts->value_min..value_max,
    value_precision, value_scale and value_unit. Reuses the static
    helpers compute_ruler_gutter, render_ruler_gutter, label_value_at and
    select_ruler_label_rows so visual and static stay consistent.
  - X-axis byte-offset ruler at the bottom: `^` ticks every (addr_w + 3)
    cols followed by absolute byte offsets computed from opts->offpos +
    realj * blocksize.
  - Cursor offset + percentage shown right-aligned on the status line, with
    "Index N data V" on the left.
  - Top minimap: Unicode block characters (▁▂▃▄▅▆▇█) for the density of
    each slice of the whole data, plus a ┏━━━┓ window indicator showing the
    visible slice. Always rendered when the new `scr.hist.minimap` config
    is true. When the terminal is wide (hist->w > 200) the minimap shrinks
    by 43 columns to make room for a two-line `px 0x20`-style hex preview
    panel on the right showing the 32 bytes at the cursor's file offset.
    Each byte is wrapped in its px-style colour code (b0x00 / b0x7f /
    b0xff / btext / other) when opts->color is set, matching rz_print's
    rz_print_byte_color exactly. A trailing safety pad keeps the canvas
    from clipping the last byte of the first hex row.
  - Missing-half bug on `p==v` when the cursor is at offset 0 fixed by
    clamping `adder` to [0, histogramwidth - span]. The old expression
    `barnumber + 1 - histogramwidth/(zoom*2)` was always negative for the
    default barnumber=0, causing the rendering loop to read `data[-N]`
    (segfault on large files, missing left half on small ones).

Cursor visibility:
  - The cursor column is drawn as a CONTINUOUS vertical line connecting two
    plain markers at the top and bottom, ALWAYS exactly one character wide.
    The line itself uses the dedicated `wordhl` palette colour (default
    red background, configurable via `ec wordhl ...`), drawn on every
    chart row so the cursor is always a full-height vertical strip. The
    markers (`▼` at the top and `▲` at the bottom, or ASCII `v` / `^`
    when scr.utf8=false) are intentionally left un-highlighted so they
    read as a clean pair of arrows pointing at the cursor column.
  - The cursor screen column is computed up-front (j_cursor) by inverting
    the data-to-column map (rel * zoom * width / histogramwidth). Two
    distinct widening bugs are avoided this way:
      1. sizeofonebar > 1 (high zoom) - each data index spans several
         screen columns; only j == j_cursor && kbar == 0 renders as the
         cursor, the remaining kbar columns fall through to the gradient.
      2. histogramwidth < width (chart much wider than data) - several
         adjacent screen columns map to the same data index via integer
         truncation; only the j_cursor column may render as the cursor.

Interactive keybindings & live config:
  - The `:` hotkey drops into rz_core_visual_prompt_input, matching the
    rest of Rizin's visual modes. Lets the user run arbitrary rizin
    commands without leaving the histogram.
  - The `?` help text now uses the same colour-coded format as the
    visual / visual-bit-editor modes (rz_core_visual_append_help with
    pal.args for keys and pal.help for descriptions), shown via
    rz_cons_less_str.
  - The config-driven opts (scr.hist.minimap, scr.hist.block, scr.utf8,
    scr.color, hex.offset) are re-read on every redraw via
    refresh_visual_opts_from_config, so `:` `e scr.hist.minimap=true`
    <Enter> takes effect immediately without having to quit and re-enter.
    The canvas's `color` field is refreshed alongside so `scr.color`
    changes take effect on the same redraw.

Hex preview panel:
  - When the terminal is wide (hist->w > 200) and the minimap is enabled,
    the visual mode shows a two-line hex preview on the right of the
    minimap rows: 32 bytes at the cursor's file offset, formatted as 8
    pairs of 2 bytes separated by spaces (`abcd ef00 1234 5678 ...`),
    matching `px 0x20` minus the header / offsets / ASCII column. The
    bytes are fetched live via rz_io_read_at_mapped each redraw, so
    moving the cursor (`h` / `l`) updates the preview.
  - Each byte gets its px-style colour code: green for 0x00, red for
    0xff, yellow for 0x7f, btext (white) for printable ASCII, "other"
    (magenta) for non-printable. Mirrors rz_print_byte_color so the
    histogram preview reads consistently with `px`.
  - A trailing safety pad keeps the canvas from clipping the last hex
    byte of the first row (a side effect of UTF-8 minimap glyphs
    interacting with the canvas's width tracking when the row fills
    the canvas exactly).
  - Implemented via two new fields on RzHistogramInteractive
    (`cursor_bytes`, `cursor_bytes_len`) that the caller fills in just
    before the render call and clears right after. The minimap helper
    grows two extra parameters that the visual function passes through;
    when the panel is disabled (narrow terminal, no cursor_bytes, or
    shrinking the minimap would leave it < 40 cols) the helper falls
    back to the previous full-width minimap.

Closes #4431 in full:
  - The negative-offset crash above is the immediate segfault from the
    bug report.
  - `print_histogram_bytes` now samples one byte per block instead of
    reading nblocks contiguous bytes from core->offset. For an 8 GB file
    shown across 80 bars the original code rendered the first 80 bytes
    of the file; the new code samples at offsets brange->from + i *
    blocksize so the chart represents the full span.
  - The inner `int i` in the column-aggregation loop is renamed to `k`
    to drop the shadow over the outer `size_t i`.

Refactor on the cmd_print.c side:
  - New default_visual_opts(core, offset) returns an RZ_OWN
    RzHistogramOptions* pre-populated for the visual commands (ruler=true,
    minimap from scr.hist.minimap, offpos from caller, palette and
    screen-mode toggles from config via refresh_visual_opts_from_config).
    The nine print_visual_bytes call sites now build opts via this helper,
    then pass it to print_visual_bytes which takes ownership. Entropy
    sets value_max=8 / value_precision=1 / data_f=fdata so the visual
    histogram shows the Shannon range matching the static side.
  - `print_visual_bytes(core, opts, data, brange)` now propagates opts
    cleanup along every error path; rz_histogram_interactive_new no
    longer leaves a heap-allocated opts pointer dangling. The redraw loop
    fetches 32 cursor bytes via rz_io_read_at_mapped, points
    hist->cursor_bytes at a stack buffer for the call, then NULLs it
    back so the next iteration's fetch is independent.
  - RzHistogramInteractive gains `blocksize`, `cursor_bytes` and
    `cursor_bytes_len` fields.

New config option:
  - `scr.hist.minimap` (bool, default true) controls whether the top
    minimap is shown for p==v / p==ev. Surfaces as `opts->minimap` and
    is honoured by `rz_histogram_interactive_horizontal`. When true,
    the minimap is ALWAYS rendered (provided there's room) - even when
    the chart already shows the full data, in which case the window
    indicator spans the whole map. Changes via `:` `e scr.hist.minimap=...`
    <Enter> are picked up on the very next redraw.

Tests (33 total, 10 new for the visual side):
  - test_histogram_interactive_horizontal_basic - smoke test with
    barnumber=0 (pins the #4431 crash regression).
  - test_histogram_interactive_horizontal_ruler_percent - fractional
    labels with value_max=100 / value_scale=0.01 / unit="%".
  - test_histogram_interactive_horizontal_ruler_default - the legacy
    0..255 byte ruler.
  - test_histogram_interactive_horizontal_no_negative_adder - covers
    `p==v` at offset 0 on a small data set.
  - test_histogram_interactive_horizontal_percent - status-line percent
    indicator present.
  - test_histogram_interactive_horizontal_cursor_markers - the ▼/▲
    cursor markers (and ASCII v/^ fallback) appear on the cursor column,
    left un-highlighted.
  - test_histogram_interactive_horizontal_cursor_full_line - the cursor
    bar is rendered on every chart row between the markers regardless
    of the data threshold (continuous vertical line).
  - test_histogram_interactive_horizontal_cursor_width - pins single-
    char width across BOTH cursor-widening bugs: high zoom (sizeofonebar
    > 1) AND chart wider than data (histogramwidth < width).
  - test_histogram_interactive_horizontal_minimap_toggle - pins the
    scr.hist.minimap gating across {zoomed, not zoomed} when
    opts->minimap=true / =false.
  - test_histogram_interactive_horizontal_hex_preview - 4 cases: wide
    terminal + cursor_bytes shows the hex panel; narrow terminal
    suppresses it; missing cursor_bytes suppresses it; colour mode
    emits ANSI escape sequences for the bytes.
  Both p== integration tests in test/db/cmd/cmd_print pass with their
  regenerated EXPECT blocks (the per-block sampling change moves the
  visible bars for small buffers).

Co-authored-by: Anton Kochkov <anton.kochkov@gmail.com>
2026-06-07 14:00:31 +08:00

1185 lines
42 KiB
C

// SPDX-FileCopyrightText: 2026 Maijin <maijin21@gmail.com>
// SPDX-License-Identifier: LGPL-3.0-only
#include <rz_cons.h>
#include "minunit.h"
bool test_histogram_horizontal(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = true;
ut8 data[] = { 50, 200 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 2, 2);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
// Ruler labels are now inclusive: top row -> vmax (255), bottom -> vmin (0).
mu_assert_true(strstr(res, "255|") != NULL, "Top ruler label = vmax");
mu_assert_true(strstr(res, "0|") != NULL, "Bottom ruler label = vmin");
mu_assert_true(strstr(res, "_") != NULL, "Baseline marker present in bottom row");
free(res);
rz_cons_free();
mu_end;
}
bool test_histogram_vertical(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
ut8 data[] = { 255, 0 };
RzStrBuf *buf = rz_histogram_vertical(&opts, data, 2, 5);
char *res = rz_strbuf_drain(buf);
// Vertical histogram for 255 and 0 should have one full column and one empty
mu_assert_notnull(res, "Histogram buffer should not be null");
mu_assert_true(strstr(res, "#") != NULL, "Blocks present for data 255");
free(res);
rz_cons_free();
mu_end;
}
// When value_max is set, the ruler labels must show that range (and unit)
// rather than the legacy 0..255 byte scale (issue #5290).
bool test_histogram_horizontal_ruler_percent(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 100;
opts.value_unit = "%";
ut8 data[] = { 255, 128, 64, 0 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 4, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
mu_assert_true(strstr(res, "100%|") != NULL, "Top ruler label uses value_max + unit");
// Legacy 0..255 labels must not leak in when value_max is set.
mu_assert_true(strstr(res, " 255|") == NULL, "Legacy 255 label absent");
free(res);
rz_cons_free();
mu_end;
}
// When value_max is left at 0, the ruler must fall back to the historical
// 0..255 scale (backwards compatibility for callers that did not opt in).
bool test_histogram_horizontal_ruler_default(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = true;
ut8 data[] = { 255, 0 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 2, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
mu_assert_true(strstr(res, "255|") != NULL, "Default 0..255 ruler still shows 255 at top");
free(res);
rz_cons_free();
mu_end;
}
// With value_max=100 and storage 0..255, a datum at the storage peak (255)
// must reach the very top of the chart. This guards the "blank top rows"
// behaviour cer-0 raised in #5290 against regressing.
bool test_histogram_horizontal_top_row_filled(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.thinline = false;
opts.ruler = false;
opts.value_min = 0;
opts.value_max = 100;
ut8 data[] = { 255 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 1, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
// The first rendered row (the top of the chart) must contain a filled
// block, not just whitespace.
const char *first_newline = strchr(res, '\n');
mu_assert_notnull(first_newline, "Output should have at least one row");
bool top_row_has_block = false;
for (const char *p = res; p < first_newline; p++) {
if (*p == '#') {
top_row_has_block = true;
break;
}
}
mu_assert_true(top_row_has_block, "Top row reached for storage peak with value_max=100");
free(res);
rz_cons_free();
mu_end;
}
// scr.hist.width opt-in: when opts->cols is set, the rendered width follows it
// instead of the legacy 78 column default.
bool test_histogram_horizontal_cols_override(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = false;
opts.cols = 20;
ut8 data[] = { 128 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 1, 2);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
// The first line should be exactly 20 chars wide (not 78).
const char *nl = strchr(res, '\n');
mu_assert_notnull(nl, "Output must have at least one row");
mu_assert_eq((int)(nl - res), 20, "Custom cols=20 honoured");
free(res);
rz_cons_free();
mu_end;
}
// Sparse Y-axis labels: a tall histogram must not carry one label per row
// (the old behaviour). At most a small number of labels should appear, with
// the top and bottom values always included.
bool test_histogram_horizontal_sparse_labels(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = true;
opts.cols = 10;
ut8 data[] = { 200 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 1, 20);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
// Count how many rows carry a numeric label by counting how many lines
// start with a digit (ignoring the leading whitespace gutter).
int label_count = 0;
int line_count = 0;
const char *p = res;
while (*p) {
const char *eol = strchr(p, '\n');
if (!eol) {
break;
}
line_count++;
// Find first non-space character.
const char *q = p;
while (q < eol && *q == ' ') {
q++;
}
if (q < eol && *q >= '0' && *q <= '9') {
label_count++;
}
p = eol + 1;
}
mu_assert_true(line_count >= 20, "At least 20 body rows produced");
mu_assert_true(label_count >= 2, "At least two label rows (top + bottom)");
mu_assert_true(label_count <= 6, "Far fewer label rows than body rows (sparse)");
free(res);
rz_cons_free();
mu_end;
}
// Floating-point ruler labels: with value_scale=0.01 and value_precision=2,
// an integer value range 0..100 must render as decimal labels 0.00..1.00.
bool test_histogram_horizontal_float_labels(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 100;
opts.value_scale = 0.01;
opts.value_precision = 2;
ut8 data[] = { 255 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 1, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
mu_assert_true(strstr(res, "1.00|") != NULL, "Top label uses 2 decimal digits");
mu_assert_true(strstr(res, "0.00|") != NULL, "Bottom label uses 2 decimal digits");
free(res);
rz_cons_free();
mu_end;
}
// X-axis offset ruler: when blocksize is set, the output must include
// "^" tick markers and absolute byte offset labels at the bottom.
bool test_histogram_horizontal_xaxis_offsets(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = true;
opts.offpos = 0x1000;
opts.blocksize = 0x10;
opts.cols = 60;
ut8 data[] = { 50, 100, 150, 200 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 4, 6);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
mu_assert_true(strstr(res, "0x1000") != NULL, "Start offset label present");
mu_assert_true(strstr(res, "0x1030") != NULL, "End offset label present");
mu_assert_true(strstr(res, "^") != NULL, "Tick characters present");
free(res);
rz_cons_free();
mu_end;
}
// Floating-point data input: when opts->data_f is supplied alongside
// value_precision > 0, the ut8 \p data argument is ignored and the chart
// uses the fp values directly. Two entropies that would round to the same
// ut8 should still be distinguishable.
bool test_histogram_horizontal_data_f(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.color = false;
opts.unicode = false;
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 1;
opts.value_precision = 2;
// Two entropies that both round to ut8 127, but are distinct as doubles.
double fdata[] = { 0.501, 0.499 };
opts.data_f = fdata;
// Pass a dummy ut8 buffer that, if the function ignored data_f, would
// produce a different chart shape.
ut8 dummy[] = { 0, 0 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, dummy, 2, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Histogram buffer should not be null");
// Top label is 1.00 (vmax), bottom is 0.00 (vmin).
mu_assert_true(strstr(res, "1.00|") != NULL, "Top label = vmax with precision");
mu_assert_true(strstr(res, "0.00|") != NULL, "Bottom label = vmin with precision");
// The dummy ut8 zero data would render an empty chart; data_f at 0.5
// must instead produce filled cells. Look for any block character.
mu_assert_true(strstr(res, "#") != NULL, "fp data renders bars (ut8 was ignored)");
free(res);
rz_cons_free();
mu_end;
}
// Multi-character value_unit suffix (e.g. "MB", "ms") must fit in the gutter
// alongside the numeric label.
bool test_histogram_horizontal_multi_char_unit(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 100;
opts.value_unit = "MB";
ut8 data[] = { 200, 150, 100, 50 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 4, 6);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "100MB|") != NULL, "Top label uses multi-char unit");
mu_assert_true(strstr(res, "0MB|") != NULL, "Bottom label uses multi-char unit");
free(res);
rz_cons_free();
mu_end;
}
// value_precision=1 (one decimal) and value_precision=3 (three decimals)
// both format correctly.
bool test_histogram_horizontal_precision_variants(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 10;
opts.value_precision = 1;
ut8 data[] = { 100, 200 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 2, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_true(strstr(res, "10.0|") != NULL, "Precision=1 top label");
mu_assert_true(strstr(res, "0.0|") != NULL, "Precision=1 bottom label");
free(res);
opts.value_precision = 3;
opts.value_max = 1;
opts.value_scale = 1.0;
buf = rz_histogram_horizontal(&opts, data, 2, 4);
res = rz_strbuf_drain(buf);
mu_assert_true(strstr(res, "1.000|") != NULL, "Precision=3 top label");
mu_assert_true(strstr(res, "0.000|") != NULL, "Precision=3 bottom label");
free(res);
rz_cons_free();
mu_end;
}
// value_scale rescales the integer label value (e.g. milliseconds → seconds).
bool test_histogram_horizontal_value_scale(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 1000;
opts.value_scale = 0.001; // ms → s
opts.value_precision = 2;
opts.value_unit = "s";
ut8 data[] = { 100, 200, 50 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 3, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_true(strstr(res, "1.00s|") != NULL, "Scaled top label (1000 * 0.001 = 1.00)");
mu_assert_true(strstr(res, "0.00s|") != NULL, "Scaled bottom label");
free(res);
rz_cons_free();
mu_end;
}
// Unicode mode produces UTF-8 block (█) and box-drawing vline (│).
bool test_histogram_horizontal_unicode(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.unicode = true;
opts.ruler = true;
ut8 data[] = { 200, 100, 50 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 3, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
// UTF-8 byte sequences: U+2502 (│) = 0xE2 0x94 0x82; U+2588 (█) = 0xE2 0x96 0x88.
mu_assert_true(strstr(res, "\xe2\x94\x82") != NULL, "Unicode vline (U+2502) present");
mu_assert_true(strstr(res, "\xe2\x96\x88") != NULL, "Unicode block (U+2588) present");
// ASCII fallbacks must NOT appear.
mu_assert_true(strstr(res, "|") == NULL, "ASCII '|' absent in unicode mode");
mu_assert_true(strstr(res, "#") == NULL, "ASCII '#' absent in unicode mode");
free(res);
rz_cons_free();
mu_end;
}
// Thinline mode draws bars as vertical pipes instead of block characters.
bool test_histogram_horizontal_thinline(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.thinline = true;
opts.ruler = true;
ut8 data[] = { 200, 100 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 2, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
// In thinline mode bars are '|', so the only block characters present are
// the baseline '_' and the gutter vline. Make sure '#' is absent.
mu_assert_true(strstr(res, "#") == NULL, "Block '#' absent in thinline mode");
mu_assert_true(strstr(res, "|") != NULL, "Vline '|' present");
free(res);
rz_cons_free();
mu_end;
}
// Color mode wraps bars in ANSI escape sequences.
bool test_histogram_horizontal_color(void) {
rz_cons_new();
rz_cons_singleton()->context->color_mode = COLOR_MODE_16M;
rz_cons_pal_init(rz_cons_singleton()->context);
rz_cons_pal_update_event();
RzHistogramOptions opts = { 0 };
opts.color = true;
opts.ruler = true;
opts.pal = &rz_cons_singleton()->context->pal;
ut8 data[] = { 200, 100 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 2, 4);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "\x1b[") != NULL, "ANSI escape sequence present");
mu_assert_true(strstr(res, "\x1b[0m") != NULL, "Reset escape present");
free(res);
rz_cons_free();
mu_end;
}
// No X-axis ruler when opts->blocksize is 0 (preserves legacy output for
// callers that don't supply a blocksize).
bool test_histogram_horizontal_no_blocksize_no_xaxis(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.offpos = 0x1000;
opts.blocksize = 0; // explicit, but it's the default too
ut8 data[] = { 200, 100, 50 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 3, 6);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "0x1000") == NULL, "No X-axis offset labels when blocksize is 0");
mu_assert_true(strstr(res, "^") == NULL, "No '^' tick markers when blocksize is 0");
free(res);
rz_cons_free();
mu_end;
}
// Baseline '_' marker appears at the bottom row even when there are zero-data
// columns (preserves legacy behaviour for low-magnitude charts).
bool test_histogram_horizontal_baseline_marker(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
// Data with strict zeros — these must render '_' on the bottom row
// (not stay blank). PR #6355's `d > 0` guard regressed this.
ut8 data[] = { 0, 100, 0, 200, 0 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 5, 6);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "_") != NULL, "Baseline '_' present for zero data columns");
free(res);
rz_cons_free();
mu_end;
}
// Narrow charts (opts->cols < 4) suppress the X-axis ruler since there's
// no room for offset labels.
bool test_histogram_horizontal_narrow_chart(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.offpos = 0x1000;
opts.blocksize = 0x10;
opts.cols = 3; // below the 4-col minimum for X-axis ruler
ut8 data[] = { 200, 100, 50 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 3, 6);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Narrow chart must still render");
mu_assert_true(strstr(res, "0x1000") == NULL, "No X-axis offsets when cols < 4");
free(res);
rz_cons_free();
mu_end;
}
// data_f bypasses ut8 quantisation: two doubles that round to the same ut8
// must render distinctly on a chart tall enough to separate them.
bool test_histogram_horizontal_data_f_precision(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 1;
opts.value_precision = 2;
// Two doubles that both quantise to (ut8)(255 * x) == 127.
// Without data_f they'd render identically.
double fdata[] = { 0.499, 0.501 };
opts.data_f = fdata;
ut8 dummy[] = { 0, 0 };
// Tall chart so the fp resolution actually matters.
RzStrBuf *buf = rz_histogram_horizontal(&opts, dummy, 2, 80);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
// Count rows where the first column is filled vs the second.
// They must differ by at least one row (the fp precision wins).
int col1_fills = 0, col2_fills = 0;
char *p = res;
while (*p) {
char *eol = strchr(p, '\n');
if (!eol)
break;
// Skip gutter (variable width). Find the vline.
char *bar = strchr(p, '|');
if (bar && bar < eol && bar + 1 < eol) {
if (bar[1] == '#')
col1_fills++;
if (bar + 2 < eol && bar[2] == '#')
col2_fills++;
}
p = eol + 1;
}
// fdata[1] > fdata[0], so column 2 must reach at least as high.
mu_assert_true(col2_fills >= col1_fills, "fp data preserves ordering");
free(res);
rz_cons_free();
mu_end;
}
// Combination test: every feature toggled on at once must still render
// without crashing and must show evidence of each feature.
bool test_histogram_horizontal_combined_features(void) {
rz_cons_new();
rz_cons_singleton()->context->color_mode = COLOR_MODE_16M;
rz_cons_pal_init(rz_cons_singleton()->context);
rz_cons_pal_update_event();
RzHistogramOptions opts = { 0 };
opts.unicode = true;
opts.thinline = true;
opts.color = true;
opts.ruler = true;
opts.pal = &rz_cons_singleton()->context->pal;
opts.offpos = 0x401000;
opts.blocksize = 0x10;
opts.cols = 60;
opts.value_min = 0;
opts.value_max = 1;
opts.value_precision = 2;
double fdata[16];
for (int i = 0; i < 16; i++) {
fdata[i] = i / 15.0;
}
opts.data_f = fdata;
ut8 dummy[16] = { 0 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, dummy, 16, 10);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Combined-features render should produce output");
mu_assert_true(strstr(res, "1.00") != NULL, "Fractional top label present");
mu_assert_true(strstr(res, "0.00") != NULL, "Fractional bottom label present");
mu_assert_true(strstr(res, "0x401000") != NULL, "X-axis start offset present");
mu_assert_true(strstr(res, "\xe2\x94\x82") != NULL, "Unicode vline present");
mu_assert_true(strstr(res, "\x1b[") != NULL, "ANSI escape present");
mu_assert_true(strstr(res, "^") != NULL, "X-axis tick present");
free(res);
rz_cons_free();
mu_end;
}
// The renderer's contract: every rendered row is exactly gutter_w + opts->cols
// printable characters wide. Callers that clamp `cols` to fit the terminal
// must subtract the gutter themselves.
bool test_histogram_horizontal_row_width_contract(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 100;
opts.value_unit = "%";
opts.cols = 50;
ut8 data[16];
for (int i = 0; i < 16; i++) {
data[i] = i * 16;
}
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 16, 6);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
// The widest possible label here is "100%" -> gutter = 1+4+1 = 6 cols.
// Data rows must be exactly gutter + cols characters wide.
const int expected_data_row_w = 6 + 50;
char *line = res;
int row_idx = 0;
while (line && *line && row_idx < 6) {
char *nl = strchr(line, '\n');
size_t len = nl ? (size_t)(nl - line) : strlen(line);
mu_assert_eq((int)len, expected_data_row_w, "Each chart row matches gutter + cols");
line = nl ? nl + 1 : NULL;
row_idx++;
}
free(res);
rz_cons_free();
mu_end;
}
// Regression: with value_max=1 and value_precision=2 the previous integer
// label_value_at truncated 1*i/(rows-1) to 0 for all but the last row, so
// upper labels printed as "1.00" four times in a row (see PR #6427 review).
// The fp interpolation should now produce distinct labels like
// 1.00, 0.75, 0.50, 0.25, 0.00.
bool test_histogram_horizontal_no_duplicate_labels(void) {
rz_cons_new();
RzHistogramOptions opts = { 0 };
opts.ruler = true;
opts.value_min = 0;
opts.value_max = 1;
opts.value_precision = 2;
ut8 data[8] = { 0 };
RzStrBuf *buf = rz_histogram_horizontal(&opts, data, 8, 10);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "1.00|") != NULL, "Top label is 1.00");
mu_assert_true(strstr(res, "0.00|") != NULL, "Bottom label is 0.00");
mu_assert_true(strstr(res, "0.50|") != NULL || strstr(res, "0.56|") != NULL ||
strstr(res, "0.44|") != NULL,
"At least one intermediate label is between extremes");
// Count how many times "1.00|" appears - must be exactly once.
int count = 0;
const char *p = res;
while ((p = strstr(p, "1.00|")) != NULL) {
count++;
p++;
}
mu_assert_eq(count, 1, "Top label 1.00 appears exactly once (no duplicates)");
free(res);
rz_cons_free();
mu_end;
}
// ----- Visual / interactive horizontal histogram tests ----------------------
// Build a fresh interactive harness with sensible defaults for the tests.
static RzHistogramInteractive *make_visual_hist(int size, int w, int h, RzHistogramOptions *opts) {
RzConsCanvas *can = rz_cons_canvas_new(w, h);
if (!can) {
return NULL;
}
RzHistogramInteractive *hist = rz_histogram_interactive_new(can, opts);
if (!hist) {
rz_cons_canvas_free(can);
return NULL;
}
hist->size = size;
hist->blocksize = 0x10;
hist->w = w;
hist->h = h;
hist->zoom = 1;
hist->barnumber = 0;
return hist;
}
// Free a harness from make_visual_hist. interactive_free frees the canvas
// and the opts pointer too, so callers do NOT also free those.
static void free_visual_hist(RzHistogramInteractive *hist) {
rz_histogram_interactive_free(hist);
}
// Smoke test: the renderer produces output, the status line is present, and
// the cursor at index 0 doesn't trigger the #4431 segfault (negative adder).
bool test_histogram_interactive_horizontal_basic(void) {
rz_cons_new();
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->ruler = false; // simplest path
ut8 data[16];
for (int i = 0; i < 16; i++) {
data[i] = i * 16;
}
RzHistogramInteractive *hist = make_visual_hist(16, 40, 10, opts);
mu_assert_notnull(hist, "hist new");
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "Index 0 data 0") != NULL, "Status line present");
free(res);
free_visual_hist(hist);
rz_cons_free();
mu_end;
}
// Y-axis ruler with percent unit + value_max=100 + value_scale=0.01 +
// precision=2 should render labels like 1.00%, 0.50%, 0.00% across 5 anchors.
bool test_histogram_interactive_horizontal_ruler_percent(void) {
rz_cons_new();
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->ruler = true;
opts->value_max = 100;
opts->value_scale = 0.01;
opts->value_precision = 2;
opts->value_unit = "%";
ut8 data[32];
for (int i = 0; i < 32; i++) {
data[i] = i * 8;
}
RzHistogramInteractive *hist = make_visual_hist(32, 60, 14, opts);
mu_assert_notnull(hist, "hist new");
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "1.00%") != NULL, "Top fractional label present");
mu_assert_true(strstr(res, "0.00%") != NULL, "Bottom fractional label present");
free(res);
free_visual_hist(hist);
rz_cons_free();
mu_end;
}
// Default 0..255 byte ruler should produce labels 255 (top) and 0 (bottom).
bool test_histogram_interactive_horizontal_ruler_default(void) {
rz_cons_new();
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->ruler = true;
ut8 data[16];
for (int i = 0; i < 16; i++) {
data[i] = 255;
}
RzHistogramInteractive *hist = make_visual_hist(16, 50, 14, opts);
mu_assert_notnull(hist, "hist new");
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "255|") != NULL, "Top byte label present");
mu_assert_true(strstr(res, "0|") != NULL, "Bottom byte label present");
free(res);
free_visual_hist(hist);
rz_cons_free();
mu_end;
}
// Pin the #4431 segfault: with barnumber at the very left (0) and zoom such
// that span > histogramwidth, the adder must clamp to 0 (not go negative).
// The renderer should produce output without crashing.
bool test_histogram_interactive_horizontal_no_negative_adder(void) {
rz_cons_new();
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
ut8 data[8] = { 1, 2, 3, 4, 5, 6, 7, 8 };
RzHistogramInteractive *hist = make_visual_hist(8, 80, 10, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 0;
hist->zoom = 1;
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render at barnumber=0 should not crash");
mu_assert_true(strstr(res, "Index 0") != NULL, "Cursor index 0 reported");
free(res);
free_visual_hist(hist);
rz_cons_free();
mu_end;
}
// Percent indicator on the status line should appear when chart % is meaningful.
bool test_histogram_interactive_horizontal_percent(void) {
rz_cons_new();
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
ut8 data[20];
for (int i = 0; i < 20; i++) {
data[i] = i * 12;
}
RzHistogramInteractive *hist = make_visual_hist(20, 60, 12, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 10; // ~50% through 20 bars
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "%") != NULL, "Percent indicator on status line");
free(res);
free_visual_hist(hist);
rz_cons_free();
mu_end;
}
// Cursor markers ▼ at the top and ▲ at the bottom (or `v`/`^` in ASCII) make
// the cursor column identifiable even when colour rendering is off. The
// markers themselves are NOT colour-highlighted (kept plain) - the wordhl
// highlighting lives on the cursor bar between them, drawn as a continuous
// vertical line regardless of whether the data threshold reaches that row.
bool test_histogram_interactive_horizontal_cursor_markers(void) {
rz_cons_new();
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = true;
ut8 data[16];
for (int i = 0; i < 16; i++) {
data[i] = i * 16;
}
RzHistogramInteractive *hist = make_visual_hist(16, 50, 12, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 8;
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "\xe2\x96\xbc" /* ▼ */) != NULL, "Top marker present (utf8)");
mu_assert_true(strstr(res, "\xe2\x96\xb2" /* ▲ */) != NULL, "Bottom marker present (utf8)");
free(res);
free_visual_hist(hist);
}
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = false; // ASCII fallback markers
ut8 data[16];
for (int i = 0; i < 16; i++) {
data[i] = i * 16;
}
RzHistogramInteractive *hist = make_visual_hist(16, 50, 12, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 8;
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strchr(res, 'v') != NULL, "Top marker present (ASCII v)");
// ^ also appears in the X-axis ruler ticks, so we just check at
// least one is on a non-ruler row.
mu_assert_true(strchr(res, '^') != NULL, "Bottom marker present (ASCII ^)");
free(res);
free_visual_hist(hist);
}
rz_cons_free();
mu_end;
}
// The cursor bar must be a CONTINUOUS vertical line between the top/bottom
// markers - drawn on every row in between regardless of the data threshold.
// With a low-value cursor column and a tall chart, the gradient bars would
// only reach the lower rows, but the cursor line should still extend up to
// the top marker.
bool test_histogram_interactive_horizontal_cursor_full_line(void) {
rz_cons_new();
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = false; // ASCII so we can count `|` chars in the cursor column
opts->thinline = true; // force `|` glyph (else `#`)
ut8 data[8] = { 8, 16, 32, 4 /* cursor at this very small value */, 64, 128, 200, 255 };
RzHistogramInteractive *hist = make_visual_hist(8, 50, 12, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 3;
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
// Count `|` characters - the cursor column must contribute one for every
// chart row except the top/bottom (where the markers live). For a 12-row
// chart minus 2 reserved for X-axis ruler -> ~10 chart rows -> at least 8
// `|` characters on the cursor column.
int pipe_count = 0;
for (const char *p = res; *p; p++) {
if (*p == '|') {
pipe_count++;
}
}
mu_assert_true(pipe_count >= 6, "Cursor column extends as a continuous vertical line");
free(res);
free_visual_hist(hist);
rz_cons_free();
mu_end;
}
// When opts->minimap is true, the minimap rows must be rendered REGARDLESS
// of whether the data is zoomed in or shown in full. The user setting wins:
// `e scr.hist.minimap=true` always shows the minimap, `false` always hides it.
bool test_histogram_interactive_horizontal_minimap_toggle(void) {
rz_cons_new();
ut8 data[256];
for (int i = 0; i < 256; i++) {
data[i] = i;
}
// minimap disabled -> no window indicator characters, even when zoomed
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = true;
opts->minimap = false;
RzHistogramInteractive *hist = make_visual_hist(256, 80, 15, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 128;
hist->zoom = 4; // forces a subset view
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_null(strstr(res, "\xe2\x94\x8f"), "Minimap window left bracket absent when minimap=false (zoomed)");
free(res);
free_visual_hist(hist);
}
// minimap enabled while zoomed -> minimap present with the window indicator
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = true;
opts->minimap = true;
RzHistogramInteractive *hist = make_visual_hist(256, 80, 15, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 128;
hist->zoom = 4;
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "\xe2\x94\x8f") != NULL, "Minimap left bracket present when minimap=true (zoomed)");
free(res);
free_visual_hist(hist);
}
// minimap enabled while NOT zoomed -> minimap still present (user setting wins)
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = true;
opts->minimap = true;
RzHistogramInteractive *hist = make_visual_hist(256, 80, 15, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 128;
hist->zoom = 1; // not zoomed - whole data visible
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render should produce output");
mu_assert_true(strstr(res, "\xe2\x94\x81") != NULL, "Minimap mid bar present when minimap=true (not zoomed)");
free(res);
free_visual_hist(hist);
}
rz_cons_free();
mu_end;
}
// Helper for the cursor-width test: counts the most 'v' characters on any
// single line of the rendered output. The top marker `v` is unambiguous (the
// bottom marker `^` also appears on the X-axis ruler so we can't use it).
static int max_v_chars_per_line(const char *res) {
int max_v = 0;
for (const char *p = res; *p;) {
const char *eol = strchr(p, '\n');
size_t len = eol ? (size_t)(eol - p) : strlen(p);
int vs = 0;
for (size_t k = 0; k < len; k++) {
if (p[k] == 'v') {
vs++;
}
}
if (vs > max_v) {
max_v = vs;
}
if (!eol) {
break;
}
p = eol + 1;
}
return max_v;
}
// The cursor is ALWAYS exactly one screen column wide on every chart row,
// independent of zoom level OR the histogramwidth-vs-width ratio.
// Two distinct widening bugs are pinned here:
// 1. sizeofonebar > 1 (high zoom): each data index spans `sizeofonebar`
// screen columns; only kbar==0 may carry the cursor.
// 2. histogramwidth < width (chart much wider than data): several adjacent
// screen columns map to the same data index via integer truncation;
// only the pre-computed j_cursor column may carry the cursor.
bool test_histogram_interactive_horizontal_cursor_width(void) {
rz_cons_new();
// Case 1: high zoom forces sizeofonebar > 1.
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = false;
opts->thinline = true;
opts->color = false;
ut8 data[4] = { 64, 128, 192, 255 };
RzHistogramInteractive *hist = make_visual_hist(4, 80, 12, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 1;
hist->zoom = 20; // -> sizeofonebar > 1
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render");
mu_assert_eq(max_v_chars_per_line(res), 1, "1-char cursor at sizeofonebar > 1");
free(res);
free_visual_hist(hist);
}
// Case 2: chart wider than data with zoom=1. histogramwidth=4, width=80,
// so 20 screen columns map to each data index. Without the j_cursor fix
// every one of those 20 columns would render as the cursor.
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = false;
opts->thinline = true;
opts->color = false;
ut8 data[4] = { 64, 128, 192, 255 };
RzHistogramInteractive *hist = make_visual_hist(4, 80, 12, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 1;
hist->zoom = 1; // -> sizeofonebar == 1, but width >> histogramwidth
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render");
mu_assert_eq(max_v_chars_per_line(res), 1, "1-char cursor at histogramwidth < width");
free(res);
free_visual_hist(hist);
}
rz_cons_free();
mu_end;
}
// The hex preview panel kicks in only when:
// - hist->cursor_bytes is non-NULL and non-empty,
// - the terminal is wide (hist->w > 200), AND
// - shrinking the minimap by 43 cols still leaves at least 40 for the map.
// Verify that when these are met, two lines of `px 0x20`-style hex appear
// on the right of the minimap rows (16 bytes per row, formatted as 8 pairs
// of 2 bytes separated by single spaces), with px-style colour codes
// applied per-byte when opts->color is set.
bool test_histogram_interactive_horizontal_hex_preview(void) {
rz_cons_new();
// Case 1: wide terminal + cursor_bytes -> hex preview shown
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = false; // ASCII so we can grep on raw hex
opts->minimap = true;
ut8 data[64];
for (int i = 0; i < 64; i++) {
data[i] = (ut8)i * 4;
}
RzHistogramInteractive *hist = make_visual_hist(64, 220, 18, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 32;
ut8 cursor_bytes[32];
// Distinctive markers: 0xab, 0xcd, 0xef on the first row,
// 0x12, 0x34, 0x56 on the second row.
memset(cursor_bytes, 0, sizeof(cursor_bytes));
cursor_bytes[0] = 0xab;
cursor_bytes[1] = 0xcd;
cursor_bytes[2] = 0xef;
cursor_bytes[16] = 0x12;
cursor_bytes[17] = 0x34;
cursor_bytes[18] = 0x56;
hist->cursor_bytes = cursor_bytes;
hist->cursor_bytes_len = sizeof(cursor_bytes);
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render");
// First row of hex: `abcd ef00 0000 0000 ...`
mu_assert_true(strstr(res, "abcd ef00") != NULL, "Hex line 1 present (abcd ef00)");
// Second row of hex: `1234 5600 0000 0000 ...`
mu_assert_true(strstr(res, "1234 5600") != NULL, "Hex line 2 present (1234 5600)");
free(res);
free_visual_hist(hist);
}
// Case 2: narrow terminal -> hex preview NOT shown
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = false;
opts->minimap = true;
ut8 data[64];
for (int i = 0; i < 64; i++) {
data[i] = (ut8)i * 4;
}
RzHistogramInteractive *hist = make_visual_hist(64, 100, 18, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 32;
ut8 cursor_bytes[32];
memset(cursor_bytes, 0xff, sizeof(cursor_bytes)); // distinctive ffff...
hist->cursor_bytes = cursor_bytes;
hist->cursor_bytes_len = sizeof(cursor_bytes);
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render");
mu_assert_null(strstr(res, "ffff ffff ffff"), "Hex panel suppressed at narrow width (<= 200 cols)");
free(res);
free_visual_hist(hist);
}
// Case 3: wide terminal but cursor_bytes is NULL -> no hex panel
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = false;
opts->minimap = true;
ut8 data[64];
for (int i = 0; i < 64; i++) {
data[i] = (ut8)i * 4;
}
RzHistogramInteractive *hist = make_visual_hist(64, 220, 18, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 32;
// cursor_bytes stays NULL by default.
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render");
// Just confirm there's nothing that LOOKS like the hex panel. The
// chart can't produce e.g. `[0-9a-f]{4} [0-9a-f]{4}` so we look for
// a specific marker pattern that would appear if the panel
// rendered with all-zero bytes (`0000 0000 0000 ...`).
mu_assert_null(strstr(res, "0000 0000 0000"), "Hex panel suppressed without cursor_bytes");
free(res);
free_visual_hist(hist);
}
// Case 4: wide terminal + colour mode -> per-byte px-style colour codes
// applied. 0x00 gets the green slot, 0xff the red slot, 0x7f yellow,
// printable ASCII the btext slot, other bytes the "other" slot.
{
RzHistogramOptions *opts = rz_histogram_options_new();
mu_assert_notnull(opts, "opts new");
opts->unicode = true;
opts->minimap = true;
opts->color = true;
opts->pal = &rz_cons_singleton()->context->pal;
ut8 data[64];
for (int i = 0; i < 64; i++) {
data[i] = (ut8)i * 4;
}
RzHistogramInteractive *hist = make_visual_hist(64, 220, 18, opts);
mu_assert_notnull(hist, "hist new");
hist->barnumber = 32;
ut8 cursor_bytes[32];
memset(cursor_bytes, 0xab, sizeof(cursor_bytes)); // baseline "other"
cursor_bytes[0] = 0x00; // green
cursor_bytes[2] = 0xff; // red
cursor_bytes[4] = 0x7f; // yellow
cursor_bytes[6] = 'A'; // printable -> btext
hist->cursor_bytes = cursor_bytes;
hist->cursor_bytes_len = sizeof(cursor_bytes);
RzStrBuf *buf = rz_histogram_interactive_horizontal(hist, data);
char *res = rz_strbuf_drain(buf);
mu_assert_notnull(res, "Render");
// The hex bytes themselves still appear as text. We can't assert
// the exact escape because palette indirection means the colour
// can come from `pal->b0x00` or the fallback Color_GREEN. So just
// check that there is at least one ESC sequence in the output
// (proving the colour path ran) and that the raw hex byte values
// are still present.
mu_assert_true(strstr(res, "\x1b[") != NULL, "Colour escape sequences present");
mu_assert_true(strstr(res, "00") != NULL, "0x00 byte rendered");
mu_assert_true(strstr(res, "ff") != NULL, "0xff byte rendered");
mu_assert_true(strstr(res, "7f") != NULL, "0x7f byte rendered");
free(res);
free_visual_hist(hist);
}
rz_cons_free();
mu_end;
}
bool all_tests() {
mu_run_test(test_histogram_horizontal);
mu_run_test(test_histogram_vertical);
mu_run_test(test_histogram_horizontal_ruler_percent);
mu_run_test(test_histogram_horizontal_ruler_default);
mu_run_test(test_histogram_horizontal_top_row_filled);
mu_run_test(test_histogram_horizontal_cols_override);
mu_run_test(test_histogram_horizontal_sparse_labels);
mu_run_test(test_histogram_horizontal_float_labels);
mu_run_test(test_histogram_horizontal_xaxis_offsets);
mu_run_test(test_histogram_horizontal_data_f);
mu_run_test(test_histogram_horizontal_multi_char_unit);
mu_run_test(test_histogram_horizontal_precision_variants);
mu_run_test(test_histogram_horizontal_value_scale);
mu_run_test(test_histogram_horizontal_unicode);
mu_run_test(test_histogram_horizontal_thinline);
mu_run_test(test_histogram_horizontal_color);
mu_run_test(test_histogram_horizontal_no_blocksize_no_xaxis);
mu_run_test(test_histogram_horizontal_baseline_marker);
mu_run_test(test_histogram_horizontal_narrow_chart);
mu_run_test(test_histogram_horizontal_data_f_precision);
mu_run_test(test_histogram_horizontal_combined_features);
mu_run_test(test_histogram_horizontal_row_width_contract);
mu_run_test(test_histogram_horizontal_no_duplicate_labels);
mu_run_test(test_histogram_interactive_horizontal_basic);
mu_run_test(test_histogram_interactive_horizontal_ruler_percent);
mu_run_test(test_histogram_interactive_horizontal_ruler_default);
mu_run_test(test_histogram_interactive_horizontal_no_negative_adder);
mu_run_test(test_histogram_interactive_horizontal_percent);
mu_run_test(test_histogram_interactive_horizontal_cursor_markers);
mu_run_test(test_histogram_interactive_horizontal_cursor_full_line);
mu_run_test(test_histogram_interactive_horizontal_cursor_width);
mu_run_test(test_histogram_interactive_horizontal_minimap_toggle);
mu_run_test(test_histogram_interactive_horizontal_hex_preview);
return tests_passed != tests_run;
}
mu_main(all_tests)