Extract connect into core (app::connect_parts); thin the command + screen.

Move the wiring orchestration — transform lookup, identity-compatibility
check, identity NC fill, transform apply, Connection creation/add — out of
the `connect` command and the interactive connect screen into
core/app/connect.{hpp,cpp}: app::connect_parts(System*, m1,p1, m2,p2) returns
a ConnectResult (ok/refused/error, connection_name, transform_name, wires,
identity_info, nc_added) with no Print/dialog/FTXUI. Name/pattern resolution
and ambiguity reporting stay in the command — that is arg-parsing, not the op.

Both frontends now call the one core op, removing the duplicated logic. This
also unifies a divergence: the interactive screen previously called
CheckIdentityCompatible without the info pointer and so never filled identity
NC pins (unlike the command); routing it through app::connect_parts makes the
screen fill NCs and surface the same warning, matching the scriptable path.

Command output is unchanged. Prune the now-dead transform.hpp / domain
connect.hpp includes from the frontends (commands.cpp keeps transform_vpx.hpp
only for ValidatePartForKind).

Add tests/test_connect.cpp (core, no UI): identity-compatible pair wires,
unknown type pairing is refused with nothing created, subset side gets NC
pins filled and the warning reported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 20:12:11 +02:00
parent 25939998ab
commit a040cc1957
5 changed files with 223 additions and 73 deletions

67
src/core/app/connect.cpp Normal file
View File

@@ -0,0 +1,67 @@
#include "core/app/connect.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform.hpp"
#include <exception>
#include <utility>
namespace app {
ConnectResult connect_parts(System *sys, Module *m1, Part *p1,
Module *m2, Part *p2)
{
ConnectResult r;
auto &reg = TransformRegistry::get();
Transform *t = reg.lookup(p1->connector_type, p2->connector_type);
bool both_empty = p1->connector_type.empty() && p2->connector_type.empty();
if (t == reg.identity()) {
if (!both_empty) {
r.refused = true;
r.error = "no transform for types '"
+ (p1->connector_type.empty() ? std::string("(none)")
: p1->connector_type)
+ "' ↔ '"
+ (p2->connector_type.empty() ? std::string("(none)")
: p2->connector_type)
+ "'. Set matching types via 'set-connector-type' first.";
return r;
}
std::string info;
std::string err = CheckIdentityCompatible(p1, p2, &info);
if (!err.empty()) {
r.refused = true;
r.error = err;
return r;
}
if (!info.empty()) {
r.identity_info = info;
r.nc_added = FillIdentityNCs(p1, p2);
}
}
auto pin_map = t->apply(p1, p2);
r.connection_name = m1->name + "/" + p1->name
+ " <-> " + m2->name + "/" + p2->name;
r.transform_name = t->name;
try {
Connection *c = new Connection(r.connection_name, m1, p1, m2, p2);
c->transform_name = t->name;
c->pin_map = std::move(pin_map);
sys->connections()->add(c);
r.wires = (int)c->pin_map.size();
r.ok = true;
} catch (const std::exception &e) {
r.error = e.what();
}
return r;
}
} // namespace app

42
src/core/app/connect.hpp Normal file
View File

@@ -0,0 +1,42 @@
#ifndef _APP_CONNECT_HPP_
#define _APP_CONNECT_HPP_
#include <string>
class System;
class Module;
class Part;
// Application layer: UI-independent operations that any frontend (TUI, GUI, …)
// can call. No console, no dialogs, no FTXUI — just System in, result out.
namespace app {
// Outcome of connecting two parts. The side effects (filling identity NC pins,
// creating the Connection and adding it to the system) all happen in core; the
// caller only renders the fields.
struct ConnectResult {
bool ok = false; ///< a Connection was created and added
bool refused = false; ///< a business rule rejected it (vs. an exception)
std::string error; ///< why refused/failed; empty when ok
std::string connection_name;
std::string transform_name;
int wires = 0; ///< pin_map size of the created connection
// Identity-transform path only: the compatibility info line and how many NC
// pins were materialised so both sides match. Empty / 0 otherwise.
std::string identity_info;
int nc_added = 0;
};
// Wire part `p1` (in module `m1`) to part `p2` (in module `m2`): look up the
// transform for their connector types, refuse on an unknown pairing or an
// identity-incompatible layout, fill identity NC pins when needed, apply the
// transform and create the Connection. Pure core — no resolution of names or
// patterns (the frontend turns user input into the Module*/Part* it passes).
ConnectResult connect_parts(System *sys, Module *m1, Part *p1,
Module *m2, Part *p2);
} // namespace app
#endif // _APP_CONNECT_HPP_

