From c2b1f4c4ae3f0b1baebb7fb9dde02cbedfe3448b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Wed, 3 Jun 2026 22:04:45 +0200 Subject: [PATCH] Extract duplicate into core; support it in the script engine + wx GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A script using `duplicate` failed with "unsupported command 'duplicate'" because the clone logic was still inline in the tui command. Extract it to core/app/edit.hpp::duplicate_module(System*, src, dst) -> {ok, error, parts, signals}: a deep clone of a module (parts, pins with spec + nc_origin, signals with type overrides, pin→signal wiring; no connections), refusing on an unknown source or an already-taken destination name. - the tui `duplicate` command renders the result (output unchanged); - the script engine dispatches `duplicate` to it — the failing script now runs; - the wx GUI gains Edit ▸ Duplicate module… (PickModule + a name prompt). tests/test_edit.cpp: deep clone wires to the clone's own signal (not the source's) and preserves the type; unknown source / existing destination refused. 412 core assertions green; tui + wx build clean. Co-Authored-By: Claude Opus 4.8 --- src/core/app/edit.cpp | 59 ++++++++++++++++++++++++++++++++++ src/core/app/edit.hpp | 16 +++++++++ src/core/app/script.cpp | 9 ++++++ src/frontends/tui/commands.cpp | 49 +++------------------------- src/frontends/wx/wx_frame.cpp | 27 ++++++++++++++++ src/frontends/wx/wx_frame.hpp | 1 + tests/test_edit.cpp | 38 ++++++++++++++++++++++ 7 files changed, 155 insertions(+), 44 deletions(-) diff --git a/src/core/app/edit.cpp b/src/core/app/edit.cpp index 45507ca..40f6b07 100644 --- a/src/core/app/edit.cpp +++ b/src/core/app/edit.cpp @@ -1,11 +1,16 @@ #include "core/app/edit.hpp" #include "core/domain/bsdl_model.hpp" +#include "core/domain/modules.hpp" #include "core/domain/parts.hpp" #include "core/domain/pin_model.hpp" +#include "core/domain/pins.hpp" #include "core/domain/signals.hpp" +#include "core/domain/system.hpp" #include "core/domain/transform_vpx.hpp" // ValidatePartForKind +#include + namespace app { SetConnectorTypeResult set_connector_type(Part *part, const std::string &kind) @@ -75,4 +80,58 @@ SetSignalTypeResult set_signal_type(Signal *sig, const std::string &type_name) return r; } +DuplicateResult duplicate_module(System *sys, const std::string &src_name, + const std::string &dst_name) +{ + DuplicateResult r; + if (!sys) { r.error = "no system"; return r; } + + Module *src; + try { src = sys->modules()->get(src_name); } + catch (const std::exception &) { + r.error = "unknown module: " + src_name; + return r; + } + if (sys->modules()->exists(dst_name)) { + r.error = "duplicate refused: module '" + dst_name + "' already exists."; + return r; + } + + Module *dst = new Module(dst_name); + + // Signals first (preserve type overrides), so pins can re-wire to them. + for (auto &skv : *src->signals) { + Signal *ss = skv.second; + Signal *ds = new Signal(ss->name); + ds->type = ss->type; + dst->signals->add(ds); + } + + // Parts, pins (spec + nc_origin), and the pin→signal wiring. + for (auto &pkv : *src) { + Part *sp = pkv.second; + Part *dp = new Part(sp->name); + dp->connector_type = sp->connector_type; + for (auto &nkv : *sp) { + Pin *sn = nkv.second; + Pin *dn = new Pin(sn->name); + dn->spec = sn->spec; + dn->nc_origin = sn->nc_origin; + dp->add(dn); + if (sn->signal()) { + Signal *ds = dst->signals->get(sn->signal()->name); + ds->add(dn); + dn->connect(ds); + } + } + dst->add(dp); + } + + sys->modules()->add(dst); + r.parts = (int)dst->size(); + r.signals = (int)dst->signals->size(); + r.ok = true; + return r; +} + } // namespace app diff --git a/src/core/app/edit.hpp b/src/core/app/edit.hpp index d30a3b3..9a7382d 100644 --- a/src/core/app/edit.hpp +++ b/src/core/app/edit.hpp @@ -7,6 +7,7 @@ class Part; class Signal; +class System; // Application layer: UI-independent part-editing operations any frontend can // call. No console, no dialogs, no FTXUI — Part in, result struct out. @@ -52,6 +53,21 @@ struct SetSignalTypeResult { // Fails (ok=false, error set, no mutation) on an unrecognised name. SetSignalTypeResult set_signal_type(Signal *sig, const std::string &type_name); +// Outcome of cloning a module under a new name. +struct DuplicateResult { + bool ok = false; + std::string error; ///< unknown source, or destination name already taken + int parts = 0; + int signals = 0; +}; + +// Deep-clone module `src_name` as `dst_name`: parts, pins (spec + nc_origin), +// signals (with type overrides) and the pin→signal wiring — but not the +// system's connections. Fails (ok=false, error set, no change) when the source +// is unknown or the destination name already exists. +DuplicateResult duplicate_module(System *sys, const std::string &src_name, + const std::string &dst_name); + } // namespace app #endif // _APP_EDIT_HPP_ diff --git a/src/core/app/script.cpp b/src/core/app/script.cpp index 81a0002..3e0eb65 100644 --- a/src/core/app/script.cpp +++ b/src/core/app/script.cpp @@ -248,6 +248,15 @@ private: + " ports bound" + (r.unbound ? (", " + std::to_string(r.unbound) + " unbound") : "")); return true; } + if (cmd == "duplicate") { + if (!need(2)) return false; + DuplicateResult r = duplicate_module(sys_.get(), a[0], a[1]); + if (!r.ok) { emit(r.error); return false; } + emit("duplicate: '" + a[0] + "' → '" + a[1] + "' (" + + std::to_string(r.parts) + " part(s), " + + std::to_string(r.signals) + " signal(s))"); + return true; + } if (cmd == "verify") { render_verify(); return true; diff --git a/src/frontends/tui/commands.cpp b/src/frontends/tui/commands.cpp index 9789d20..8dcb49a 100644 --- a/src/frontends/tui/commands.cpp +++ b/src/frontends/tui/commands.cpp @@ -595,51 +595,12 @@ void Tui::RegisterCommands() { {"new module name", Completion::None}}, [this](const std::vector &args) { if (!sys) { Print("no system: run 'new' first."); return; } - - Module *src; - try { src = sys->modules()->get(args[0]); } - catch (const std::exception &) { - Print("unknown module: " + args[0]); return; - } - if (sys->modules()->exists(args[1])) { - Print("duplicate refused: module '" + args[1] + "' already exists."); - return; - } - - Module *dst = new Module(args[1]); - - // 1. Copy signals (preserve type overrides). - for (auto &skv : *src->signals) { - Signal *ss = skv.second; - Signal *ds = new Signal(ss->name); - ds->type = ss->type; - dst->signals->add(ds); - } - - // 2. Copy parts, pins, and re-wire pin→signal. - for (auto &pkv : *src) { - Part *sp = pkv.second; - Part *dp = new Part(sp->name); - dp->connector_type = sp->connector_type; - for (auto &nkv : *sp) { - Pin *sn = nkv.second; - Pin *dn = new Pin(sn->name); - dn->spec = sn->spec; - dn->nc_origin = sn->nc_origin; - dp->add(dn); - if (sn->signal()) { - Signal *ds = dst->signals->get(sn->signal()->name); - ds->add(dn); - dn->connect(ds); - } - } - dst->add(dp); - } - - sys->modules()->add(dst); + app::DuplicateResult r = + app::duplicate_module(sys.get(), args[0], args[1]); + if (!r.ok) { Print(r.error); return; } Print("duplicate: '" + args[0] + "' → '" + args[1] + "'" - + " (" + std::to_string(dst->size()) + " part(s), " - + std::to_string(dst->signals->size()) + " signal(s))"); + + " (" + std::to_string(r.parts) + " part(s), " + + std::to_string(r.signals) + " signal(s))"); }, /*prompt_for_missing=*/ true, "clone a module under a new name (parts, pins, signals; no connections)", diff --git a/src/frontends/wx/wx_frame.cpp b/src/frontends/wx/wx_frame.cpp index ce09cf9..856819e 100644 --- a/src/frontends/wx/wx_frame.cpp +++ b/src/frontends/wx/wx_frame.cpp @@ -42,6 +42,7 @@ enum { ID_ATTACH_BSDL, ID_SET_SIGNAL_TYPE, ID_CONNECT, + ID_DUPLICATE, ID_VERIFY, ID_QUIT, ID_ABOUT, @@ -132,6 +133,8 @@ EssimFrame::EssimFrame(WxFrontend &fe) edit->Append(ID_SET_SIGNAL_TYPE, "Set &signal type…\tCtrl-G"); edit->AppendSeparator(); edit->Append(ID_CONNECT, "C&onnect parts…\tCtrl-O"); + edit->AppendSeparator(); + edit->Append(ID_DUPLICATE, "&Duplicate module…\tCtrl-D"); auto *sysm = new wxMenu; sysm->Append(ID_VERIFY, "&Verify\tCtrl-K"); @@ -195,6 +198,7 @@ EssimFrame::EssimFrame(WxFrontend &fe) Bind(wxEVT_MENU, &EssimFrame::OnAttachBsdl, this, ID_ATTACH_BSDL); Bind(wxEVT_MENU, &EssimFrame::OnSetSignalType, this, ID_SET_SIGNAL_TYPE); Bind(wxEVT_MENU, &EssimFrame::OnConnect, this, ID_CONNECT); + Bind(wxEVT_MENU, &EssimFrame::OnDuplicateModule, this, ID_DUPLICATE); Bind(wxEVT_MENU, &EssimFrame::OnVerify, this, ID_VERIFY); Bind(wxEVT_MENU, &EssimFrame::OnQuit, this, ID_QUIT); Bind(wxEVT_MENU, &EssimFrame::OnAbout, this, ID_ABOUT); @@ -594,6 +598,29 @@ void EssimFrame::OnSetSignalType(wxCommandEvent &) { RebuildModelView(); } +void EssimFrame::OnDuplicateModule(wxCommandEvent &) { + Module *m = PickModule("Duplicate module"); + if (!m) return; + const std::string src = m->name; // m may move in the table after the add + + wxTextEntryDialog dlg(this, "New module name:", "Duplicate module", + wx(src) + "_copy"); + if (dlg.ShowModal() != wxID_OK) return; + const std::string dst = dlg.GetValue().utf8_string(); + if (dst.empty()) return; + + app::DuplicateResult r = app::duplicate_module(fe_.system(), src, dst); + if (!r.ok) { + Log(wx(r.error)); + wxMessageBox(wx(r.error), "Duplicate module", wxOK | wxICON_ERROR, this); + return; + } + Log(wx("duplicate: '" + src + "' → '" + dst + "' (" + + std::to_string(r.parts) + " part(s), " + + std::to_string(r.signals) + " signal(s))")); + RebuildModelView(); +} + void EssimFrame::OnVerify(wxCommandEvent &) { app::VerifyReport r = app::verify(fe_.system()); diff --git a/src/frontends/wx/wx_frame.hpp b/src/frontends/wx/wx_frame.hpp index b378f66..0f7658d 100644 --- a/src/frontends/wx/wx_frame.hpp +++ b/src/frontends/wx/wx_frame.hpp @@ -28,6 +28,7 @@ private: void OnAttachBsdl(wxCommandEvent &); void OnConnect(wxCommandEvent &); void OnSetSignalType(wxCommandEvent &); + void OnDuplicateModule(wxCommandEvent &); void OnVerify(wxCommandEvent &); void OnQuit(wxCommandEvent &); void OnAbout(wxCommandEvent &); diff --git a/tests/test_edit.cpp b/tests/test_edit.cpp index 150b9bf..cc9cace 100644 --- a/tests/test_edit.cpp +++ b/tests/test_edit.cpp @@ -1,10 +1,12 @@ #include #include "core/app/edit.hpp" +#include "core/domain/modules.hpp" #include "core/domain/parts.hpp" #include "core/domain/pins.hpp" #include "core/domain/signal_type.hpp" #include "core/domain/signals.hpp" +#include "core/domain/system.hpp" // app::set_connector_type is pure core: validate the kind, tag the part and // apply the connector model. No Print/dialog/FTXUI. @@ -73,3 +75,39 @@ TEST_CASE("set_signal_type rejects an unknown name without mutating") { CHECK(r.error.find("power, gnd, other") != std::string::npos); CHECK(s.type == SignalType::Other); // unchanged } + +TEST_CASE("duplicate_module deep-clones parts, pins and signals") { + System sys; + Module *a = sys.modules()->merge("A"); + Part *p = new Part("J1"); a->add(p); + Pin *pin = new Pin("1"); p->add(pin); + Signal *s = a->signals->merge("NET"); + s->type = SignalType::Power; + s->add(pin); pin->connect(s); + + app::DuplicateResult r = app::duplicate_module(&sys, "A", "B"); + + CHECK(r.ok); + CHECK(r.parts == 1); + CHECK(r.signals == 1); + REQUIRE(sys.modules()->exists("B")); + Module *b = sys.modules()->get("B"); + CHECK(b->signals->get("NET")->type == SignalType::Power); // type preserved + Pin *bpin = b->get("J1")->get("1"); + REQUIRE(bpin->signal() != nullptr); + CHECK(bpin->signal() == b->signals->get("NET")); // wired to the clone's own signal + CHECK(bpin->signal() != s); // not aliasing the source +} + +TEST_CASE("duplicate_module refuses an unknown source or an existing destination") { + System sys; + sys.modules()->merge("A"); + + app::DuplicateResult dst = app::duplicate_module(&sys, "A", "A"); + CHECK_FALSE(dst.ok); + CHECK(dst.error.find("already exists") != std::string::npos); + + app::DuplicateResult unk = app::duplicate_module(&sys, "NOPE", "X"); + CHECK_FALSE(unk.ok); + CHECK(unk.error.find("unknown module") != std::string::npos); +}