From 43aab23534831b80174d33e37276a09199c0dfab Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 25 Oct 2026 01:59:31 +0200 Subject: [PATCH] mplex: copy mode test runs against the real server and all real sources; fix -Wmaybe-uninitialized Co-Authored-By: Claude Sonnet 5.5 --- taicpp/src/mplex/mplex_copymode.cpp | 38 ++++++--- taicpp/tests/mplex_copy_gen.py | 88 +++++++++++++++++--- taicpp/tests/mplex_copy_run.sh | 16 +--- taicpp/tests/mplex_copy_test.cpp | 121 +++++++++++++++------------- 4 files changed, 170 insertions(+), 93 deletions(-) diff --git a/taicpp/src/mplex/mplex_copymode.cpp b/taicpp/src/mplex/mplex_copymode.cpp index be2f2b7..6a4e69c 100644 --- a/taicpp/src/mplex/mplex_copymode.cpp +++ b/taicpp/src/mplex/mplex_copymode.cpp @@ -708,18 +708,24 @@ bool MplexCopyMode::c_next_matching_bracket(const Words&, MplexClientConn*) { int ay = abs_y(); if (!llen(ay)) return false; wchar_t here = char_at(cx, ay); - std::optional found = std::nullopt; + Point target{0, 0}; + bool have = false; // plain flag instead of an optional: GCC -Wmaybe-uninitialized false positive + auto attempt = [&](int x, bool forward) { + std::optional hit = match_bracket(x, ay, forward); + if (hit) { + target = *hit; + have = true; + } + }; if (vi_keys()) { - if (contains(L"([{", here)) found = match_bracket(cx, ay, true); - else if (contains(L")]}", here)) found = match_bracket(cx, ay, false); + if (contains(L"([{", here)) attempt(cx, true); + else if (contains(L")]}", here)) attempt(cx, false); } else { for (int x : {cx, cx + 1}) { - if (contains(L"([{", char_at(x, ay)) && !found.has_value() && (x == cx || here == L' ')) - found = match_bracket(x, ay, true); + if (contains(L"([{", char_at(x, ay)) && !have && (x == cx || here == L' ')) attempt(x, true); } } - if (found.has_value()) { - Point target = found.value(); + if (have) { goto_pos(target.first, target.second); } else { next_word_end(true); @@ -731,16 +737,24 @@ bool MplexCopyMode::c_previous_matching_bracket(const Words&, MplexClientConn*) int ay = abs_y(); if (!llen(ay)) return false; wchar_t here = char_at(cx, ay); - std::optional found; + Point target{0, 0}; + bool have = false; + auto attempt = [&](int x) { + std::optional hit = match_bracket(x, ay, false); + if (hit) { + target = *hit; + have = true; + } + }; if (contains(L")]}", here)) { - found = match_bracket(cx, ay, false); + attempt(cx); } else if (vi_keys()) { if (contains(L"([{", here) || contains(L"([{", char_at(cx + 1, ay))) return false; } else if (cx > 0 && contains(L")]}", char_at(cx - 1, ay))) { - found = match_bracket(cx - 1, ay, false); + attempt(cx - 1); } - if (found) { - goto_pos(found->first, found->second); + if (have) { + goto_pos(target.first, target.second); } else { previous_word(true); } diff --git a/taicpp/tests/mplex_copy_gen.py b/taicpp/tests/mplex_copy_gen.py index 2647afa..030026c 100644 --- a/taicpp/tests/mplex_copy_gen.py +++ b/taicpp/tests/mplex_copy_gen.py @@ -11,6 +11,7 @@ usage: python3 -I mplex_copy_gen.py REPO_ROOT OUT_FILE [fuzz_count] [seed] import os import random import sys +import socket import tempfile import zlib @@ -56,21 +57,46 @@ class Recorder: self.lines.append("\t".join(str(f) for f in fields)) -def state(mode, pane, server, err, closed): - last_copy = "-" - if server.copied: - text, command, append = server.copied[-1] - last_copy = "%s/%s/%d" % (esc(text), esc(command), 1 if append else 0) +def state(mode, pane, server, client, err, closed): + top = server.buffers[0]["data"] if server.buffers else "" + shown = "-" + if client.message is not None: + shown = esc(client.message[0]) + prompt = "-" if client.prompt is None else esc(client.prompt.label + client.prompt.text) return "\t".join([ "E", str(mode.cx), str(mode.cy), str(mode.oy), str(mode.hist()), pair(mode.anchor), pair(mode.mark), "1" if mode.rect else "0", "1" if mode.line_select else "0", search_text(mode.search), search_text(pane.last_search), "1" if mode.show_position else "0", "1" if closed else "0", - str(len(server.copied)), last_copy, str(len(server.messages)), - esc(server.messages[-1]) if server.messages else "-", str(mode.count), str(mode.lastcx), str(mode.lastsx), - esc(err), "%08x" % digest(mode), str(pane.history_size()), str(pane.prefix_count), + str(len(server.buffers)), esc(top), str(len(server.messages)), + esc(server.messages[-1][1]) if server.messages else "-", str(mode.count), str(mode.lastcx), str(mode.lastsx), + esc(err), "%08x" % digest(mode), str(pane.history_size()), str(pane.prefix_count), shown, prompt, ]) +HOME = tempfile.mkdtemp(prefix="home.", dir=os.path.dirname(os.path.abspath(out_path))) + + +def make_fixture(keys, pane): + """A real MplexServer (not listening) with one session and window holding pane, and one attached client.""" + server = tai.MplexServer(HOME) + server.options.set("mode-keys", keys) + session = tai.MplexSession(server.next_id("session"), "0", server.options, "/", {}) + session.size = (pane.w, pane.h + 1) + server.sessions[session.id] = session + window = tai.MplexWindow(server.next_id("window"), "w", pane.w, pane.h, session.options) + window.root = tai.MplexLayoutNode("pane", pane=pane, w=pane.w, h=pane.h) + window.active = pane + pane.window = window + server.panes[pane.id] = pane + server.register_window(session, window, 0, True) + left, right = socket.socketpair() + client = tai.MplexClientConn(server, left, server.next_id("client")) + client.session = session + client.attached = True + server.clients.append(client) + return server, client, (left, right) + + rec = Recorder() scenarios = 0 steps_total = 0 @@ -82,8 +108,7 @@ def run_scenario(name, rows, cols, history, keys, content, steps, lines=None, ex pane = tai.MplexPane(1, cols, rows, history, tempfile.gettempdir()) if lines is None: pane.feed(content.replace("\n", "\r\n").encode("utf-8")) - server = test_tai.MplexStubServer() - server.options.set("mode-keys", keys) + server, client, pair_sockets = make_fixture(keys, pane) if lines is not None: mode = tai.MplexCopyMode(server, pane, lines=lines) else: @@ -112,7 +137,24 @@ def run_scenario(name, rows, cols, history, keys, content, steps, lines=None, ex elif step[0] == "K": rec.add("K", esc(step[1])) try: - mode.key(step[1], b"", None) + mode.key(step[1], b"", client) + except tai.MplexError as exc: + err = "M:" + str(exc) + except Exception as exc: # noqa: BLE001 + err = "X:" + str(exc) + elif step[0] in ("Y", "Z"): + rec.add(step[0], esc(step[1]) if step[0] == "Y" else "") + try: + if step[0] == "Z": + if client.prompt is not None: + client.prompt.key(server, client, "Escape", b"\x1b") + elif client.prompt is not None: + for ch in step[1]: + if client.prompt is None: + break + client.prompt.key(server, client, "Space" if ch == " " else ch, ch.encode("utf-8")) + if client.prompt is not None: + client.prompt.key(server, client, "Enter", b"\r") except tai.MplexError as exc: err = "M:" + str(exc) except Exception as exc: # noqa: BLE001 @@ -124,12 +166,14 @@ def run_scenario(name, rows, cols, history, keys, content, steps, lines=None, ex rec.add("R", step[1], step[2]) pane.place(0, 0, step[2], step[1]) closed = mode not in pane.modes - rec.lines.append(state(mode, pane, server, err, closed)) + rec.lines.append(state(mode, pane, server, client, err, closed)) if closed: break if expect is not None: rec.add("T", expect[0], esc(expect[1])) rec.add("X") + for sock in pair_sockets: + sock.close() # ---- command table ----------------------------------------------------------------------------- @@ -174,6 +218,19 @@ for table in ("copy-mode", "copy-mode-vi"): content = "\n".join("line %d (a b) {c} a,b,c" % n for n in range(40)) + "\n" run_scenario("key-%s-%s" % (table, key), 6, 30, 100, "vi" if table == "copy-mode-vi" else "emacs", content, [("C", 1, ["page-up"]), ("C", 1, ["begin-selection"]), ("K", key)]) +# command-prompt bindings through the real server: open the prompt with the key, answer it (or cancel it) +PROMPT_ANSWERS = ["5", "0", "12", "x", "line 3", "(a b)", "9", "(", "line", "LINE", "{c}", "a,", " ", "-1"] +for table in ("copy-mode", "copy-mode-vi"): + for key, binding in list(config.tables[table].items()): + if not binding.command.startswith("command-prompt"): + continue + content = "\n".join("line %d (a b) {c} a,b,c" % n for n in range(40)) + "\n" + for answer in PROMPT_ANSWERS: + run_scenario("prompt-%s-%s-%s" % (table, key, answer), 6, 30, 100, "vi" if table == "copy-mode-vi" else "emacs", + content, [("C", 1, ["history-top"]), ("K", key), ("Y", answer), ("C", 1, ["search-again"]), + ("C", 1, ["jump-again"])]) + run_scenario("promptesc-%s-%s" % (table, key), 6, 30, 100, "vi" if table == "copy-mode-vi" else "emacs", + content, [("K", key), ("Z", ""), ("C", 1, ["cursor-down"])]) # ---- seeded fuzz ---------------------------------------------------------------------------------------- rng = random.Random(seed) @@ -212,6 +269,10 @@ def pick_step(): if rng.random() < 0.08: table = config.tables["copy-mode-vi" if rng.random() < 0.5 else "copy-mode"] return ("K", rng.choice(list(table))) + if rng.random() < 0.06: + return ("Y", rng.choice(PROMPT_ANSWERS)) + if rng.random() < 0.02: + return ("Z", "") pick = rng.random() if pick < 0.08: return ("C", count, [rng.choice(JUMPS), rng.choice(JARGS)]) @@ -315,4 +376,7 @@ tmux_live(int(os.environ.get("F4_TMUX_LIVE", "240"))) with open(out_path, "w", encoding="utf-8") as handle: handle.write("\n".join(rec.lines) + "\n") +import shutil # noqa: E402 + +shutil.rmtree(HOME, ignore_errors=True) sys.stderr.write("scenarios=%d steps=%d\n" % (scenarios, steps_total)) diff --git a/taicpp/tests/mplex_copy_run.sh b/taicpp/tests/mplex_copy_run.sh index a1de759..122676a 100755 --- a/taicpp/tests/mplex_copy_run.sh +++ b/taicpp/tests/mplex_copy_run.sh @@ -1,27 +1,19 @@ #!/bin/sh -# Build and run the MplexCopyMode differential test. Writes only under taicpp/build/f4. -# usage: tests/mplex_copy_run.sh [fuzz_count] [seed] (SAN=1 builds with ASan and UBSan) +# Build and run the MplexCopyMode differential test against ALL real sources. Writes only under taicpp/build/f4. +# usage: tests/mplex_copy_run.sh [fuzz_count] [seed] (SAN=1 builds with ASan and UBSan; F4_TMUX_LIVE=N live tmux cases) set -e here=$(cd "$(dirname "$0")" && pwd) tc=$(cd "$here/.." && pwd) repo=$(cd "$tc/.." && pwd) out="$tc/build/f4" mkdir -p "$out" -srcs="" -for f in "$tc"/src/mplex/*.cpp; do - case "$f" in - */mplex_server.cpp|*/mplex_commands.cpp|*/mplex_client.cpp) continue ;; - esac - srcs="$srcs $f" -done flags="-O2" bin="$out/mplex_copy_test" if [ -n "$SAN" ]; then flags="-g -O1 -fsanitize=address,undefined" bin="$out/mplex_copy_test_san" fi -# The test supplies its own MplexServer stub and the MplexCommands destructor it needs. -g++ -std=c++23 -Wall -Wextra $flags -DF4_STUB_COMMANDS -I"$tc/src/mplex" "$tc/tests/mplex_copy_test.cpp" $srcs \ - -Wl,--unresolved-symbols=ignore-in-object-files -o "$bin" +g++ -std=c++23 -Wall -Wextra $flags -I"$tc/src/mplex" -I"$tc/src/plexer" "$tc/tests/mplex_copy_test.cpp" \ + "$tc"/src/mplex/*.cpp "$tc"/src/plexer/plexer_flow.cpp -o "$bin" python3 -I "$tc/tests/mplex_copy_gen.py" "$repo" "$out/scenarios.txt" "${1:-3600}" "${2:-20251025}" "$bin" "$out/scenarios.txt" diff --git a/taicpp/tests/mplex_copy_test.cpp b/taicpp/tests/mplex_copy_test.cpp index 54ea12f..d20a4bc 100644 --- a/taicpp/tests/mplex_copy_test.cpp +++ b/taicpp/tests/mplex_copy_test.cpp @@ -3,11 +3,14 @@ // every step's full state line (cursor, scroll, selection, mark, search, copies, messages, render checksum) // must match the Python oracle byte for byte. Fixture scenarios also carry the value tmux 3.6 produced. // -// The MplexServer here is the small stub the Python tests use (MplexStubServer): options, bindings, a copy sink. +// Every scenario runs against a real MplexServer (not listening, throwaway TAI_HOME) with one real session, window, +// pane and attached MplexClientConn; key presses go through the real run_binding and the real command set, and +// prompt answers through the real MplexPrompt. Linked against all real sources, no stubs. // Build and run: tests/mplex_copy_run.sh (writes only under taicpp/build/). #include #include #include +#include #include #include #include @@ -15,9 +18,12 @@ #include #include +#include + #include "mplex_args.h" #include "mplex_canvas.h" #include "mplex_commands.h" +#include "mplex_clientconn.h" #include "mplex_config.h" #include "mplex_copymode.h" #include "mplex_renderer.h" @@ -25,41 +31,6 @@ using namespace detz::mplex; -namespace { -std::vector> g_copied; -std::vector g_messages; -} // namespace - -#ifdef F4_STUB_COMMANDS -struct detz::mplex::MplexCommands::Impl {}; -detz::mplex::MplexCommands::~MplexCommands() = default; -#endif - -namespace detz::mplex { - -MplexServer::MplexServer(const std::string& home_in) : home(home_in) { - config = std::make_shared(); - options = &config->options; - started = 0.0; -} -MplexServer::~MplexServer() = default; - -MplexOptions* MplexServer::pane_options(const std::shared_ptr&) { return options; } -void MplexServer::message(const std::shared_ptr&, const std::string& text, std::optional) { - g_messages.push_back(text); -} -void MplexServer::mark_dirty(const std::shared_ptr&) {} -void MplexServer::store_copy(const std::string& text, const std::string& command, bool append) { - g_copied.emplace_back(text, command, append); -} -void MplexServer::run_binding(const std::shared_ptr&, const MplexBinding& binding, - const std::shared_ptr& pane) { - auto words = MplexCommandParser::parse(binding.command)[0]; - auto mode = std::dynamic_pointer_cast(pane->top_mode()); - mode->command(words[2], std::vector(words.begin() + 3, words.end()), nullptr); -} - -} // namespace detz::mplex namespace { @@ -125,9 +96,8 @@ std::string hex8(uint32_t value) { return buf; } -std::string digest(MplexCopyMode& mode) { - alignas(16) static char scratch[sizeof(MplexClientConn)]; // render() ignores its client - ModeBlock block = mode.render(*reinterpret_cast(scratch)); +std::string digest(MplexCopyMode& mode, MplexClientConn& client) { + ModeBlock block = mode.render(client); std::string parts; for (const Row& row : block.grid) { for (const Cell& cell : row) { @@ -151,20 +121,19 @@ std::string search_text(const std::optional& value) { return esc(value->text) + "," + (value->forward ? "1" : "0") + "," + (value->literal ? "1" : "0"); } -std::string state_line(MplexCopyMode& mode, const std::shared_ptr& pane, const std::string& err, bool closed) { - std::string last_copy = "-"; - if (!g_copied.empty()) { - const auto& [text, command, append] = g_copied.back(); - last_copy = esc(text) + "/" + esc(command) + "/" + (append ? "1" : "0"); - } +std::string state_line(MplexCopyMode& mode, const std::shared_ptr& pane, MplexServer& server, + MplexClientConn& client, const std::string& err, bool closed) { + std::string top = server.buffers.empty() ? "" : server.buffers[0].data; + std::string shown = client.message ? esc(client.message->first) : "-"; + std::string prompt = client.prompt ? esc(to_utf8(client.prompt->label + client.prompt->text)) : "-"; std::vector f = { "E", std::to_string(mode.cx), std::to_string(mode.cy), std::to_string(mode.oy), std::to_string(mode.hist()), pair_text(mode.anchor), pair_text(mode.mark), mode.rect ? "1" : "0", mode.line_select ? "1" : "0", search_text(mode.search), search_text(pane->last_search), mode.show_position ? "1" : "0", closed ? "1" : "0", - std::to_string(g_copied.size()), last_copy, std::to_string(g_messages.size()), - g_messages.empty() ? "-" : esc(g_messages.back()), std::to_string(mode.count), std::to_string(mode.lastcx), - std::to_string(mode.lastsx), esc(err), digest(mode), std::to_string(pane->history_size()), - std::to_string(pane->prefix_count)}; + std::to_string(server.buffers.size()), esc(top), std::to_string(server.messages.size()), + server.messages.empty() ? "-" : esc(server.messages.back().second), std::to_string(mode.count), + std::to_string(mode.lastcx), std::to_string(mode.lastsx), esc(err), digest(mode, client), + std::to_string(pane->history_size()), std::to_string(pane->prefix_count), shown, prompt}; std::string out; for (size_t i = 0; i < f.size(); ++i) out += (i ? "\t" : "") + f[i]; return out; @@ -193,6 +162,15 @@ int main(int argc, char** argv) { std::cerr << "usage: mplex_copy_test SCENARIOS\n"; return 2; } + // throwaway TAI_HOME next to the scenario file (under taicpp/build/), removed at the end + std::string scenario_path = argv[1]; + size_t slash = scenario_path.rfind('/'); + std::string home_template = (slash == std::string::npos ? std::string(".") : scenario_path.substr(0, slash)) + "/home.XXXXXX"; + if (mkdtemp(home_template.data()) == nullptr) { + std::cerr << "cannot create a throwaway TAI_HOME\n"; + return 2; + } + const std::string home = home_template; std::ifstream in(argv[1]); std::vector lines; for (std::string line; std::getline(in, line);) lines.push_back(line); @@ -218,9 +196,7 @@ int main(int argc, char** argv) { std::string content = unesc(s[6]); bool view = s[7] == "1"; bool exit_at_bottom = s[8] == "1"; - g_copied.clear(); - g_messages.clear(); - MplexServer server("/tmp"); + MplexServer server(home); server.options->set("mode-keys", keys); auto pane = std::make_shared(1, cols, rows, history, "/tmp"); std::vector view_lines; @@ -250,11 +226,25 @@ int main(int argc, char** argv) { mode = std::make_shared(&server, pane, nullptr, false, exit_at_bottom); } pane->push_mode(mode); + // one real session and window holding the pane, one attached client (see make_fixture in the generator) + auto session = std::make_shared(server.next_id("session"), "0", server.options, "/", MplexPane::EnvList{}); + session->size = std::make_pair(pane->w, pane->h + 1); + server.sessions.set(session->id, session); + auto window = std::make_shared(server.next_id("window"), "w", pane->w, pane->h, &session->options); + window->root = std::make_shared("pane", pane, pane->w, pane->h); + window->active = pane; + pane->set_window(window); + server.panes.set(pane->id, pane); + server.register_window(session, window, 0, true); + auto client = std::make_shared(&server, -1, server.next_id("client")); + client->session = session; + client->attached = true; + server.clients.push_back(client); ++checked_scenarios; { auto e0 = split_tabs(lines[at++]); std::string got = std::to_string(mode->cx) + " " + std::to_string(mode->cy) + " " + std::to_string(mode->oy) + " " + - std::to_string(mode->hist()) + " " + mode->name + " " + digest(*mode); + std::to_string(mode->hist()) + " " + mode->name + " " + digest(*mode, *client); std::string want = e0[1] + " " + e0[2] + " " + e0[3] + " " + e0[4] + " " + e0[5] + " " + e0[6]; if (got != want) fail(name, "initial", want, got); } @@ -280,9 +270,25 @@ int main(int argc, char** argv) { err = std::string("X:") + exc.what(); } } else if (step[0] == "K") { - auto conn = std::make_shared(&server, -1, 1); try { - mode->key(unesc(step[1]), Bytes(), *conn); + mode->key(unesc(step[1]), Bytes(), *client); + } catch (const MplexError& exc) { + err = std::string("M:") + exc.what(); + } catch (const std::exception& exc) { + err = std::string("X:") + exc.what(); + } + } else if (step[0] == "Y" || step[0] == "Z") { + try { + if (step[0] == "Z") { + if (client->prompt) client->prompt->key(server, *client, "Escape", "\x1b"); + } else if (client->prompt) { + UStr typed = to_ustr(unesc(step[1])); + for (wchar_t ch : typed) { + if (!client->prompt) break; + client->prompt->key(server, *client, ch == L' ' ? std::string("Space") : to_utf8(ch), to_utf8(ch)); + } + if (client->prompt) client->prompt->key(server, *client, "Enter", "\r"); + } } catch (const MplexError& exc) { err = std::string("M:") + exc.what(); } catch (const std::exception& exc) { @@ -297,7 +303,7 @@ int main(int argc, char** argv) { return 2; } bool closed = std::find(pane->modes.begin(), pane->modes.end(), mode) == pane->modes.end(); - std::string got = state_line(*mode, pane, err, closed); + std::string got = state_line(*mode, pane, server, *client, err, closed); std::string want = lines[at++]; ++checked_steps; if (got != want) fail(name, "after step [" + lines[at - 2] + "]", want, got); @@ -317,12 +323,13 @@ int main(int argc, char** argv) { if (t[1] == "cursor") { if (last_state_cursor != t[2]) fail(name, "tmux cursor", t[2], last_state_cursor); } else { - std::string got = g_copied.empty() ? "" : std::get<0>(g_copied.back()); + std::string got = server.top_buffer_text(); if (got != unesc(t[2])) fail(name, "tmux copied text", unesc(t[2]), got); } } } std::cout << "scenarios=" << checked_scenarios << " steps=" << checked_steps << " tmux_fixture_checks=" << tmux_checks << " failures=" << failures << " tmux_live=" << live_total << " tmux_live_differ=" << live_diff << "\n"; + std::filesystem::remove_all(home); return failures ? 1 : 0; }