summaryrefslogtreecommitdiff
path: root/tests/view_test.c
diff options
context:
space:
mode:
authorBerke Güzel <wenekar1@gmail.com>2026-10-12 17:49:34 +0300
committerBerke Güzel <wenekar1@gmail.com>2026-10-12 17:49:34 +0300
commit4384435941b8c7658856b381d071aee2e0035e89 (patch)
tree5ece99c3dd1decb8ec3fd404c0ece2b5865a552a /tests/view_test.c
term: move the term core to subprojects/term
The core now builds, tests, and benchmarks alone, as a Meson subproject with no dependency on the main tree. - include/term/ holds term.h and the header-only unicode/. src/ holds the core and its private headers. The main tree gets only include/term through term_dep, so it cannot reach a private header. - The term tests, the term benchmarks, the Kitty parser suite, and tools/amalgamate.py and tools/generate_unicode.py move with it. The tests run as term:<name>. - The main build passes its -march (arch_flag) and b_ndebug to the subproject. A directory configured before this commit needs meson configure -Dterm:arch_flag=-march=... once. - The term tests compile with -UNDEBUG. They do work inside assert. - The subproject exports term_test_dep with term_api.h, for the main tree's render, thumbnail, color, and selection tests. - The Kitty bridge used struct vector2i and has not compiled since the public API redesign. It builds again. 2 of its 14 tests fail: REP after a tab, and a cursor report after a resize. - dirgraph.py drops the term and unicode layers. Its self-test uses pty. - The symbols test ignores the __odr_asan symbols that ASan adds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests/view_test.c')
-rw-r--r--tests/view_test.c475
1 files changed, 475 insertions, 0 deletions
diff --git a/tests/view_test.c b/tests/view_test.c
new file mode 100644
index 0000000..5e17450
--- /dev/null
+++ b/tests/view_test.c
@@ -0,0 +1,475 @@
+#include <assert.h>
+#include "term_api.h"
+
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/*
+ * The scrollback view: the viewport can leave the live screen, and the
+ * terminal then shows history rows. These tests cover what the view does
+ * while the terminal keeps working underneath it.
+ */
+
+/* The tests use small terminals, so a row fits in a short buffer. */
+#define VIEW_TEXT_CAP 256
+
+/* The text of a view row, trailing blanks cut. */
+static void view_row_text(struct term *t, int row, char *buf, size_t cap)
+{
+ const struct term_cell *cells = term_snapshot_row(tt_snapshot_for(t), row);
+ size_t n = 0;
+ for (int c = 0; c < tt_cols(t) && n + 1 < cap; c++) {
+ uint32_t cp = tt_cell_cp(t, &cells[c]);
+ buf[n++] = !cp ? ' ' : cp < 0x80 ? (char)cp : '?';
+ }
+ while (n > 0 && buf[n - 1] == ' ')
+ n--;
+ buf[n] = 0;
+}
+
+static bool view_row_is(struct term *t, int row, const char *want)
+{
+ char got[VIEW_TEXT_CAP];
+ view_row_text(t, row, got, sizeof(got));
+ return !strcmp(got, want);
+}
+
+/* Every view row holds the text of the same row in want. A view row that
+ * resolves to no row at all fails the text compare. */
+static void assert_view_rows_equal(struct term *t, struct term *want)
+{
+ for (int row = 0; row < tt_rows(t); row++) {
+ char got[VIEW_TEXT_CAP], expect[VIEW_TEXT_CAP];
+ view_row_text(t, row, got, sizeof(got));
+ view_row_text(want, row, expect, sizeof(expect));
+ assert(!strcmp(got, expect));
+ }
+}
+
+/* The view offset, in range, and the same as the snapshot reports. The two
+ * must come from one field: a second copy of the offset is what let the
+ * scrollbar and the rows disagree. */
+static void assert_offset_agrees(struct term *t)
+{
+ const struct term_view *v = term_snapshot_view(tt_snapshot_for(t));
+ struct term_scrollback sb = term_scrollback(t);
+ assert(sb.offset == v->scroll_offset);
+ assert(sb.offset >= 0);
+ assert(sb.offset <= sb.total);
+}
+
+/* A terminal whose history is small enough to fill. */
+static struct term *small_history_term(int cols, int rows, int scrollback)
+{
+ struct term_options opt = {
+ .size = { .x = cols, .y = rows },
+ .scrollback = scrollback,
+ };
+ struct term *t = term_new(&opt);
+ assert(t);
+ return t;
+}
+
+/* Fill the history with count lines of "Lnn" and leave the cursor on the
+ * last screen row. */
+static void feed_lines(struct term *t, const char *tag, int count)
+{
+ char line[64];
+ for (int i = 0; i < count; i++) {
+ int len = snprintf(line, sizeof(line), "%s%02d\r\n", tag, i);
+ tt_write(t, line, (size_t)len);
+ }
+}
+
+/* Output that arrives while the view sits in the history leaves the view
+ * on the same text. */
+static void view_holds_text_while_output_arrives(void)
+{
+ struct term *t = tt_new(12, 5);
+ struct term *reference = tt_new(12, 5);
+ feed_lines(t, "L", 15);
+ feed_lines(reference, "L", 15);
+ term_scroll(t, 2);
+ term_scroll(reference, 2);
+ assert(tt_view_offset(t) == 2);
+ assert_view_rows_equal(t, reference);
+
+ feed_lines(t, "M", 4);
+ feed_lines(reference, "M", 4);
+ assert_offset_agrees(t);
+ /* Nothing was evicted, so the view still shows the rows it showed. */
+ assert_view_rows_equal(t, reference);
+ term_free(t);
+ term_free(reference);
+}
+
+/* A view that sits at the top of the history keeps the oldest text there
+ * when more output arrives. The history grows by a row, so the offset
+ * grows with it. */
+static void view_at_the_top_keeps_the_oldest_text(void)
+{
+ const int scrollback = 20;
+ struct term *t = small_history_term(12, 5, scrollback);
+ struct term *reference = small_history_term(12, 5, scrollback);
+ feed_lines(t, "L", 12);
+ feed_lines(reference, "L", 12);
+ term_scroll_to(t, INT_MAX);
+ term_scroll_to(reference, INT_MAX);
+ int total = tt_history(t);
+ assert(total > 0 && total + tt_rows(t) <= scrollback);
+ assert(tt_view_offset(t) == total);
+ assert(view_row_is(t, 0, "L00"));
+
+ /* Three more lines with room in the history: the oldest line stays on
+ * the top row of the view, and the offset grows with the history. */
+ feed_lines(t, "M", 3);
+ feed_lines(reference, "M", 3);
+ assert_offset_agrees(t);
+ assert(tt_history(t) == total + 3);
+ assert(tt_view_offset(t) == total + 3);
+ assert(view_row_is(t, 0, "L00"));
+ assert_view_rows_equal(t, reference);
+ term_free(t);
+ term_free(reference);
+}
+
+/* Output larger than the history evicts the rows the view was anchored to.
+ * The view then shows the oldest text the terminal still keeps, and the
+ * offset stays inside the history. */
+static void view_survives_output_larger_than_history(void)
+{
+ const int scrollback = 20;
+ struct term *t = small_history_term(12, 5, scrollback);
+ struct term *reference = small_history_term(12, 5, scrollback);
+ feed_lines(t, "L", 15);
+ feed_lines(reference, "L", 15);
+ term_scroll(t, 3);
+
+ feed_lines(t, "M", 40);
+ feed_lines(reference, "M", 40);
+ assert_offset_agrees(t);
+ assert(tt_history(t) == tt_history(reference));
+ assert(tt_history(t) == scrollback);
+ assert(tt_view_offset(t) > 0);
+
+ /* The top of the history is where the view ends up. */
+ term_scroll(reference, INT_MAX);
+ assert(tt_view_offset(reference) == tt_history(reference));
+ assert(tt_view_offset(t) == tt_history(t));
+ assert_view_rows_equal(t, reference);
+ term_free(t);
+ term_free(reference);
+}
+
+/* An eviction can drop a line that wraps to more rows than the push adds.
+ * The offset then stops at the oldest row the terminal still keeps, and
+ * never sits above the history. */
+static void view_survives_eviction_of_a_wrapped_line(void)
+{
+ const int scrollback = 20;
+ struct term *t = small_history_term(12, 5, scrollback);
+ struct term *reference = small_history_term(12, 5, scrollback);
+ /* The oldest line wraps to three rows. */
+ const char *wrapped = "0123456789012345678901234\r\n";
+ tt_puts(t, wrapped);
+ tt_puts(reference, wrapped);
+ feed_lines(t, "s", 22);
+ feed_lines(reference, "s", 22);
+ term_scroll_to(t, INT_MAX);
+ term_scroll_to(reference, INT_MAX);
+ assert_offset_agrees(t);
+
+ /* The next line evicts the wrapped one: three rows go, one arrives. */
+ for (int i = 0; i < 4; i++) {
+ tt_puts(t, "n\r\n");
+ tt_puts(reference, "n\r\n");
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) <= tt_history(t));
+ }
+ /* The view sits on the oldest text the terminal still keeps. */
+ term_scroll_to(reference, INT_MAX);
+ assert(tt_view_offset(t) == tt_view_offset(reference));
+ assert_view_rows_equal(t, reference);
+ term_free(t);
+ term_free(reference);
+}
+
+/* A resize keeps the same retained text at the top of the view. */
+static void view_keeps_anchor_on_resize(void)
+{
+ static const struct {
+ int cols, rows;
+ } sizes[] = { { 12, 6 }, { 20, 5 }, { 6, 5 } };
+ for (size_t i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++) {
+ struct term *t = tt_new(12, 5);
+ struct term *reference = tt_new(12, 5);
+ feed_lines(t, "L", 15);
+ feed_lines(reference, "L", 15);
+ term_scroll(t, 3);
+ assert(tt_view_offset(t) == 3);
+ assert(tt_resize(t, sizes[i].cols, sizes[i].rows));
+ assert(tt_resize(reference, sizes[i].cols, sizes[i].rows));
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) > 0);
+ assert(view_row_is(t, 0, "L08"));
+ term_scroll_to(t, 0);
+ assert_view_rows_equal(t, reference);
+ term_free(t);
+ term_free(reference);
+ }
+}
+
+/* The alternate screen has no history to show. The normal screen comes
+ * back at its own bottom. */
+static void view_follows_the_alternate_screen(void)
+{
+ struct term *t = tt_new(12, 5);
+ feed_lines(t, "L", 15);
+ term_scroll(t, 3);
+ assert(tt_view_offset(t) == 3);
+
+ tt_puts(t, "\033[?1049hALT");
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) == 0);
+ assert(view_row_is(t, 0, "ALT"));
+
+ tt_puts(t, "\033[?1049l");
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) == 0);
+ assert(view_row_is(t, 0, "L11"));
+ term_free(t);
+}
+
+/* The view offset has one owner. Every operation that moves or clears the
+ * view leaves the public offset and the snapshot offset equal. */
+static void view_offset_has_one_owner(void)
+{
+ struct term *t = tt_new(12, 5);
+ feed_lines(t, "L", 15);
+ assert_offset_agrees(t);
+ term_scroll(t, 3);
+ assert_offset_agrees(t);
+ term_scroll_to(t, INT_MAX);
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) == tt_history(t));
+ term_scroll(t, INT_MIN);
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) == 0);
+ term_free(t);
+}
+
+/* ED2 and ED3 return the view to the live screen. RIS keeps the offset, as
+ * it did before the view had an anchor of its own. */
+static void view_returns_to_bottom_on_erase(void)
+{
+ struct term *t = tt_new(12, 5);
+ feed_lines(t, "L", 15);
+ term_scroll(t, 3);
+ assert(tt_view_offset(t) == 3);
+
+ tt_puts(t, "\033[2J");
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) == 0);
+
+ term_scroll(t, 2);
+ assert(tt_view_offset(t) == 2);
+ tt_puts(t, "\033[3J");
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) == 0);
+ assert(tt_history(t) == 0);
+ term_free(t);
+
+ t = tt_new(12, 5);
+ feed_lines(t, "L", 15);
+ term_scroll(t, 3);
+ int before = tt_view_offset(t);
+ tt_puts(t, "\033c");
+ assert_offset_agrees(t);
+ assert(tt_view_offset(t) == before);
+ term_free(t);
+}
+
+/* Rewrap keeps the cell that selected the anchored segment in view. */
+static void wrapped_anchor_survives_resize(void)
+{
+ struct term *t = tt_new(10, 3);
+ tt_puts(t, "abcdefghijKLMNOPQRSTuvwxyz\r\n");
+ feed_lines(t, "L", 10);
+ term_scroll_to(t, INT_MAX);
+ term_scroll(t, -1);
+ assert(view_row_is(t, 0, "KLMNOPQRST"));
+ tt_select(t, 0, 0, 2, 0);
+ char *selected = tt_selection_text(t);
+ assert(selected && !strcmp(selected, "KLM"));
+ free(selected);
+ assert(tt_resize(t, 6, 3));
+ assert_offset_agrees(t);
+ assert(view_row_is(t, 0, "ghijKL"));
+ selected = tt_selection_text(t);
+ assert(selected && !strcmp(selected, "KLM"));
+ free(selected);
+ feed_lines(t, "N", 5);
+ assert(view_row_is(t, 0, "ghijKL"));
+ assert(tt_resize(t, 13, 3));
+ assert(view_row_is(t, 0, "abcdefghijKLM"));
+ assert(tt_resize(t, 6, 3));
+ assert(view_row_is(t, 0, "ghijKL"));
+ assert_offset_agrees(t);
+ term_free(t);
+}
+
+/* A wide glyph leaves a wrap pad. That pad does not move the anchor. */
+static void wide_anchor_survives_resize(void)
+{
+ struct term *t = tt_new(5, 3);
+ tt_puts(t, "abcd日本語XYZ\r\n");
+ feed_lines(t, "L", 10);
+ term_scroll_to(t, INT_MAX);
+ term_scroll(t, -1);
+ assert(tt_cell_cp(t, tt_view_cell(t, 0, 0)) == 0x65e5);
+ assert(tt_resize(t, 3, 3));
+ assert_offset_agrees(t);
+ assert(tt_cell_cp(t, tt_view_cell(t, 0, 0)) == 'd');
+ assert(tt_cell_cp(t, tt_view_cell(t, 1, 0)) == 0x65e5);
+ assert(tt_resize(t, 8, 3));
+ assert(tt_cell_cp(t, tt_view_cell(t, 0, 0)) == 'a');
+ assert(tt_resize(t, 3, 3));
+ assert(tt_cell_cp(t, tt_view_cell(t, 1, 0)) == 0x65e5);
+ term_free(t);
+}
+
+/* Incremental and new snapshots must show the same retained rows. */
+static void snapshots_agree_after_eviction_and_rewrap(void)
+{
+ struct term *t = small_history_term(11, 5, 23);
+ struct term_snapshot *persistent = term_snapshot_new();
+ assert(persistent);
+ for (int n = 0; n < 120; n++) {
+ char line[120];
+ snprintf(line, sizeof(line), "%03d abcdefghijklmnop 日本語 café\r\n", n);
+ tt_puts(t, line);
+ if (n % 13 == 0)
+ assert(tt_resize(t, 6 + n % 17, 5));
+ int total = tt_history(t);
+ term_scroll_to(t, n % (total + 1));
+ assert(term_snapshot_update(persistent, t));
+ struct term_snapshot *fresh = term_snapshot_new();
+ assert(fresh && term_snapshot_update(fresh, t));
+ struct term_vector2i size = term_size(t);
+ for (int y = 0; y < size.y; y++) {
+ const struct term_cell *a = term_snapshot_row(persistent, y);
+ const struct term_cell *b = term_snapshot_row(fresh, y);
+ for (int x = 0; x < size.x; x++) {
+ assert(a[x].fg == b[x].fg && a[x].bg == b[x].bg);
+ assert(a[x].width == b[x].width && a[x].attrs == b[x].attrs);
+ uint32_t acp[32], bcp[32];
+ int an = term_snapshot_cluster(persistent, a[x].text, acp, 32);
+ int bn = term_snapshot_cluster(fresh, b[x].text, bcp, 32);
+ assert(an == bn);
+ if (an)
+ assert(!memcmp(acp, bcp, (size_t)an * sizeof(*acp)));
+ else
+ assert(a[x].text == b[x].text);
+ }
+ assert(term_snapshot_row_key(persistent, y) == term_snapshot_row_key(fresh, y));
+ }
+ assert_offset_agrees(t);
+ term_snapshot_free(fresh);
+ }
+ term_snapshot_free(persistent);
+ term_free(t);
+}
+
+static void snapshots_agree_after_prefix_eviction(void)
+{
+ struct term *t = tt_new(1000, 3);
+ char line[1000];
+ for (int i = 0; i < 9500; i++) {
+ memset(line, 'A' + i % 26, sizeof(line));
+ tt_write(t, line, sizeof(line));
+ }
+ tt_write(t, "\r\n", 2);
+ term_scroll(t, INT_MAX);
+ struct term_snapshot *persistent = term_snapshot_new();
+ assert(persistent && term_snapshot_update(persistent, t));
+ uint64_t old_key = term_snapshot_row_key(persistent, 0);
+ memset(line, 'z', sizeof(line));
+ for (int i = 0; i < 1200; i++) {
+ tt_write(t, line, sizeof(line));
+ tt_write(t, "\r\n", 2);
+ }
+ assert(term_snapshot_update(persistent, t));
+ assert(term_snapshot_row_key(persistent, 0) != old_key);
+ struct term_snapshot *fresh = term_snapshot_new();
+ assert(fresh && term_snapshot_update(fresh, t));
+ for (int y = 0; y < 3; y++) {
+ const struct term_cell *a = term_snapshot_row(persistent, y);
+ const struct term_cell *b = term_snapshot_row(fresh, y);
+ for (int x = 0; x < 1000; x++)
+ assert(a[x].text == b[x].text && a[x].fg == b[x].fg && a[x].bg == b[x].bg);
+ assert(term_snapshot_row_key(persistent, y) == term_snapshot_row_key(fresh, y));
+ }
+ term_snapshot_free(fresh);
+ term_snapshot_free(persistent);
+ term_free(t);
+}
+
+/* A line that needs more than one storage part stays scannable. Every
+ * scroll offset names one history row, and every view row shows the text
+ * of the history row the offset puts there. */
+static void view_scrolls_a_line_that_needs_several_parts(void)
+{
+ const int cols = 40, rows = 6, cells = 2000000;
+ struct term_options opt = { .size = { .x = cols, .y = rows }, .scrollback = 60000 };
+ struct term *t = term_new(&opt);
+ assert(t);
+ char *big = malloc((size_t)cells);
+ assert(big);
+ for (int i = 0; i < cells; i++)
+ big[i] = (char)('0' + i % 10);
+ tt_write(t, big, (size_t)cells);
+ tt_write(t, "\r\n", 2);
+ free(big);
+
+ /* The rows still on the screen are not history rows. */
+ int total = tt_history(t);
+ assert(total == cells / cols - (rows - 1));
+ struct term_snapshot *snap = term_snapshot_new();
+ assert(snap);
+ for (int off = 1; off <= total; off += 5) {
+ term_scroll_to(t, off);
+ assert(tt_view_offset(t) == off);
+ assert(term_snapshot_update(snap, t));
+ for (int y = 0; y < rows; y++) {
+ int row = total - off + y;
+ if (row >= total)
+ continue;
+ const struct term_cell *line = term_snapshot_row(snap, y);
+ for (int x = 0; x < cols; x++)
+ assert(line[x].text == (uint32_t)('0' + (row * cols + x) % 10));
+ }
+ }
+ term_snapshot_free(snap);
+ term_free(t);
+}
+
+int main(void)
+{
+ snapshots_agree_after_prefix_eviction();
+ wrapped_anchor_survives_resize();
+ wide_anchor_survives_resize();
+ snapshots_agree_after_eviction_and_rewrap();
+ view_holds_text_while_output_arrives();
+ view_at_the_top_keeps_the_oldest_text();
+ view_survives_output_larger_than_history();
+ view_survives_eviction_of_a_wrapped_line();
+ view_keeps_anchor_on_resize();
+ view_follows_the_alternate_screen();
+ view_offset_has_one_owner();
+ view_scrolls_a_line_that_needs_several_parts();
+ view_returns_to_bottom_on_erase();
+ printf("PASS: scrollback view tests\n");
+ return 0;
+}