Extract duplicate into core; support it in the script engine + wx GUI

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:04:45 +02:00
parent 794430e86c
commit c2b1f4c4ae
7 changed files with 155 additions and 44 deletions

View File

@@ -1,11 +1,16 @@
#include "core/app/edit.hpp" #include "core/app/edit.hpp"
#include "core/domain/bsdl_model.hpp" #include "core/domain/bsdl_model.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp" #include "core/domain/parts.hpp"
#include "core/domain/pin_model.hpp" #include "core/domain/pin_model.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp" #include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform_vpx.hpp" // ValidatePartForKind #include "core/domain/transform_vpx.hpp" // ValidatePartForKind
#include <exception>
namespace app { namespace app {
SetConnectorTypeResult set_connector_type(Part *part, const std::string &kind) 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; 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 } // namespace app

View File

@@ -7,6 +7,7 @@
class Part; class Part;
class Signal; class Signal;
class System;
// Application layer: UI-independent part-editing operations any frontend can // Application layer: UI-independent part-editing operations any frontend can
// call. No console, no dialogs, no FTXUI — Part in, result struct out. // 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. // Fails (ok=false, error set, no mutation) on an unrecognised name.
SetSignalTypeResult set_signal_type(Signal *sig, const std::string &type_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 } // namespace app
#endif // _APP_EDIT_HPP_ #endif // _APP_EDIT_HPP_

View File

@@ -248,6 +248,15 @@ private:
+ " ports bound" + (r.unbound ? (", " + std::to_string(r.unbound) + " unbound") : "")); + " ports bound" + (r.unbound ? (", " + std::to_string(r.unbound) + " unbound") : ""));
return true; 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") { if (cmd == "verify") {
render_verify(); render_verify();
return true; return true;

View File

@@ -595,51 +595,12 @@ void Tui::RegisterCommands() {
{"new module name", Completion::None}}, {"new module name", Completion::None}},
[this](const std::vector<std::string> &args) { [this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; } if (!sys) { Print("no system: run 'new' first."); return; }
app::DuplicateResult r =
Module *src; app::duplicate_module(sys.get(), args[0], args[1]);
try { src = sys->modules()->get(args[0]); } if (!r.ok) { Print(r.error); return; }
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);
Print("duplicate: '" + args[0] + "' → '" + args[1] + "'" Print("duplicate: '" + args[0] + "' → '" + args[1] + "'"
+ " (" + std::to_string(dst->size()) + " part(s), " + " (" + std::to_string(r.parts) + " part(s), "
+ std::to_string(dst->signals->size()) + " signal(s))"); + std::to_string(r.signals) + " signal(s))");
}, },
/*prompt_for_missing=*/ true, /*prompt_for_missing=*/ true,
"clone a module under a new name (parts, pins, signals; no connections)", "clone a module under a new name (parts, pins, signals; no connections)",

View File

@@ -42,6 +42,7 @@ enum {
ID_ATTACH_BSDL, ID_ATTACH_BSDL,
ID_SET_SIGNAL_TYPE, ID_SET_SIGNAL_TYPE,
ID_CONNECT, ID_CONNECT,
ID_DUPLICATE,
ID_VERIFY, ID_VERIFY,
ID_QUIT, ID_QUIT,
ID_ABOUT, ID_ABOUT,
@@ -132,6 +133,8 @@ EssimFrame::EssimFrame(WxFrontend &fe)
edit->Append(ID_SET_SIGNAL_TYPE, "Set &signal type…\tCtrl-G"); edit->Append(ID_SET_SIGNAL_TYPE, "Set &signal type…\tCtrl-G");
edit->AppendSeparator(); edit->AppendSeparator();
edit->Append(ID_CONNECT, "C&onnect parts…\tCtrl-O"); edit->Append(ID_CONNECT, "C&onnect parts…\tCtrl-O");
edit->AppendSeparator();
edit->Append(ID_DUPLICATE, "&Duplicate module…\tCtrl-D");
auto *sysm = new wxMenu; auto *sysm = new wxMenu;
sysm->Append(ID_VERIFY, "&Verify\tCtrl-K"); 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::OnAttachBsdl, this, ID_ATTACH_BSDL);
Bind(wxEVT_MENU, &EssimFrame::OnSetSignalType, this, ID_SET_SIGNAL_TYPE); Bind(wxEVT_MENU, &EssimFrame::OnSetSignalType, this, ID_SET_SIGNAL_TYPE);
Bind(wxEVT_MENU, &EssimFrame::OnConnect, this, ID_CONNECT); 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::OnVerify, this, ID_VERIFY);
Bind(wxEVT_MENU, &EssimFrame::OnQuit, this, ID_QUIT); Bind(wxEVT_MENU, &EssimFrame::OnQuit, this, ID_QUIT);
Bind(wxEVT_MENU, &EssimFrame::OnAbout, this, ID_ABOUT); Bind(wxEVT_MENU, &EssimFrame::OnAbout, this, ID_ABOUT);
@@ -594,6 +598,29 @@ void EssimFrame::OnSetSignalType(wxCommandEvent &) {
RebuildModelView(); 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 &) { void EssimFrame::OnVerify(wxCommandEvent &) {
app::VerifyReport r = app::verify(fe_.system()); app::VerifyReport r = app::verify(fe_.system());

View File

@@ -28,6 +28,7 @@ private:
void OnAttachBsdl(wxCommandEvent &); void OnAttachBsdl(wxCommandEvent &);
void OnConnect(wxCommandEvent &); void OnConnect(wxCommandEvent &);
void OnSetSignalType(wxCommandEvent &); void OnSetSignalType(wxCommandEvent &);
void OnDuplicateModule(wxCommandEvent &);
void OnVerify(wxCommandEvent &); void OnVerify(wxCommandEvent &);
void OnQuit(wxCommandEvent &); void OnQuit(wxCommandEvent &);
void OnAbout(wxCommandEvent &); void OnAbout(wxCommandEvent &);

View File

@@ -1,10 +1,12 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include "core/app/edit.hpp" #include "core/app/edit.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp" #include "core/domain/parts.hpp"
#include "core/domain/pins.hpp" #include "core/domain/pins.hpp"
#include "core/domain/signal_type.hpp" #include "core/domain/signal_type.hpp"
#include "core/domain/signals.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 // app::set_connector_type is pure core: validate the kind, tag the part and
// apply the connector model. No Print/dialog/FTXUI. // 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(r.error.find("power, gnd, other") != std::string::npos);
CHECK(s.type == SignalType::Other); // unchanged 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);
}