diff options
| author | Berke Güzel <wenekar1@gmail.com> | 2026-10-12 17:49:34 +0300 |
|---|---|---|
| committer | Berke Güzel <wenekar1@gmail.com> | 2026-10-12 17:49:34 +0300 |
| commit | 4384435941b8c7658856b381d071aee2e0035e89 (patch) | |
| tree | 5ece99c3dd1decb8ec3fd404c0ece2b5865a552a /tests/model_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/model_test.c')
| -rw-r--r-- | tests/model_test.c | 458 |
1 files changed, 458 insertions, 0 deletions
diff --git a/tests/model_test.c b/tests/model_test.c new file mode 100644 index 0000000..395097e --- /dev/null +++ b/tests/model_test.c @@ -0,0 +1,458 @@ +/* + * Compare the terminal with the reference model (model.c) through the + * public API: every history page and screen row through a snapshot, the + * cursor, the modes, the history size, and the reply bytes. + * + * Each seed makes a random geometry and history limit, then runs four + * steps of generated input. The terminal gets each step in random chunks, + * so the fast paths and the byte parser both run. One step in four ends + * with a random resize. Every step ends with queries (DSR, DECRQSS, + * DECRQM), so hidden state such as the pen, the margins, and the modes + * shows in the replies. + * + * On a mismatch the test shrinks the input, prints a short C string that + * reproduces it, and fails. + * + * Usage: model-test [seeds] [first-seed] + */ +#include "model.h" +#include "fuzz_gen.h" +#include "term/term.h" + +#include <stdio.h> +#include <limits.h> +#include <stdlib.h> +#include <string.h> + +/* Queries that make hidden state visible. They change nothing. */ +static const char probe[] = + "\033[6n\033P$qm\033\\\033P$qr\033\\\033P$q q\033\\" + "\033[?6$p\033[?7$p\033[4$p\033[20$p"; + +struct event { + char *bytes; /* NULL for a resize */ + size_t len; + unsigned flags; + struct term_vector2i size; +}; + +struct script { + struct term_vector2i size; + int history; + struct event *ev; + int n, cap; +}; + +static void script_add(struct script *s, struct event e) +{ + if (s->n == s->cap) { + s->cap = s->cap ? s->cap * 2 : 16; + s->ev = realloc(s->ev, (size_t)s->cap * sizeof(*s->ev)); + if (!s->ev) abort(); + } + s->ev[s->n++] = e; +} + +static void script_free(struct script *s) +{ + for (int i = 0; i < s->n; i++) free(s->ev[i].bytes); + free(s->ev); + *s = (struct script){0}; +} + +static char *dup_bytes(const char *p, size_t n) +{ + char *d = malloc(n ? n : 1); + if (!d) abort(); + memcpy(d, p, n); + return d; +} + +struct replies { + char *s; + size_t len, cap; +}; + +static void on_reply(void *ud, const char *bytes, size_t len) +{ + struct replies *r = ud; + if (r->len + len > r->cap) { + r->cap = (r->len + len) * 2; + r->s = realloc(r->s, r->cap); + if (!r->s) abort(); + } + memcpy(r->s + r->len, bytes, len); + r->len += len; +} + +static void dump(FILE *f, const char *s, size_t n) +{ + fputc('"', f); + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (c == 27) fputs("\\033", f); + else if (c == '\r') fputs("\\r", f); + else if (c == '\n') fputs("\\n", f); + else if (c == '\t') fputs("\\t", f); + else if (c == '"' || c == '\\') fprintf(f, "\\%c", c); + else if (c >= 32 && c < 127) fputc(c, f); + else fprintf(f, "\\x%02x\"\"", c); + } + fputc('"', f); +} + +static char glyph(uint32_t cp) +{ + return cp >= 32 && cp < 127 ? (char)cp : cp ? '?' : '.'; +} + +/* Print every retained row of both, side by side. */ +static void dump_all(struct model *m, struct term *t, struct term_snapshot *snap, FILE *log) +{ + int total = term_scrollback(t).total, mtotal = model_history(m); + struct term_vector2i size = term_size(t), msize = model_size(m); + int rows = total + size.y > mtotal + msize.y ? total + size.y : mtotal + msize.y; + fprintf(log, " term (history %d)%*s model (history %d)\n", total, size.x > 8 ? size.x - 8 : 0, + "", mtotal); + for (int r = 0; r < rows; r++) { + fprintf(log, " %3d |", r); + if (r < total + size.y) { + int offset = r < total ? total - r : 0; + term_scroll_to(t, offset); + term_snapshot_update(snap, t); + const struct term_cell *row = term_snapshot_row(snap, r < total ? 0 : r - total); + for (int x = 0; x < size.x; x++) { + uint32_t cp[GRAPHEME_MAX_LEN] = {0}; + if (row[x].text) term_snapshot_cluster(snap, row[x].text, cp, GRAPHEME_MAX_LEN); + fputc(glyph(cp[0]), log); + } + } else { + fprintf(log, "%*s", size.x, ""); + } + fprintf(log, "| |"); + if (r < mtotal + msize.y) { + const struct model_cell *row = model_row(m, r); + for (int x = 0; x < msize.x; x++) fputc(glyph(row[x].n ? row[x].cp[0] : 0), log); + } + fprintf(log, "|\n"); + } + term_scroll_to(t, 0); + struct term_cursor c = term_cursor(t), mc = model_cursor(m); + fprintf(log, " cursor term %d,%d model %d,%d\n", c.pos.x, c.pos.y, mc.pos.x, mc.pos.y); +} + +/* Returns NULL when the terminal matches the model, else a description. + * Details go to log when it is not NULL. */ +static const char *compare(struct model *m, struct term *t, struct term_snapshot *snap, + struct replies *got, char *why, size_t cap, FILE *log) +{ + size_t want_len; + const char *want = model_replies(m, &want_len); + if (want_len != got->len || (want_len && memcmp(want, got->s, want_len))) { + if (log) { + fprintf(log, "replies: term "); + dump(log, got->s ? got->s : "", got->len); + fprintf(log, "\n model "); + dump(log, want, want_len); + fputc('\n', log); + } + snprintf(why, cap, "replies differ"); + return why; + } + struct term_vector2i size = term_size(t), msize = model_size(m); + if (size.x != msize.x || size.y != msize.y) { + snprintf(why, cap, "size %dx%d, model %dx%d", size.x, size.y, msize.x, msize.y); + return why; + } + struct term_cursor c = term_cursor(t), mc = model_cursor(m); + if (c.pos.x != mc.pos.x || c.pos.y != mc.pos.y || c.style != mc.style || + c.visible != mc.visible || c.blink != mc.blink) { + snprintf(why, cap, "cursor %d,%d style %d visible %d blink %d; model %d,%d %d %d %d", + c.pos.x, c.pos.y, c.style, c.visible, c.blink, + mc.pos.x, mc.pos.y, mc.style, mc.visible, mc.blink); + return why; + } + struct term_modes md = term_modes(t), mm = model_modes(m); + if (md.keyboard != mm.keyboard || md.mouse != mm.mouse || + md.mouse_encoding != mm.mouse_encoding || md.app_cursor != mm.app_cursor || + md.app_keypad != mm.app_keypad || md.backarrow_bs != mm.backarrow_bs || + md.bracketed_paste != mm.bracketed_paste || md.focus_events != mm.focus_events || + md.alternate != mm.alternate || md.reverse_video != mm.reverse_video || + md.synchronized != mm.synchronized) { + snprintf(why, cap, "modes differ (alternate %d/%d reverse %d/%d keypad %d/%d)", + md.alternate, mm.alternate, md.reverse_video, mm.reverse_video, + md.app_keypad, mm.app_keypad); + return why; + } + /* Visit each retained row. Wide-line totals can start as estimates. */ + int total = model_history(m); + term_scroll_to(t, 0); + term_scroll(t, INT_MAX); + for (int offset = total;; offset = offset > size.y ? offset - size.y : 0) { + if (!offset) + term_scroll_to(t, 0); + if (!term_snapshot_update(snap, t)) { + snprintf(why, cap, "snapshot update failed"); + return why; + } + for (int y = 0; y < size.y; y++) { + const struct term_cell *row = term_snapshot_row(snap, y); + int retained = total - offset + y; + const struct model_cell *want_row = model_row(m, retained); + for (int x = 0; x < size.x; x++) { + const struct model_cell *e = &want_row[x]; + uint32_t cps[GRAPHEME_MAX_LEN] = {0}; + int n = row[x].text ? term_snapshot_cluster(snap, row[x].text, cps, + GRAPHEME_MAX_LEN) : 0; + if (n == e->n && row[x].width == e->width && row[x].attrs == e->attrs && + row[x].fg == e->fg && row[x].bg == e->bg && + !memcmp(cps, e->cp, (size_t)n * sizeof(*cps))) + continue; + if (log) { + fprintf(log, "retained row %d (offset %d):\n term: ", retained, offset); + for (int k = 0; k < size.x; k++) { + uint32_t cp[GRAPHEME_MAX_LEN] = {0}; + if (row[k].text) + term_snapshot_cluster(snap, row[k].text, cp, GRAPHEME_MAX_LEN); + fputc(glyph(cp[0]), log); + } + fprintf(log, "\n model: "); + want_row = model_row(m, retained); + for (int k = 0; k < size.x; k++) + fputc(glyph(want_row[k].n ? want_row[k].cp[0] : 0), log); + fputc('\n', log); + e = &want_row[x]; + } + snprintf(why, cap, + "cell %d,%d: term U+%04X (%d cps) w%u attrs %#x fg %#x bg %#x; " + "model U+%04X (%u cps) w%u attrs %#x fg %#x bg %#x", + x, y, cps[0], n, row[x].width, row[x].attrs, row[x].fg, row[x].bg, + e->cp[0], e->n, e->width, e->attrs, e->fg, e->bg); + term_scroll_to(t, 0); + return why; + } + } + if (!offset) break; + term_scroll(t, -(offset > size.y ? size.y : offset)); + } + int observed_total = term_scrollback(t).total; + if (observed_total != total) { + snprintf(why, cap, "visited history %d rows, model %d", observed_total, total); + return why; + } + term_scroll_to(t, 0); + return NULL; +} + +enum chunking { CHUNK_RANDOM, CHUNK_WHOLE, CHUNK_BYTES }; + +/* Run a script. Returns the event index of the first mismatch, or -1. */ +static int run(const struct script *s, enum chunking chunks, uint64_t chunk_seed, + char *why, size_t cap, bool verbose) +{ + struct replies got = {0}; + struct term_options opt = { + .size = s->size, .scrollback = s->history, + .callbacks = { .ud = &got, .reply = on_reply }, + }; + struct term *t = term_new(&opt); + struct model *m = model_new(s->size, s->history); + struct term_snapshot *snap = term_snapshot_new(); + if (!t || !snap) abort(); + uint64_t saved_rng = rng; + rng = chunk_seed | 1; + int bad = -1; + for (int i = 0; i < s->n && bad < 0; i++) { + const struct event *e = &s->ev[i]; + if (e->bytes) { + model_feed(m, e->bytes, e->len, e->flags); + for (size_t k = 0; k < e->len;) { + size_t n = chunks == CHUNK_WHOLE ? e->len - k + : chunks == CHUNK_BYTES ? 1 + : 1 + (size_t)rn((int)(e->len - k)); + term_feed(t, e->bytes + k, n, e->flags); + k += n; + } + } else { + bool a = model_resize(m, e->size); + bool b = term_resize(t, e->size); + if (a != b) { + snprintf(why, cap, "resize result differs"); + bad = i; + break; + } + } + if (compare(m, t, snap, &got, why, cap, verbose ? stderr : NULL)) { + bad = i; + if (verbose) dump_all(m, t, snap, stderr); + } + model_take_replies(m); + got.len = 0; + } + rng = saved_rng; + free(got.s); + term_snapshot_free(snap); + model_free(m); + term_free(t); + return bad; +} + +static bool fails(const struct script *s, char *why, size_t cap) +{ + return run(s, CHUNK_WHOLE, 1, why, cap, false) >= 0 || + run(s, CHUNK_BYTES, 1, why, cap, false) >= 0; +} + +/* Remove byte ranges and events while the script still fails. */ +static void shrink(struct script *s) +{ + char why[512]; + for (int i = s->n - 1; i >= 0; i--) { + struct event e = s->ev[i]; + memmove(s->ev + i, s->ev + i + 1, (size_t)(s->n - i - 1) * sizeof(*s->ev)); + s->n--; + if (fails(s, why, sizeof(why))) { + free(e.bytes); + continue; + } + memmove(s->ev + i + 1, s->ev + i, (size_t)(s->n - i) * sizeof(*s->ev)); + s->ev[i] = e; + s->n++; + } + for (int i = 0; i < s->n; i++) { + struct event *e = &s->ev[i]; + if (!e->bytes) continue; + for (size_t step = e->len / 2 ? e->len / 2 : 1; step >= 1; step /= 2) { + for (size_t at = 0; at + step <= e->len;) { + char *keep = dup_bytes(e->bytes, e->len); + size_t keep_len = e->len; + memmove(e->bytes + at, e->bytes + at + step, e->len - at - step); + e->len -= step; + if (fails(s, why, sizeof(why))) { + free(keep); + continue; + } + free(e->bytes); + e->bytes = keep; + e->len = keep_len; + at++; + } + if (step == 1) break; + } + } +} + +static void report(struct script *s, int seed, const char *why) +{ + fprintf(stderr, "seed %d: %s\n", seed, why); + shrink(s); + char buf2[512]; + fprintf(stderr, "shrunk: %dx%d history %d\n", s->size.x, s->size.y, s->history); + for (int i = 0; i < s->n; i++) { + if (s->ev[i].bytes) { + fprintf(stderr, " feed%s ", s->ev[i].flags ? " (ONLCR)" : ""); + dump(stderr, s->ev[i].bytes, s->ev[i].len); + fputc('\n', stderr); + } else { + fprintf(stderr, " resize %dx%d\n", s->ev[i].size.x, s->ev[i].size.y); + } + } + int at = run(s, CHUNK_WHOLE, 1, buf2, sizeof(buf2), true); + if (at < 0) at = run(s, CHUNK_BYTES, 1, buf2, sizeof(buf2), true); + fprintf(stderr, "shrunk mismatch after event %d: %s\n", at, buf2); +} + +/* Fixed cases that the generator reaches rarely. */ +static int fixed_cases(void) +{ + static const struct { + int cols, rows, history; + const char *input; + } cases[] = { + { 12, 3, 5, "a中e\xcc\x81日本 \033[1;3;4;9;38;5;123;48;2;1;2;3mQ\033[0mR" }, + { 8, 4, 5, "\033[2;3r\033[?6hZ\nQ\nR\033[?6l\033[r" }, + { 12, 3, 50, "one two three four five\r\nsix seven eight\r\nnine" }, + { 10, 3, 50, "\033]133;A\007$ ls -la /usr/share\r\nout1\r\n\033]133;A\007$ " }, + { 6, 3, 5, "\xf0\x9f\x87\xa9\xf0\x9f\x87\xaa\xe2\x9d\xa4\xef\xb8\x8f" "ab\xe2\x98\xba\xef\xb8\x8e" }, + { 8, 3, 5, "\033(0lqqk\033(Bx\x0e" "abc\x0f" "abc" }, + { 8, 3, 5, "abcdefgh\033[3;4H\033[31;1m\0337\033[0m\033[H\0338X" }, + { 6, 2, 5, "abcdef\xe3\x80z\xed\xa0\x80\x80" }, + }; + static const struct term_vector2i sizes[] = { + { .x = 5, .y = 3 }, { .x = 16, .y = 4 }, { .x = 7, .y = 3 }, { .x = 20, .y = 5 }, + { .x = 1, .y = 2 }, { .x = 3, .y = 6 }, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + struct script s = { + .size = { .x = cases[i].cols, .y = cases[i].rows }, + .history = cases[i].history, + }; + script_add(&s, (struct event){ + .bytes = dup_bytes(cases[i].input, strlen(cases[i].input)), + .len = strlen(cases[i].input) }); + script_add(&s, (struct event){ .bytes = dup_bytes(probe, sizeof(probe) - 1), + .len = sizeof(probe) - 1 }); + for (size_t k = 0; k < sizeof(sizes) / sizeof(sizes[0]); k++) + script_add(&s, (struct event){ .size = sizes[k] }); + char why[512]; + int at = run(&s, CHUNK_BYTES, 1, why, sizeof(why), true); + if (at >= 0) { + fprintf(stderr, "fixed case %zu event %d: %s\n", i, at, why); + report(&s, -(int)i, why); + script_free(&s); + return 1; + } + script_free(&s); + } + return 0; +} + +int main(int argc, char **argv) +{ + int seeds = argc > 1 ? atoi(argv[1]) : 20000; + int first = argc > 2 ? atoi(argv[2]) : 1; + if (fixed_cases()) return 1; + static const int limits[] = { 0, 5, 50, 4096 }; + for (int seed = first; seed < first + seeds; seed++) { + rng = 0x9e3779b97f4a7c15ull * (uint64_t)seed; + struct script s = { + .size = { .x = 4 + rn(37), .y = 2 + rn(11) }, + .history = limits[rn(4)], + }; + struct term_vector2i size = s.size; + for (int step = 0; step < 4; step++) { + blen = 0; + gen(10 + rn(30), size.x, size.y); + put(probe); + unsigned flags = rn(5) == 0 ? TERM_FEED_ONLCR : 0; + script_add(&s, (struct event){ .bytes = dup_bytes(buf, blen), .len = blen, + .flags = flags }); + if (rn(4) == 0) { + size = (struct term_vector2i){ .x = 1 + rn(44), .y = 1 + rn(14) }; + script_add(&s, (struct event){ .size = size }); + } + } + if (getenv("MODEL_TEST_PRINT")) { + fprintf(stderr, "seed %d: %dx%d history %d\n", seed, s.size.x, s.size.y, s.history); + for (int i = 0; i < s.n; i++) { + if (!s.ev[i].bytes) { + fprintf(stderr, " resize %dx%d\n", s.ev[i].size.x, s.ev[i].size.y); + continue; + } + fprintf(stderr, " feed "); + dump(stderr, s.ev[i].bytes, s.ev[i].len); + fputc('\n', stderr); + } + } + char why[512]; + uint64_t chunk_seed = rng; + if (run(&s, CHUNK_RANDOM, chunk_seed, why, sizeof(why), false) >= 0) { + run(&s, CHUNK_RANDOM, chunk_seed, why, sizeof(why), true); + report(&s, seed, why); + script_free(&s); + return 1; + } + script_free(&s); + } + return 0; +} |
