core/ui: extract export into src/app (frontend-agnostic), thin TUI command

First step of separating business logic from the TUI. The export command built
the CSV/ODS file inside its lambda, mixed with Print/ShowError/dialog calls.
Move all of it — CSV + ODS building, sheet-name sanitising, file writing — into
src/app/export.{hpp,cpp} (namespace app, no FTXUI/console dependency):
export_connections(const System*, path, format) -> ExportResult. The TUI
command is now a thin wrapper (resolve args/dialog, call the core, render). The
core is unit-tested without any UI (test_export); 342 assertions pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:23:41 +02:00
parent ac2edd90c4
commit 3010bb25eb
4 changed files with 327 additions and 192 deletions

76
tests/test_export.cpp Normal file
View File

@@ -0,0 +1,76 @@
#include <doctest/doctest.h>
#include "app/export.hpp"
#include "system/connect.hpp"
#include "system/modules.hpp"
#include "system/parts.hpp"
#include "system/pins.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
namespace {
std::string slurp(const std::string &path)
{
std::ifstream f(path);
std::stringstream ss;
ss << f.rdbuf();
return ss.str();
}
} // namespace
TEST_CASE("export_format_from_path maps extensions") {
app::ExportFormat f;
CHECK(app::export_format_from_path("a.csv", f));
CHECK(f == app::ExportFormat::Csv);
CHECK(app::export_format_from_path("a.ODS", f));
CHECK(f == app::ExportFormat::Ods);
CHECK_FALSE(app::export_format_from_path("a.txt", f));
CHECK_FALSE(app::export_format_from_path("noext", f));
}
TEST_CASE("export_connections writes a flat CSV (no UI needed)") {
// Two cards, one wired pin pair via a connection.
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *ja = new Part("J1"); a->add(ja);
Part *jb = new Part("P1"); b->add(jb);
Pin *pa = new Pin("1"); ja->add(pa);
Pin *pb = new Pin("1"); jb->add(pb);
Signal *sa = a->signals->merge("NETA"); sa->add(pa); pa->connect(sa);
Signal *sb = b->signals->merge("NETB"); sb->add(pb); pb->connect(sb);
Connection *c = new Connection("A.J1<->B.P1", a, ja, b, jb);
c->transform_name = "identity";
c->pin_map.emplace_back(pa, pb);
sys.connections()->add(c);
const char *path = "test_export_out.csv";
app::ExportResult r = app::export_connections(&sys, path, app::ExportFormat::Csv);
CHECK(r.ok);
CHECK(r.rows == 1);
std::string out = slurp(path);
CHECK(out.find("connection,transform,") == 0); // header present
CHECK(out.find("A.J1<->B.P1") != std::string::npos); // connection name
CHECK(out.find("identity") != std::string::npos); // transform
CHECK(out.find("NETA") != std::string::npos); // left signal
CHECK(out.find("NETB") != std::string::npos); // right signal
std::remove(path);
}
TEST_CASE("export_connections reports a bad path instead of crashing") {
System sys;
app::ExportResult r = app::export_connections(
&sys, "/nonexistent-dir-xyz/out.csv", app::ExportFormat::Csv);
CHECK_FALSE(r.ok);
CHECK_FALSE(r.error.empty());
}