View File

@@ -13,9 +13,9 @@
#include "core/domain/signals.hpp" #include "core/domain/signals.hpp"
#include "core/domain/system.hpp" #include "core/domain/system.hpp"
#include "core/app/connect.hpp"
#include "core/app/verify.hpp" #include "core/app/verify.hpp"
#include "core/domain/transform.hpp" #include "core/domain/transform_vpx.hpp" // ValidatePartForKind
#include "core/domain/transform_vpx.hpp"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -560,47 +560,23 @@ void Tui::RegisterCommands() {
auto [p2, p2_alts] = resolve_part(m2, args[3]); auto [p2, p2_alts] = resolve_part(m2, args[3]);
if (!p2) { report_ambiguous("part in " + m2->name, args[3], p2_alts); return; } if (!p2) { report_ambiguous("part in " + m2->name, args[3], p2_alts); return; }
auto &reg = TransformRegistry::get(); // Resolution above is arg-parsing (user text → objects); the wiring
Transform *t = reg.lookup(p1->connector_type, p2->connector_type); // itself — transform lookup, identity NC fill, Connection creation —
bool both_empty = p1->connector_type.empty() && p2->connector_type.empty(); // is app::connect_parts.
if (t == reg.identity()) { app::ConnectResult cr = app::connect_parts(sys.get(), m1, p1, m2, p2);
if (!both_empty) { if (cr.refused) { Print("connect refused: " + cr.error); return; }
Print("connect refused: no transform for types '" if (!cr.identity_info.empty()) {
+ (p1->connector_type.empty() ? "(none)" : p1->connector_type) Print("connect: " + cr.identity_info);
+ "' ↔ '" if (cr.nc_added > 0)
+ (p2->connector_type.empty() ? "(none)" : p2->connector_type) Print("connect: added " + std::to_string(cr.nc_added)
+ "'. Set matching types via 'set-connector-type' first.");
return;
}
std::string info;
std::string err = CheckIdentityCompatible(p1, p2, &info);
if (!err.empty()) {
Print("connect refused: " + err);
return;
}
if (!info.empty()) {
int added = FillIdentityNCs(p1, p2);
Print("connect: " + info);
if (added > 0)
Print("connect: added " + std::to_string(added)
+ " NC pin(s) so both sides match"); + " NC pin(s) so both sides match");
} }
} if (cr.ok)
auto pin_map = t->apply(p1, p2); Print("connected: " + cr.connection_name
+ " via " + cr.transform_name
std::string conn_name = m1->name + "/" + p1->name + " (" + std::to_string(cr.wires) + " wires)");
+ " <-> " + m2->name + "/" + p2->name; else
try { Print(std::string("connect failed: ") + cr.error);
Connection *c = new Connection(conn_name, m1, p1, m2, p2);
c->transform_name = t->name;
c->pin_map = std::move(pin_map);
sys->connections()->add(c);
Print("connected: " + conn_name
+ " via " + t->name
+ " (" + std::to_string(c->pin_map.size()) + " wires)");
} catch (const std::exception &e) {
Print(std::string("connect failed: ") + e.what());
}
}, },
/*prompt_for_missing=*/ false, /*prompt_for_missing=*/ false,
"connect a part across two modules (interactive screen if no args)", "connect a part across two modules (interactive screen if no args)",

View File

