// Differential test of MplexCopyMode against tai.py's MplexCopyMode. Scenario file made by // mplex_copy_gen.py (tmux 3.6 fixtures from test_tai.py, every default copy-mode binding, seeded fuzz); // 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. // // 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 #include #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" #include "mplex_server.h" using namespace detz::mplex; namespace { std::string esc(const std::string& text) { std::string out; for (char ch : text) { if (ch == '%') out += "%25"; else if (ch == '\t') out += "%09"; else if (ch == '\n') out += "%0A"; else if (ch == '\r') out += "%0D"; else out += ch; } return out; } std::string unesc(const std::string& text) { std::string out; for (size_t i = 0; i < text.size(); ++i) { if (text[i] == '%' && i + 2 < text.size()) { out += static_cast(std::stoi(text.substr(i + 1, 2), nullptr, 16)); i += 2; } else { out += text[i]; } } return out; } std::vector split_tabs(const std::string& line) { std::vector out; size_t start = 0; while (true) { size_t at = line.find('\t', start); if (at == std::string::npos) { out.push_back(line.substr(start)); break; } out.push_back(line.substr(start, at - start)); start = at + 1; } return out; } uint32_t crc32(const std::string& data) { static uint32_t table[256]; static bool ready = false; if (!ready) { for (uint32_t n = 0; n < 256; ++n) { uint32_t c = n; for (int k = 0; k < 8; ++k) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1; table[n] = c; } ready = true; } uint32_t crc = 0xFFFFFFFFu; for (unsigned char byte : data) crc = table[(crc ^ byte) & 0xFF] ^ (crc >> 8); return crc ^ 0xFFFFFFFFu; } std::string hex8(uint32_t value) { char buf[16]; std::snprintf(buf, sizeof buf, "%08x", value); return buf; } 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) { parts += to_utf8(cell.ch); parts += "|" + color_sgr(cell.fg) + "|" + color_sgr(cell.bg) + "|" + std::to_string(cell.attr) + ";"; } parts += "\n"; } if (block.cursor) parts += "(" + std::to_string(block.cursor->first) + ", " + std::to_string(block.cursor->second) + ")"; else parts += "None"; return hex8(crc32(parts)); } std::string pair_text(const std::optional& value) { if (!value) return "-"; return std::to_string(value->first) + "," + std::to_string(value->second); } std::string search_text(const std::optional& value) { if (!value) return "-"; return esc(value->text) + "," + (value->forward ? "1" : "0") + "," + (value->literal ? "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(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; } int failures = 0; int shown = 0; long checked_steps = 0; long checked_scenarios = 0; long tmux_checks = 0; long live_total = 0; long live_diff = 0; void fail(const std::string& scenario, const std::string& what, const std::string& want, const std::string& got) { ++failures; if (shown < 25) { ++shown; std::cerr << "MISMATCH " << scenario << " " << what << "\n python: " << want << "\n c++ : " << got << "\n"; } } } // namespace int main(int argc, char** argv) { if (argc < 2) { 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); size_t at = 0; // command table { auto fields = split_tabs(lines.at(at++)); std::vector want(fields.begin() + 1, fields.end()); if (want != MplexCopyMode::command_names()) { std::cerr << "command_names() differs from the Python c_* methods\n"; ++failures; } } while (at < lines.size()) { auto s = split_tabs(lines[at++]); if (s.empty() || s[0] != "S") { std::cerr << "scenario file out of step at line " << at << "\n"; return 2; } std::string name = unesc(s[1]); int rows = std::stoi(s[2]), cols = std::stoi(s[3]), history = std::stoi(s[4]); std::string keys = s[5]; std::string content = unesc(s[6]); bool view = s[7] == "1"; bool exit_at_bottom = s[8] == "1"; MplexServer server(home); server.options->set("mode-keys", keys); auto pane = std::make_shared(1, cols, rows, history, "/tmp"); std::vector view_lines; auto replace_nl = [](const std::string& text) { std::string out; for (char ch : text) { if (ch == '\n') out += "\r\n"; else out += ch; } return out; }; std::shared_ptr mode; if (view) { size_t start = 0; while (true) { size_t nl = content.find('\n', start); if (nl == std::string::npos) { view_lines.push_back(content.substr(start)); break; } view_lines.push_back(content.substr(start, nl - start)); start = nl + 1; } mode = std::make_shared(&server, pane, &view_lines); } else { pane->feed(replace_nl(content)); 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, *client); std::string want = e0[1] + " " + e0[2] + " " + e0[3] + " " + e0[4] + " " + e0[5] + " " + e0[6]; if (got != want) fail(name, "initial", want, got); } std::string last_state_cursor; std::vector tmux_line; while (lines[at] != "X") { auto step = split_tabs(lines[at++]); std::string err; if (step[0] == "T") { tmux_line = step; continue; } if (step[0] == "C") { int count = std::stoi(step[1]); std::vector words; for (size_t i = 2; i < step.size(); ++i) words.push_back(unesc(step[i])); pane->prefix_count = count; try { mode->command(words[0], std::vector(words.begin() + 1, words.end()), nullptr); } 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] == "K") { try { 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) { err = std::string("X:") + exc.what(); } } else if (step[0] == "F") { pane->feed(replace_nl(unesc(step[1]))); } else if (step[0] == "R") { pane->place(0, 0, std::stoi(step[2]), std::stoi(step[1])); } else { std::cerr << "bad step line " << lines[at - 1] << "\n"; return 2; } bool closed = std::find(pane->modes.begin(), pane->modes.end(), mode) == pane->modes.end(); 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); last_state_cursor = std::to_string(mode->cx) + " " + std::to_string(mode->cy) + " " + std::to_string(mode->oy); } ++at; // "X" if (!tmux_line.empty() && tmux_line[1] == "livecursor") { ++live_total; if (last_state_cursor != tmux_line[2]) { ++live_diff; if (live_diff <= 12) std::cout << "tmux live differs: " << name << " tmux=" << tmux_line[2] << " mplex=" << last_state_cursor << "\n"; } } else if (!tmux_line.empty()) { auto& t = tmux_line; ++tmux_checks; if (t[1] == "cursor") { if (last_state_cursor != t[2]) fail(name, "tmux cursor", t[2], last_state_cursor); } else { 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; }