811 lines
34 KiB
C++
811 lines
34 KiB
C++
// Differential test for the F3 classes: replays the scenarios written by mplex_model_gen.py
|
|
// (which drives the real Python classes in tai.py) through the C++ classes and compares.
|
|
// Usage: mplex_model_test CASES.jsonl SCRATCH_DIR
|
|
// Also runs a real-pty test of MplexPane (spawn, feed, write, drain, close, tail_log, log rollover).
|
|
#include <fcntl.h>
|
|
#include <poll.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <sstream>
|
|
|
|
#include "mplex_canvas.h"
|
|
#include "mplex_chooser.h"
|
|
#include "mplex_clientconn.h"
|
|
#include "mplex_clockmode.h"
|
|
#include "mplex_ctx.h"
|
|
#include "mplex_flowscreen.h"
|
|
#include "mplex_json.h"
|
|
#include "mplex_panenumbers.h"
|
|
#include "mplex_pane.h"
|
|
#include "mplex_prompt.h"
|
|
#include "mplex_py.h"
|
|
#include "mplex_renderer.h"
|
|
#include "mplex_server.h"
|
|
#include "mplex_style.h"
|
|
|
|
using namespace detz::mplex;
|
|
|
|
namespace {
|
|
|
|
int g_checks = 0;
|
|
int g_failures = 0;
|
|
std::map<std::string, std::pair<int, int>> g_by_kind; // kind -> (checks, failures)
|
|
std::string g_kind;
|
|
|
|
void fail(const std::string& what, const std::string& got, const std::string& want) {
|
|
++g_failures;
|
|
++g_by_kind[g_kind].second;
|
|
if (g_failures <= 25) {
|
|
std::cerr << "FAIL [" << g_kind << "] " << what << "\n got : " << got.substr(0, 600) << "\n want: " << want.substr(0, 600)
|
|
<< "\n";
|
|
}
|
|
}
|
|
|
|
void check(bool ok, const std::string& what) {
|
|
++g_checks;
|
|
++g_by_kind[g_kind].first;
|
|
if (!ok) fail(what, "false", "true");
|
|
}
|
|
|
|
Json canon(const Json& value) {
|
|
if (value.is_object()) {
|
|
auto items = value.as_object();
|
|
std::sort(items.begin(), items.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
|
|
for (auto& item : items) item.second = canon(item.second);
|
|
return Json::object(items);
|
|
}
|
|
if (value.is_array()) {
|
|
Json::Array items;
|
|
for (const auto& item : value.as_array()) items.push_back(canon(item));
|
|
return Json::array(items);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
std::string first_diff(const Json& a, const Json& b, const std::string& path) {
|
|
if (a.kind() != b.kind()) return path + ": " + a.dumps() + " vs " + b.dumps();
|
|
if (a.is_array()) {
|
|
const auto& x = a.as_array();
|
|
const auto& y = b.as_array();
|
|
for (size_t i = 0; i < std::min(x.size(), y.size()); ++i) {
|
|
std::string d = first_diff(x[i], y[i], path + "[" + std::to_string(i) + "]");
|
|
if (!d.empty()) return d;
|
|
}
|
|
if (x.size() != y.size()) return path + ": length " + std::to_string(x.size()) + " vs " + std::to_string(y.size());
|
|
return "";
|
|
}
|
|
if (a.is_object()) {
|
|
const auto& x = a.as_object();
|
|
const auto& y = b.as_object();
|
|
for (size_t i = 0; i < std::min(x.size(), y.size()); ++i) {
|
|
if (x[i].first != y[i].first) return path + ": key " + x[i].first + " vs " + y[i].first;
|
|
std::string d = first_diff(x[i].second, y[i].second, path + "." + x[i].first);
|
|
if (!d.empty()) return d;
|
|
}
|
|
if (x.size() != y.size()) return path + ": size";
|
|
return "";
|
|
}
|
|
return a.dumps() == b.dumps() ? "" : path + ": " + a.dumps() + " vs " + b.dumps();
|
|
}
|
|
|
|
void expect_json(const std::string& what, const Json& got, const Json& want) {
|
|
++g_checks;
|
|
++g_by_kind[g_kind].first;
|
|
Json ca = canon(got), cb = canon(want);
|
|
std::string a = ca.dumps(), b = cb.dumps();
|
|
if (a != b) fail(what, first_diff(ca, cb, "$"), "(got vs want at first difference)");
|
|
}
|
|
|
|
void expect_str(const std::string& what, const std::string& got, const std::string& want) {
|
|
++g_checks;
|
|
++g_by_kind[g_kind].first;
|
|
if (got != want) fail(what, got, want);
|
|
}
|
|
|
|
std::string hex(const std::string& data) {
|
|
static const char* digits = "0123456789abcdef";
|
|
std::string out;
|
|
for (unsigned char c : data) {
|
|
out.push_back(digits[c >> 4]);
|
|
out.push_back(digits[c & 15]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string unhex(const std::string& text) {
|
|
std::string out;
|
|
for (size_t i = 0; i + 1 < text.size(); i += 2) out.push_back(static_cast<char>(std::stoi(text.substr(i, 2), nullptr, 16)));
|
|
return out;
|
|
}
|
|
|
|
Json S(const std::string& s) { return Json::string(s); }
|
|
Json I(long long v) { return Json::integer(v); }
|
|
Json B(bool v) { return Json::boolean(v); }
|
|
Json A(Json::Array items = {}) { return Json::array(std::move(items)); }
|
|
Json frag(Color c) { return c == 0 ? Json() : S(color_sgr(c)); }
|
|
Color color_of(const Json& j) { return j.is_null() ? 0 : color_from_sgr(j.as_string()); }
|
|
|
|
Json cell_json(const Cell& c) { return A({I(static_cast<long long>(c.ch)), frag(c.fg), frag(c.bg), I(c.attr)}); }
|
|
Cell cell_from(const Json& j) {
|
|
const auto& a = j.as_array();
|
|
return Cell{static_cast<wchar_t>(a[0].as_int()), color_of(a[1]), color_of(a[2]), static_cast<int>(a[3].as_int())};
|
|
}
|
|
Json grid_json(const Grid& g) {
|
|
Json::Array rows;
|
|
for (const Row& r : g) {
|
|
Json::Array cells;
|
|
for (const Cell& c : r) cells.push_back(cell_json(c));
|
|
rows.push_back(A(cells));
|
|
}
|
|
return A(rows);
|
|
}
|
|
Grid grid_from(const Json& j) {
|
|
Grid g;
|
|
for (const Json& r : j.as_array()) {
|
|
Row row;
|
|
for (const Json& c : r.as_array()) row.push_back(cell_from(c));
|
|
g.push_back(row);
|
|
}
|
|
return g;
|
|
}
|
|
Json canvas_json(const MplexCanvas& c) {
|
|
Json cursor;
|
|
if (c.cursor) cursor = A({I(c.cursor->first), I(c.cursor->second)});
|
|
return Json::object({{"rows", I(c.rows)}, {"cols", I(c.cols)}, {"cells", grid_json(c.cells)}, {"cursor", cursor}});
|
|
}
|
|
Style style_from(const Json& j) {
|
|
const auto& a = j.as_array();
|
|
return Style{color_of(a[0]), color_of(a[1]), static_cast<int>(a[2].as_int())};
|
|
}
|
|
Json opt_int(const std::optional<int>& v) { return v ? I(*v) : Json(); }
|
|
|
|
std::string g_scratch;
|
|
std::unique_ptr<MplexServer> g_stub_server;
|
|
MplexServer& stub_server() {
|
|
if (!g_stub_server) {
|
|
std::string home = g_scratch + "/cpp_stub_home";
|
|
::mkdir(g_scratch.c_str(), 0700);
|
|
::mkdir(home.c_str(), 0700);
|
|
g_stub_server = std::make_unique<MplexServer>(home);
|
|
}
|
|
return *g_stub_server;
|
|
}
|
|
|
|
// ---- A..D ------------------------------------------------------------------------------------
|
|
|
|
void t_border(const Json& c) {
|
|
const auto& f = c.get("flags")->as_array();
|
|
wchar_t got = MplexCanvas::border_char(f[0].as_bool(), f[1].as_bool(), f[2].as_bool(), f[3].as_bool(),
|
|
c.get("lines")->as_string());
|
|
expect_json("border_char " + c.get("lines")->as_string(), I(static_cast<long long>(got)), *c.get("want"));
|
|
}
|
|
|
|
void t_encode(const Json& c) {
|
|
const Json& cv = *c.get("canvas");
|
|
MplexCanvas canvas(static_cast<int>(cv.get("rows")->as_int()), static_cast<int>(cv.get("cols")->as_int()));
|
|
canvas.cells = grid_from(*cv.get("cells"));
|
|
const Json& cur = *cv.get("cursor");
|
|
if (!cur.is_null()) canvas.cursor = std::make_pair(static_cast<int>(cur.as_array()[0].as_int()), static_cast<int>(cur.as_array()[1].as_int()));
|
|
const Json& prev = *c.get("prev");
|
|
Grid prev_grid;
|
|
const Grid* prev_ptr = nullptr;
|
|
if (!prev.is_null()) {
|
|
prev_grid = grid_from(prev);
|
|
prev_ptr = &prev_grid;
|
|
}
|
|
auto result = canvas.encode(prev_ptr);
|
|
expect_str("encode bytes", hex(result.first), c.get("want")->as_string());
|
|
expect_json("encode snapshot", grid_json(result.second), *cv.get("cells"));
|
|
}
|
|
|
|
void t_statustext(const Json& c) {
|
|
Style base = style_from(*c.get("base"));
|
|
std::optional<Style> reset;
|
|
if (!c.get("reset")->is_null()) reset = style_from(*c.get("reset"));
|
|
Row cells = MplexStatusText::cells(to_ustr(c.get("text")->as_string()), base, reset);
|
|
Json::Array out;
|
|
for (const Cell& cell : cells) out.push_back(cell_json(cell));
|
|
expect_json("statustext " + c.get("text")->as_string(), A(out), *c.get("want"));
|
|
}
|
|
|
|
void t_font(const Json& c) {
|
|
UStr text = to_ustr(c.get("text")->as_string());
|
|
Grid rows = MplexFont::draw(text, color_of(*c.get("bg")));
|
|
expect_json("font draw " + c.get("text")->as_string(), grid_json(rows), *c.get("want"));
|
|
expect_json("font width", I(MplexFont::width(text)), *c.get("width"));
|
|
}
|
|
|
|
// ---- E: prompt --------------------------------------------------------------------------------------
|
|
|
|
void t_printable(const Json& c) {
|
|
expect_str("printable", MplexPrompt::printable(c.get("name")->as_string(), unhex(c.get("raw")->as_string())),
|
|
c.get("want")->as_string());
|
|
}
|
|
|
|
void t_prompt(const Json& c) {
|
|
MplexServer& server = stub_server();
|
|
server.buffers.clear();
|
|
server.buffers.push_back(MplexBuffer{"b0", c.get("buf")->as_string()});
|
|
auto client = std::make_shared<MplexClientConn>(&server, -1, 0);
|
|
Json events = A();
|
|
auto history = std::make_shared<std::vector<std::string>>();
|
|
for (const Json& h : c.get("history")->as_array()) history->push_back(h.as_string());
|
|
MplexPromptOptions options;
|
|
options.history = history;
|
|
const std::string mode = c.get("mode")->as_string();
|
|
options.single = mode == "single";
|
|
options.numeric = mode == "numeric";
|
|
options.key_name = mode == "key_name";
|
|
options.confirm = mode == "confirm";
|
|
options.on_change = [&](const std::string& t) { events.push(A({S("change"), S(t)})); };
|
|
options.on_cancel = [&]() { events.push(A({S("cancel")})); };
|
|
if (c.get("step")->as_bool())
|
|
options.on_step = [&](const std::string& n, const std::string& t) { events.push(A({S("step"), S(n), S(t)})); };
|
|
auto prompt = std::make_shared<MplexPrompt>(c.get("label")->as_string(), c.get("text")->as_string(),
|
|
[&](const std::string& v) { events.push(A({S("done"), S(v)})); }, options);
|
|
client->prompt = prompt;
|
|
Json trace = A();
|
|
for (const Json& k : c.get("keys")->as_array()) {
|
|
prompt->key(server, *client, k.as_array()[0].as_string(), unhex(k.as_array()[1].as_string()));
|
|
Json idx;
|
|
if (prompt->hist_index) idx = I(*prompt->hist_index);
|
|
trace.push(A({S(to_utf8(prompt->text)), I(prompt->pos), idx, B(client->prompt == nullptr)}));
|
|
}
|
|
Json hist_after = A();
|
|
for (const auto& h : *history) hist_after.push(S(h));
|
|
Json want_view = *c.get("view");
|
|
auto view = prompt->view();
|
|
expect_json("prompt view", A({S(to_utf8(view.first)), I(view.second)}), want_view);
|
|
expect_json("prompt trace/events", Json::object({{"trace", trace}, {"events", events}, {"history_after", hist_after}}),
|
|
*c.get("want"));
|
|
}
|
|
|
|
// ---- F: chooser ----------------------------------------------------------------------------------------
|
|
|
|
struct TreeNode {
|
|
std::string label;
|
|
long long payload;
|
|
std::vector<TreeNode> kids;
|
|
};
|
|
TreeNode tree_from(const Json& j) {
|
|
TreeNode n;
|
|
n.label = j.as_array()[0].as_string();
|
|
n.payload = j.as_array()[1].as_int();
|
|
for (const Json& k : j.as_array()[2].as_array()) n.kids.push_back(tree_from(k));
|
|
return n;
|
|
}
|
|
bool remove_payload(std::vector<TreeNode>& nodes, long long payload) {
|
|
std::vector<TreeNode> keep;
|
|
for (auto& n : nodes) {
|
|
if (n.payload == payload) continue;
|
|
remove_payload(n.kids, payload);
|
|
keep.push_back(n);
|
|
}
|
|
nodes = keep;
|
|
return true;
|
|
}
|
|
std::vector<MplexChoice::Ptr> build_nodes(const std::vector<TreeNode>& nodes) {
|
|
std::vector<MplexChoice::Ptr> out;
|
|
for (const auto& n : nodes) out.push_back(std::make_shared<MplexChoice>(n.label, I(n.payload), build_nodes(n.kids), false));
|
|
return out;
|
|
}
|
|
|
|
void t_chooser(const Json& c) {
|
|
MplexServer& server = stub_server();
|
|
int w = static_cast<int>(c.get("w")->as_int()), h = static_cast<int>(c.get("h")->as_int());
|
|
auto pane = std::make_shared<MplexPane>(0, w, h, 100, g_scratch, "chooser");
|
|
server.panes.clear();
|
|
server.panes.set(0, pane);
|
|
auto client = std::make_shared<MplexClientConn>(&server, -1, 0);
|
|
auto data = std::make_shared<std::vector<TreeNode>>();
|
|
for (const Json& n : c.get("tree")->as_array()) data->push_back(tree_from(n));
|
|
bool expand_first = c.get("expand_first")->as_bool();
|
|
Json chosen = A(), deleted = A();
|
|
auto builder = [data, expand_first]() {
|
|
auto built = build_nodes(*data);
|
|
if (expand_first)
|
|
for (auto& n : built) n->expanded = true;
|
|
return built;
|
|
};
|
|
auto on_choose = [&](const MplexChoice::Ptr& n, MplexClientConn&) { chosen.push(n->payload); };
|
|
MplexChooser::OnDelete on_delete = nullptr;
|
|
if (c.get("delete")->as_bool())
|
|
on_delete = [&, data](const MplexChoice::Ptr& n, MplexClientConn&) {
|
|
deleted.push(n->payload);
|
|
remove_payload(*data, n->payload.as_int());
|
|
};
|
|
MplexChooser::Preview preview = nullptr;
|
|
if (c.get("preview")->as_bool())
|
|
preview = [](const MplexChoice::Ptr& item, int pw, int ph) {
|
|
Grid g;
|
|
UStr line = to_ustr("pv:" + item->label);
|
|
if (static_cast<int>(line.size()) > pw) line.resize(static_cast<size_t>(std::max(0, pw)));
|
|
for (int k = 0; k < ph; ++k) {
|
|
Row r;
|
|
for (wchar_t ch : line) r.push_back(Cell{ch, 0, 0, 0});
|
|
g.push_back(r);
|
|
}
|
|
return g;
|
|
};
|
|
std::optional<Json> selected;
|
|
if (!c.get("selected")->is_null()) selected = *c.get("selected");
|
|
auto mode = std::make_shared<MplexChooser>(&server, pane, c.get("name")->as_string(), builder, on_choose, on_delete, preview,
|
|
selected);
|
|
pane->push_mode(mode);
|
|
auto state = [&]() {
|
|
Json visible = A();
|
|
for (const auto& n : mode->visible) visible.push(A({S(n->label), I(n->depth), B(n->expanded), B(n->tag)}));
|
|
bool in_modes = std::find(pane->modes.begin(), pane->modes.end(), mode) != pane->modes.end();
|
|
Json prompt_v;
|
|
if (client->prompt) prompt_v = S(to_utf8(client->prompt->view().first));
|
|
Json st = Json::object({{"cursor", I(mode->cursor)}, {"visible", visible}, {"closed", B(!in_modes)}, {"prompt", prompt_v},
|
|
{"chosen", chosen}, {"deleted", deleted}, {"show_preview", B(mode->show_preview)}});
|
|
if (in_modes) st.set("grid", grid_json(mode->render(*client).grid));
|
|
return st;
|
|
};
|
|
Json states = A({state()});
|
|
for (const Json& k : c.get("keys")->as_array()) {
|
|
const std::string& name = k.as_string();
|
|
if (client->prompt)
|
|
client->prompt->key(server, *client, name, name);
|
|
else
|
|
mode->key(name, name, *client);
|
|
states.push(state());
|
|
}
|
|
expect_json("chooser states", states, *c.get("want"));
|
|
server.panes.clear();
|
|
}
|
|
|
|
// ---- G: flow screen -------------------------------------------------------------------------------------
|
|
|
|
void t_flowscreen(const Json& c) {
|
|
MplexServer& server = stub_server();
|
|
server.flows.clear();
|
|
MplexFlowRecord rec;
|
|
rec.id = 7;
|
|
rec.until = 0;
|
|
server.flows.set(7, rec);
|
|
auto client = std::make_shared<MplexClientConn>(&server, -1, 0);
|
|
std::vector<std::string> options;
|
|
for (const Json& o : c.get("options")->as_array()) options.push_back(o.as_string());
|
|
auto screen = std::make_shared<MplexFlowScreen>(&server, 7, c.get("title")->as_string(), c.get("text")->as_string(), options,
|
|
static_cast<int>(c.get("cursor")->as_int()), 123.5);
|
|
client->overlay = screen;
|
|
int rows = static_cast<int>(c.get("rows")->as_int()), cols = static_cast<int>(c.get("cols")->as_int());
|
|
MplexCanvas canvas(rows, cols);
|
|
screen->draw(canvas, static_cast<int>(c.get("area_y")->as_int()));
|
|
expect_json("flow draw", canvas_json(canvas), *c.get("canvas"));
|
|
expect_json("flow cursor", I(screen->cursor), *c.get("initial_cursor"));
|
|
Json lines = A();
|
|
for (const auto& l : screen->rows(std::max(20, std::min(cols - 4, 68)))) lines.push(S(l));
|
|
expect_json("flow rows", lines, *c.get("lines"));
|
|
Json states = A();
|
|
const Json& drop = *c.get("drop_at");
|
|
int i = 0;
|
|
for (const Json& k : c.get("keys")->as_array()) {
|
|
if (!drop.is_null() && i == drop.as_int()) server.flows.erase(7);
|
|
screen->key(k.as_string(), k.as_string(), *client);
|
|
Json ans;
|
|
if (auto* r = server.flows.find(7)) {
|
|
if (r->answer) ans = Json::object({{"status", S(r->answer->status)}, {"value", r->answer->value}});
|
|
}
|
|
states.push(A({I(screen->cursor), ans, B(client->overlay == nullptr), B(client->dirty)}));
|
|
++i;
|
|
}
|
|
expect_json("flow keys", states, *c.get("want"));
|
|
}
|
|
|
|
// ---- H, I: the model ------------------------------------------------------------------------------------
|
|
|
|
struct Model {
|
|
explicit Model(const std::string& home) : server(home) {}
|
|
MplexServer server;
|
|
std::vector<std::shared_ptr<MplexSession>> sessions;
|
|
std::vector<std::shared_ptr<MplexWindow>> windows;
|
|
std::shared_ptr<MplexSession> cur_session;
|
|
std::shared_ptr<MplexWindow> cur_window;
|
|
Json errors = A();
|
|
|
|
std::shared_ptr<MplexPane> pane_at(long pos) {
|
|
auto panes = cur_window->panes();
|
|
return panes[static_cast<size_t>(((pos % static_cast<long>(panes.size())) + panes.size()) % panes.size())];
|
|
}
|
|
|
|
void op(const Json& item) {
|
|
const auto& a = item.as_array();
|
|
const std::string kind = a[0].as_string();
|
|
try {
|
|
if (kind == "session") {
|
|
auto s = std::make_shared<MplexSession>(server.next_id("session"), a[1].as_string(), server.options, "/",
|
|
MplexPane::EnvList{});
|
|
s->size = std::make_pair(static_cast<int>(a[2].as_int()), static_cast<int>(a[3].as_int()));
|
|
server.sessions.set(s->id, s);
|
|
sessions.push_back(s);
|
|
cur_session = s;
|
|
} else if (kind == "window") {
|
|
auto session = cur_session;
|
|
int cols = session->size->first, rows = session->size->second;
|
|
int width = std::max(1, cols);
|
|
int height = std::max(1, rows - (server.options->get("status").truthy() ? 1 : 0));
|
|
auto window = std::make_shared<MplexWindow>(server.next_id("window"), a[1].as_string(), width, height, &session->options);
|
|
window->set_session(session);
|
|
auto pane = std::make_shared<MplexPane>(server.next_id("pane"), window->width, window->height, 50, server.dir, "mdl");
|
|
pane->set_window(window);
|
|
server.panes.set(pane->id, pane);
|
|
window->root = std::make_shared<MplexLayoutNode>("pane", pane, window->width, window->height);
|
|
window->active = pane;
|
|
pane->last_active = 1.0;
|
|
server.register_window(session, window, static_cast<int>(a[2].as_int()), a[3].as_bool());
|
|
window->relayout();
|
|
windows.push_back(window);
|
|
cur_window = window;
|
|
} else if (kind == "split") {
|
|
auto window = cur_window;
|
|
auto target = pane_at(a[1].as_int());
|
|
auto leaf = window->leaf_of(target);
|
|
std::string direction = a[2].as_string();
|
|
int span = direction == "lr" ? leaf->w : leaf->h;
|
|
if (span < 3) throw MplexError("no space for new pane");
|
|
auto pane = std::make_shared<MplexPane>(server.next_id("pane"), 1, 1, 50, server.dir, "mdl");
|
|
pane->set_window(window);
|
|
server.panes.set(pane->id, pane);
|
|
int size = a[4].is_null() ? -1 : static_cast<int>(a[4].as_int());
|
|
auto split = MplexLayout::split(window->root, leaf, direction, a[3].as_bool(), pane, size);
|
|
window->root = split.first;
|
|
window->layout_index = -1;
|
|
window->relayout();
|
|
pane->last_active = static_cast<double>(pane->id);
|
|
window->set_active(pane);
|
|
window->active->last_active = static_cast<double>(pane->id);
|
|
} else if (kind == "feed") {
|
|
pane_at(a[1].as_int())->feed(a[2].as_string());
|
|
} else if (kind == "active") {
|
|
auto pane = pane_at(a[1].as_int());
|
|
cur_window->set_active(pane);
|
|
pane->last_active = static_cast<double>(pane->id);
|
|
} else if (kind == "zoom") {
|
|
cur_window->zoomed = pane_at(a[1].as_int());
|
|
cur_window->relayout();
|
|
} else if (kind == "opt") {
|
|
const std::string scope = a[1].as_string();
|
|
MplexOptions* target = scope == "server" ? server.options : scope == "session" ? &cur_session->options : &cur_window->options;
|
|
target->set(a[2].as_string(), a[3].as_string());
|
|
} else if (kind == "select") {
|
|
cur_session->select(static_cast<int>(a[1].as_int()));
|
|
} else if (kind == "sessopt") {
|
|
long n = static_cast<long>(sessions.size());
|
|
sessions[static_cast<size_t>(((a[1].as_int() % n) + n) % n)]->options.set(a[2].as_string(), a[3].as_string());
|
|
} else if (kind == "mark") {
|
|
server.marked = pane_at(a[1].as_int());
|
|
} else if (kind == "curwin") {
|
|
long n = static_cast<long>(windows.size());
|
|
cur_window = windows[static_cast<size_t>(((a[1].as_int() % n) + n) % n)];
|
|
cur_session = cur_window->session();
|
|
}
|
|
} catch (const MplexError& e) {
|
|
errors.push(A({S(kind), S(e.what())}));
|
|
}
|
|
}
|
|
|
|
Json dump() {
|
|
Json sess_list = A();
|
|
for (const auto& s : server.sessions.values()) {
|
|
Json wins = A();
|
|
for (int index : s->indexes()) {
|
|
auto w = *s->windows.find(index);
|
|
Json panes = A();
|
|
for (const auto& p : w->panes()) panes.push(A({I(p->id), I(p->x), I(p->y), I(p->w), I(p->h), Json::number(p->last_active)}));
|
|
wins.push(Json::object({{"index", I(index)}, {"id", I(w->id)}, {"name", S(w->name)}, {"w", I(w->width)},
|
|
{"h", I(w->height)}, {"active", I(w->active->id)},
|
|
{"last_pane", w->last_pane ? I(w->last_pane->id) : Json()},
|
|
{"zoomed", w->zoomed ? I(w->zoomed->id) : Json()}, {"panes", panes}}));
|
|
}
|
|
Json hist = A(), idx = A();
|
|
for (const auto& h : s->history) hist.push(I(h->id));
|
|
for (int i : s->indexes()) idx.push(I(i));
|
|
sess_list.push(Json::object({{"id", I(s->id)}, {"name", S(s->name)}, {"current", opt_int(s->current)},
|
|
{"last", opt_int(s->last())}, {"history", hist}, {"indexes", idx},
|
|
{"free0", I(s->free_index(0))}, {"free3", I(s->free_index(3))}, {"tag", S(s->tag())},
|
|
{"windows", wins}}));
|
|
}
|
|
return Json::object({{"errors", errors}, {"sessions", sess_list}});
|
|
}
|
|
};
|
|
|
|
int g_model_seq = 0;
|
|
std::unique_ptr<Model> build_model(const Json& ops) {
|
|
std::string home = g_scratch + "/cpp_model" + std::to_string(g_model_seq++);
|
|
::mkdir(home.c_str(), 0700);
|
|
auto model = std::make_unique<Model>(home);
|
|
for (const Json& item : ops.as_array()) model->op(item);
|
|
return model;
|
|
}
|
|
|
|
void t_render(const Json& c) {
|
|
auto model = build_model(*c.get("ops"));
|
|
expect_json("model dump", model->dump(), *c.get("dump"));
|
|
MplexServer& server = model->server;
|
|
for (const Json& spec : c.get("clients")->as_array()) {
|
|
auto conn = std::make_shared<MplexClientConn>(&server, -1, 0);
|
|
conn->session = *server.sessions.find(static_cast<int>(spec.get("session")->as_int()));
|
|
conn->attached = true;
|
|
conn->rows = static_cast<int>(spec.get("rows")->as_int());
|
|
conn->cols = static_cast<int>(spec.get("cols")->as_int());
|
|
conn->table = "root";
|
|
if (const Json* p = spec.get("prompt")) {
|
|
conn->prompt = std::make_shared<MplexPrompt>(p->as_array()[0].as_string(), p->as_array()[1].as_string(),
|
|
[](const std::string&) {});
|
|
conn->prompt->pos = p->as_array()[2].as_int();
|
|
} else if (const Json* m = spec.get("message")) {
|
|
conn->message = std::make_pair(m->as_string(), py::monotonic() + 1000);
|
|
} else if (const Json* e = spec.get("message_expired")) {
|
|
conn->message = std::make_pair(e->as_string(), py::monotonic() - 1);
|
|
} else if (const Json* o = spec.get("overlay")) {
|
|
if (o->as_string() == "numbers") {
|
|
conn->overlay = std::make_shared<MplexPaneNumbers>(&server, conn->session->window(), py::monotonic() + 1000);
|
|
} else {
|
|
MplexFlowRecord rec;
|
|
rec.id = 1;
|
|
server.flows.set(1, rec);
|
|
std::string body;
|
|
for (int i = 0; i < 3; ++i) body += "Some body text to wrap ";
|
|
conn->overlay = std::make_shared<MplexFlowScreen>(&server, 1, "Title", body, std::vector<std::string>{"yes", "no", "maybe"}, 1, 1e12);
|
|
}
|
|
}
|
|
MplexCanvas canvas = server.renderer->frame(*conn);
|
|
expect_json("render frame", canvas_json(canvas), *spec.get("want"));
|
|
}
|
|
}
|
|
|
|
void t_targets(const Json& c) {
|
|
auto model = build_model(*c.get("ops"));
|
|
expect_json("model dump", model->dump(), *c.get("dump"));
|
|
MplexServer& server = model->server;
|
|
auto sess_list = server.sessions.values();
|
|
for (const Json& q : c.get("queries")->as_array()) {
|
|
const Json& info = *q.get("ctx");
|
|
std::shared_ptr<MplexClientConn> conn;
|
|
std::shared_ptr<MplexPane> pane;
|
|
if (const Json* s = info.get("session")) {
|
|
conn = std::make_shared<MplexClientConn>(&server, -1, 0);
|
|
conn->session = *server.sessions.find(static_cast<int>(s->as_int()));
|
|
} else if (const Json* p = info.get("pane")) {
|
|
pane = *server.panes.find(static_cast<int>(p->as_int()));
|
|
} else if (const Json* e = info.get("env_pane")) {
|
|
conn = std::make_shared<MplexClientConn>(&server, -1, 0);
|
|
conn->env_pane = static_cast<int>(e->as_int());
|
|
} else if (info.get("nosess")) {
|
|
conn = std::make_shared<MplexClientConn>(&server, -1, 0);
|
|
}
|
|
MplexCtx ctx(&server, conn, pane);
|
|
expect_json("ctx ids",
|
|
A({ctx.session ? I(ctx.session->id) : Json(), ctx.window ? I(ctx.window->id) : Json(), ctx.pane ? I(ctx.pane->id) : Json()}),
|
|
*q.get("ctx_ids"));
|
|
for (const Json& r : q.get("results")->as_array()) {
|
|
const std::string which = r.as_array()[0].as_string();
|
|
const Json& arg = r.as_array()[1];
|
|
Json got;
|
|
try {
|
|
if (which == "session") {
|
|
got = A({S("ok"), I(MplexTargets::session(ctx, arg.as_string())->id)});
|
|
} else if (which == "window") {
|
|
auto t = MplexTargets::window(ctx, arg.as_string());
|
|
got = A({S("ok"), I(t.session->id), opt_int(t.index), I(t.window->id)});
|
|
} else if (which == "pane") {
|
|
auto t = MplexTargets::pane(ctx, arg.as_string());
|
|
got = A({S("ok"), t.session ? I(t.session->id) : Json(), t.window ? I(t.window->id) : Json(), t.pane ? I(t.pane->id) : Json()});
|
|
} else {
|
|
auto s = *server.sessions.find(static_cast<int>(arg.as_array()[0].as_int()));
|
|
got = opt_int(MplexTargets::window_index(s, arg.as_array()[1].as_string()));
|
|
}
|
|
} catch (const MplexError& e) {
|
|
got = A({S("err"), S(e.what())});
|
|
}
|
|
expect_json("target " + which + " '" + (arg.is_string() ? arg.as_string() : arg.dumps()) + "'", got, r.as_array()[2]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- J: framing -----------------------------------------------------------------------------------------
|
|
|
|
void t_frames(const Json& c) {
|
|
MplexServer& server = stub_server();
|
|
auto conn = std::make_shared<MplexClientConn>(&server, -1, 1);
|
|
Json got = A();
|
|
for (const Json& chunk : c.get("chunks")->as_array()) {
|
|
auto res = conn->frames(unhex(chunk.as_string()));
|
|
if (!res) {
|
|
got.push(Json());
|
|
break;
|
|
}
|
|
Json frames = A();
|
|
for (const auto& f : *res) frames.push(A({S(hex(f.first)), S(hex(f.second))}));
|
|
got.push(frames);
|
|
}
|
|
expect_json("frames", got, *c.get("want"));
|
|
auto sent = std::make_shared<MplexClientConn>(&server, -1, 2);
|
|
Json sends = A();
|
|
for (const Json& s : c.get("sends")->as_array()) {
|
|
bool r = sent->send(unhex(s.as_array()[0].as_string()), unhex(s.as_array()[1].as_string()));
|
|
sends.push(A({s.as_array()[0], s.as_array()[1], B(r)}));
|
|
}
|
|
expect_json("sends", sends, *c.get("sends"));
|
|
expect_str("out bytes", hex(sent->out), c.get("out")->as_string());
|
|
}
|
|
|
|
void t_send_full(const Json& c) {
|
|
MplexServer& server = stub_server();
|
|
auto conn = std::make_shared<MplexClientConn>(&server, -1, 3);
|
|
conn->out.assign(static_cast<size_t>(MPLEX_CLIENT_OUT_MAX) + 1, 'x');
|
|
conn->prev = Grid{};
|
|
bool r = conn->send("O", "abc");
|
|
expect_json("send_full", A({B(r), B(!conn->prev.has_value()), I(static_cast<long long>(conn->out.size()))}), *c.get("want"));
|
|
}
|
|
|
|
// ---- pane / pty ----------------------------------------------------------------------------------------------
|
|
|
|
void read_into(MplexPane& pane, int ms) {
|
|
struct pollfd pfd;
|
|
pfd.fd = pane.master_fd;
|
|
pfd.events = POLLIN;
|
|
while (pane.master_fd >= 0 && ::poll(&pfd, 1, ms) > 0) {
|
|
char buf[4096];
|
|
ssize_t n = ::read(pane.master_fd, buf, sizeof buf);
|
|
if (n <= 0) break;
|
|
pane.feed(Bytes(buf, static_cast<size_t>(n)));
|
|
}
|
|
}
|
|
|
|
void t_pane_pty() {
|
|
g_kind = "pane_pty";
|
|
std::string dir = g_scratch + "/pane_logs";
|
|
::mkdir(dir.c_str(), 0700);
|
|
MplexPane pane(5, 40, 10, 100, dir, "stamp");
|
|
check(pane.tag() == "%5", "tag");
|
|
check(pane.w == 40 && pane.h == 10, "size");
|
|
check(pane.log_path == dir + "/stamp-pane5.log", "log path");
|
|
pane.spawn("/bin/sh", {"sh", "-c", "echo hello-pane; read x; echo got:$x; sleep 30"}, "/tmp",
|
|
{{"PATH", "/usr/bin:/bin"}, {"TERM", "xterm"}});
|
|
check(pane.master_fd >= 0 && pane.pid.has_value(), "spawned");
|
|
check(pane.shell_name == "sh", "shell_name");
|
|
read_into(pane, 500);
|
|
std::string text = pane.screen.text();
|
|
check(text.find("hello-pane") != std::string::npos, "echo reached the screen: " + text);
|
|
pane.write("abc\n");
|
|
check(pane.pending_in == "abc\n", "write queued");
|
|
pane.drain();
|
|
check(pane.pending_in.empty(), "drain wrote");
|
|
read_into(pane, 500);
|
|
text = pane.screen.text();
|
|
check(text.find("got:abc") != std::string::npos, "stdin echoed through pty: " + text);
|
|
check(pane.current_path() == "/tmp", "current_path " + pane.current_path());
|
|
std::string fg = pane.foreground_command();
|
|
check(!fg.empty(), "foreground_command " + fg);
|
|
std::string tail = pane.tail_log(3);
|
|
check(tail.find("got:abc") != std::string::npos, "tail_log: " + tail);
|
|
check(pane.tail_log(1).find("sleep") == std::string::npos, "tail_log one line");
|
|
// resize pushes TIOCSWINSZ
|
|
pane.place(3, 4, 30, 8);
|
|
check(pane.x == 3 && pane.y == 4 && pane.w == 30 && pane.h == 8 && pane.screen.rows == 8 && pane.screen.cols == 30, "place");
|
|
// overflow guard
|
|
Bytes big(static_cast<size_t>(MPLEX_PANE_IN_MAX) + 1, 'z');
|
|
pane.write(big);
|
|
check(pane.dropped == static_cast<long>(big.size()), "pane input cap counted");
|
|
pane.pending_in.clear();
|
|
// log rollover at MPLEX_LOG_MAX
|
|
Bytes chunk(1 << 20, 'a');
|
|
for (int i = 0; i < 17; ++i) pane.feed(chunk);
|
|
struct stat st;
|
|
check(::stat((pane.log_path + ".1").c_str(), &st) == 0, "log rolled over to .1");
|
|
check(::stat(pane.log_path.c_str(), &st) == 0 && st.st_size < (1 << 24), "fresh log smaller than the cap");
|
|
pid_t pid = *pane.pid;
|
|
pane.close(true);
|
|
check(pane.master_fd == -1, "master closed");
|
|
int status = 0;
|
|
pid_t done = ::waitpid(pid, &status, 0);
|
|
check(done == pid && WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL, "hard close killed the group");
|
|
// history eviction accounting: 100 line cap, push 300 lines through a 5 row screen
|
|
struct Probe : MplexMode {
|
|
using MplexMode::MplexMode;
|
|
long pushed = 0, evicted = 0;
|
|
void on_output(long p, long e) override {
|
|
pushed += p;
|
|
evicted += e;
|
|
}
|
|
};
|
|
auto probe = std::make_shared<Probe>(nullptr, nullptr);
|
|
auto sp = std::make_shared<MplexPane>(7, 20, 5, 10, dir, "stamp");
|
|
sp->push_mode(probe);
|
|
std::string lines;
|
|
for (int i = 0; i < 300; ++i) lines += "line " + std::to_string(i) + "\r\n";
|
|
sp->feed(lines);
|
|
check(sp->history_size() == 10, "history capped at the limit: " + std::to_string(sp->history_size()));
|
|
check(probe->pushed == 296 && probe->evicted == 286, "on_output pushed/evicted " + std::to_string(probe->pushed) + "/" + std::to_string(probe->evicted));
|
|
sp->clear_history();
|
|
check(sp->history_size() == 0, "clear_history");
|
|
// pop/find mode
|
|
check(sp->find_mode<Probe>() == probe && sp->top_mode() == probe, "find_mode");
|
|
sp->pop_mode();
|
|
check(sp->top_mode() == nullptr, "pop_mode");
|
|
// execvpe PATH search failure exits 127
|
|
MplexPane bad(8, 10, 3, 5, dir, "stamp");
|
|
bad.spawn("no-such-binary-xyz", {"x"}, "/", {{"PATH", "/nonexistent"}});
|
|
int st2 = 0;
|
|
::waitpid(*bad.pid, &st2, 0);
|
|
check(WIFEXITED(st2) && WEXITSTATUS(st2) == 127, "exec failure exits 127");
|
|
bad.close();
|
|
}
|
|
|
|
void t_session_window_units() {
|
|
g_kind = "session_units";
|
|
MplexOptions root;
|
|
MplexSession s(3, "n", &root, "/", {});
|
|
check(s.tag() == "$3" && s.indexes().empty() && !s.window() && !s.last().has_value(), "empty session");
|
|
auto w0 = std::make_shared<MplexWindow>(0, "a", 10, 5, &s.options);
|
|
auto w1 = std::make_shared<MplexWindow>(1, "b", 10, 5, &s.options);
|
|
auto w2 = std::make_shared<MplexWindow>(2, "c", 10, 5, &s.options);
|
|
s.windows.set(2, w0);
|
|
s.windows.set(5, w1);
|
|
s.windows.set(0, w2);
|
|
check(s.indexes() == (std::vector<int>{0, 2, 5}), "indexes sorted");
|
|
check(s.free_index(0) == 1 && s.free_index(2) == 3 && s.free_index(9) == 9, "free_index");
|
|
check(!s.select(7), "select missing");
|
|
check(s.select(2) && s.current == 2 && s.history.empty(), "select first");
|
|
check(s.select(5) && s.last() == 2 && s.history.size() == 1 && s.history[0] == w0, "select second");
|
|
check(s.select(0) && s.last() == 5 && s.history.size() == 2 && s.history[0] == w1 && s.history[1] == w0, "select third");
|
|
check(s.select(2) && s.last() == 0 && s.history.size() == 2 && s.history[0] == w2 && s.history[1] == w1, "select back");
|
|
check(s.index_of(w1) == 5 && !s.index_of(std::make_shared<MplexWindow>(9, "z", 1, 1, nullptr)).has_value(), "index_of");
|
|
check(w0->tag() == "@0", "window tag");
|
|
check(w0->panes().empty() && w0->visible_panes().empty(), "no root no panes");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
if (argc < 3) {
|
|
std::cerr << "usage: mplex_model_test CASES.jsonl SCRATCH\n";
|
|
return 2;
|
|
}
|
|
g_scratch = argv[2];
|
|
::mkdir(g_scratch.c_str(), 0700);
|
|
std::ifstream in(argv[1]);
|
|
std::string line;
|
|
std::map<std::string, int> counts;
|
|
while (std::getline(in, line)) {
|
|
Json c = Json::loads(line);
|
|
std::string t = c.get("t")->as_string();
|
|
g_kind = t;
|
|
++counts[t];
|
|
try {
|
|
if (t == "border") t_border(c);
|
|
else if (t == "encode") t_encode(c);
|
|
else if (t == "statustext") t_statustext(c);
|
|
else if (t == "font") t_font(c);
|
|
else if (t == "prompt") t_prompt(c);
|
|
else if (t == "printable") t_printable(c);
|
|
else if (t == "chooser") t_chooser(c);
|
|
else if (t == "flowscreen") t_flowscreen(c);
|
|
else if (t == "render") t_render(c);
|
|
else if (t == "targets") t_targets(c);
|
|
else if (t == "frames") t_frames(c);
|
|
else if (t == "send_full") t_send_full(c);
|
|
else fail("unknown case kind", t, "");
|
|
} catch (const std::exception& e) {
|
|
fail("exception", e.what(), "no exception");
|
|
}
|
|
}
|
|
t_session_window_units();
|
|
t_pane_pty();
|
|
for (const auto& kv : g_by_kind)
|
|
std::cout << kv.first << ": " << kv.second.first << " checks, " << kv.second.second << " failures (" << counts[kv.first] << " cases)\n";
|
|
std::cout << "total checks " << g_checks << ", failures " << g_failures << "\n";
|
|
return g_failures == 0 ? 0 : 1;
|
|
}
|