@@ -1,11 +1,10 @@
#include "frontends/tui/tui.hpp" #include "frontends/tui/tui.hpp"
#include "frontends/tui/tui_helpers.hpp" #include "frontends/tui/tui_helpers.hpp"
#include "core/domain/connect.hpp" #include "core/app/connect.hpp"
#include "core/domain/modules.hpp" #include "core/domain/modules.hpp"
#include "core/domain/parts.hpp" #include "core/domain/parts.hpp"
#include "core/domain/system.hpp" #include "core/domain/system.hpp"
#include "core/domain/transform.hpp"
#include <ftxui/component/component.hpp> #include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp> #include <ftxui/component/component_options.hpp>
@@ -67,37 +66,24 @@ Component Tui::BuildConnectScreen() {
Part *p1 = m1->get(connect_p1_list[connect_p1_idx]); Part *p1 = m1->get(connect_p1_list[connect_p1_idx]);
Part *p2 = m2->get(connect_p2_list[connect_p2_idx]); Part *p2 = m2->get(connect_p2_list[connect_p2_idx]);
auto &reg = TransformRegistry::get(); // Same wiring op as the `connect` command — see app::connect_parts.
Transform *t = reg.lookup(p1->connector_type, p2->connector_type); app::ConnectResult cr = app::connect_parts(sys.get(), m1, p1, m2, p2);
bool both_empty = p1->connector_type.empty() && p2->connector_type.empty(); if (cr.refused) {
if (t == reg.identity()) { Print("connect refused: " + cr.error);
if (!both_empty) { } else {
Print("connect refused: no transform for types '" if (!cr.identity_info.empty()) {
+ (p1->connector_type.empty() ? "(none)" : p1->connector_type) Print("connect: " + cr.identity_info);
+ "' ↔ '" if (cr.nc_added > 0)
+ (p2->connector_type.empty() ? "(none)" : p2->connector_type) Print("connect: added " + std::to_string(cr.nc_added)
+ "'. Set matching types via 'set-connector-type' first."); + " NC pin(s) so both sides match");
screen_idx = 0;
return;
} }
std::string err = CheckIdentityCompatible(p1, p2); if (cr.ok)
if (!err.empty()) { Print("connected: " + cr.connection_name
Print("connect refused: " + err); + " via " + cr.transform_name
screen_idx = 0; + " (" + std::to_string(cr.wires) + " wires)");
return; else
Print(std::string("connect failed: ") + cr.error);
} }
}
auto pin_map = t->apply(p1, p2);
std::string conn_name = m1->name + "/" + p1->name
+ " <-> " + m2->name + "/" + p2->name;
Connection *c = new Connection(conn_name, m1, p1, m2, p2);
c->transform_name = t->name;
c->pin_map = std::move(pin_map);
sys->connections()->add(c);
Print("connected: " + conn_name
+ " via " + t->name
+ " (" + std::to_string(c->pin_map.size()) + " wires)");
} catch (const std::exception &e) { } catch (const std::exception &e) {
Print(std::string("connect failed: ") + e.what()); Print(std::string("connect failed: ") + e.what());
} }

79
tests/test_connect.cpp Normal file
View File

@@ -0,0 +1,79 @@
#include <doctest/doctest.h>
#include "core/app/connect.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/system.hpp"
// app::connect_parts is pure core: given two already-resolved parts it looks up
// the transform, fills identity NC pins, creates the Connection and returns a
// ConnectResult. No Print/dialog/FTXUI. These tests drive it directly.
namespace {
// A part with the given pin names, attached to a fresh module.
Part *make_part(System &sys, const std::string &mod, const std::string &part,
std::initializer_list<const char *> pins,
const std::string &type = "")
{
Module *m = sys.modules()->merge(mod);
Part *p = new Part(part);
p->connector_type = type;
m->add(p);
for (const char *pn : pins) p->add(new Pin(pn));
return p;
}
} // namespace
TEST_CASE("connect_parts wires an identity-compatible pair") {
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *p1 = make_part(sys, "A", "J1", {"1", "2"});
Part *p2 = make_part(sys, "B", "P1", {"1", "2"});
app::ConnectResult r = app::connect_parts(&sys, a, p1, b, p2);
CHECK(r.ok);
CHECK_FALSE(r.refused);
CHECK(r.transform_name == "identity");
CHECK(r.wires == 2);
CHECK(r.identity_info.empty()); // identical sets → no NC fill, no warning
CHECK(r.nc_added == 0);
CHECK(r.connection_name == "A/J1 <-> B/P1");
CHECK(sys.connections()->size() == 1);
}
TEST_CASE("connect_parts refuses an unknown connector-type pairing") {
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *p1 = make_part(sys, "A", "J1", {"1"}, "foo");
Part *p2 = make_part(sys, "B", "P1", {"1"}, "bar");
app::ConnectResult r = app::connect_parts(&sys, a, p1, b, p2);
CHECK_FALSE(r.ok);
CHECK(r.refused);
CHECK(r.error.find("no transform") != std::string::npos);
CHECK(sys.connections()->size() == 0); // nothing created
}
TEST_CASE("connect_parts fills NC pins on the subset side and reports it") {
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *p1 = make_part(sys, "A", "J1", {"1", "2", "3"}); // larger side
Part *p2 = make_part(sys, "B", "P1", {"1", "2"}); // missing "3"
app::ConnectResult r = app::connect_parts(&sys, a, p1, b, p2);
CHECK(r.ok);
CHECK_FALSE(r.identity_info.empty()); // subset path surfaces a warning
CHECK(r.nc_added == 1); // pin "3" materialised on B
CHECK(r.wires == 3); // all three now wired
CHECK(p2->size() == 3); // the NC pin really got added
}