chore: auto-commit (286 archivos)
- .claude/agents/fn-orquestador/SKILL.md - .claude/commands/fn_claude.md - .claude/rules/INDEX.md - .claude/rules/cpp_apps.md - .claude/rules/ids_naming.md - CHANGELOG.md - apps/dag_engine/README.md - apps/dag_engine/api.go - apps/dag_engine/dags_migrated/example.yaml - apps/dag_engine/dags_migrated/example_lineage_tracking.yaml - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Tests para auto_detect_type (cpp/functions/core/auto_detect_type).
|
||||
// Pura: sin ImGui context, sin I/O.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/auto_detect_type.h"
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers de test
|
||||
// ---------------------------------------------------------------------------
|
||||
// Construye una matriz row-major de 1 columna con los strings dados.
|
||||
// cells[r * 1 + 0] = values[r]
|
||||
static std::vector<const char*> make_col(const std::vector<const char*>& values) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parse_number
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parse_number: numeros validos") {
|
||||
double v = 0;
|
||||
REQUIRE(parse_number("3.14", v));
|
||||
REQUIRE(std::abs(v - 3.14) < 1e-9);
|
||||
|
||||
REQUIRE(parse_number("-42", v));
|
||||
REQUIRE(v == -42.0);
|
||||
|
||||
REQUIRE(parse_number("0", v));
|
||||
REQUIRE(v == 0.0);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_number: vacios y no-numericos") {
|
||||
double v = 0;
|
||||
REQUIRE_FALSE(parse_number("", v));
|
||||
REQUIRE_FALSE(parse_number(nullptr, v));
|
||||
REQUIRE_FALSE(parse_number("abc", v));
|
||||
REQUIRE_FALSE(parse_number("1abc", v));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// is_bool_text
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("is_bool_text detecta true y false") {
|
||||
REQUIRE(is_bool_text("true"));
|
||||
REQUIRE(is_bool_text("false"));
|
||||
REQUIRE_FALSE(is_bool_text("True"));
|
||||
REQUIRE_FALSE(is_bool_text("1"));
|
||||
REQUIRE_FALSE(is_bool_text(""));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// is_date_iso
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("is_date_iso detecta formato YYYY-MM-DD") {
|
||||
REQUIRE(is_date_iso("2024-01-15"));
|
||||
REQUIRE(is_date_iso("2024-01-15T12:00:00")); // >= 10 chars y cumple patron
|
||||
REQUIRE_FALSE(is_date_iso("24-01-15"));
|
||||
REQUIRE_FALSE(is_date_iso("2024/01/15"));
|
||||
REQUIRE_FALSE(is_date_iso("abc"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: columna numerica entera
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: columna numerica entera") {
|
||||
std::vector<const char*> data = {"1", "2", "3", "100"};
|
||||
ColumnType t = auto_detect_type(data.data(), 4, 1, 0);
|
||||
REQUIRE(t == ColumnType::Int);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: columna de texto libre
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: columna de texto libre") {
|
||||
std::vector<const char*> data = {"Alice", "Bob", "Carol"};
|
||||
ColumnType t = auto_detect_type(data.data(), 3, 1, 0);
|
||||
REQUIRE(t == ColumnType::String);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: columna de fechas ISO
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: columna de fechas ISO") {
|
||||
std::vector<const char*> data = {"2024-01-01", "2024-06-15", "2023-12-31"};
|
||||
ColumnType t = auto_detect_type(data.data(), 3, 1, 0);
|
||||
REQUIRE(t == ColumnType::Date);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: columna booleana
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: columna booleana") {
|
||||
std::vector<const char*> data = {"true", "false", "true"};
|
||||
ColumnType t = auto_detect_type(data.data(), 3, 1, 0);
|
||||
REQUIRE(t == ColumnType::Bool);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: columna float
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: columna float") {
|
||||
std::vector<const char*> data = {"1.5", "2.7", "3.14"};
|
||||
ColumnType t = auto_detect_type(data.data(), 3, 1, 0);
|
||||
REQUIRE(t == ColumnType::Float);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: mezcla int + texto -> String
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: mezcla numerica y texto retorna String") {
|
||||
std::vector<const char*> data = {"1", "2", "three", "4"};
|
||||
ColumnType t = auto_detect_type(data.data(), 4, 1, 0);
|
||||
REQUIRE(t == ColumnType::String);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: columna vacia (todo null / "") retorna String
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: columna vacia retorna String") {
|
||||
std::vector<const char*> data = {"", nullptr, ""};
|
||||
ColumnType t = auto_detect_type(data.data(), 3, 1, 0);
|
||||
REQUIRE(t == ColumnType::String);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto_detect_type: sample_n limita el escaneo
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("auto_detect_type: sample_n=2 evalua solo las primeras 2 celdas no-vacias") {
|
||||
// Primeras 2 = enteros; el resto texto — con sample_n=2 deberia ver solo Int
|
||||
std::vector<const char*> data = {"10", "20", "texto", "mas_texto"};
|
||||
ColumnType t = auto_detect_type(data.data(), 4, 1, 0, 2);
|
||||
REQUIRE(t == ColumnType::Int);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Tests para compute_column_stats (cpp/functions/core/compute_column_stats).
|
||||
// Pura: sin ImGui context, sin I/O.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/compute_column_stats.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
static double absrel(double got, double expected) {
|
||||
if (expected == 0.0) return std::abs(got);
|
||||
return std::abs(got - expected) / std::abs(expected);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stats sobre vector numerico conocido
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: media correcta sobre vector numerico") {
|
||||
// 1,2,3,4,5 -> mean = 3.0
|
||||
std::vector<const char*> data = {"1", "2", "3", "4", "5"};
|
||||
ColStats s = compute_column_stats(data.data(), 5, 1, 0);
|
||||
REQUIRE(s.total == 5);
|
||||
REQUIRE(s.empty_count == 0);
|
||||
REQUIRE(s.numeric_count == 5);
|
||||
REQUIRE(s.numeric == true);
|
||||
REQUIRE(absrel(s.mean, 3.0) < 1e-9);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// p50 = mediana
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: p50 es mediana") {
|
||||
// [1,2,3,4,5] -> p50=3
|
||||
std::vector<const char*> data = {"1", "2", "3", "4", "5"};
|
||||
ColStats s = compute_column_stats(data.data(), 5, 1, 0);
|
||||
REQUIRE(absrel(s.p50, 3.0) < 1e-9);
|
||||
}
|
||||
|
||||
TEST_CASE("compute_column_stats: p25 y p75 correctos") {
|
||||
// [1,2,3,4] -> p25=1.75, p50=2.5, p75=3.25 (interpolacion lineal)
|
||||
std::vector<const char*> data = {"1", "2", "3", "4"};
|
||||
ColStats s = compute_column_stats(data.data(), 4, 1, 0);
|
||||
REQUIRE(absrel(s.p25, 1.75) < 1e-9);
|
||||
REQUIRE(absrel(s.p50, 2.5) < 1e-9);
|
||||
REQUIRE(absrel(s.p75, 3.25) < 1e-9);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// missing count
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: conteo de vacios correcto") {
|
||||
// 3 valores, 2 vacios
|
||||
std::vector<const char*> data = {"", "5", nullptr, "10", ""};
|
||||
ColStats s = compute_column_stats(data.data(), 5, 1, 0);
|
||||
REQUIRE(s.total == 5);
|
||||
REQUIRE(s.empty_count == 3);
|
||||
REQUIRE(s.numeric_count == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// columna de texto: no es numerica
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: columna texto no es numerica") {
|
||||
std::vector<const char*> data = {"Alice", "Bob", "Carol"};
|
||||
ColStats s = compute_column_stats(data.data(), 3, 1, 0);
|
||||
REQUIRE(s.numeric == false);
|
||||
REQUIRE(s.numeric_count == 0);
|
||||
REQUIRE(s.unique_count == 3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// unique_count
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: unique_count correcto") {
|
||||
std::vector<const char*> data = {"a", "b", "a", "c", "b", "a"};
|
||||
ColStats s = compute_column_stats(data.data(), 6, 1, 0);
|
||||
REQUIRE(s.unique_count == 3);
|
||||
REQUIRE(s.unique_capped == false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// top_categories: la mas frecuente es la primera
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: top_categories ordena por frecuencia desc") {
|
||||
// "x" aparece 4 veces, "y" 2, "z" 1
|
||||
std::vector<const char*> data = {"x", "x", "y", "x", "z", "y", "x"};
|
||||
ColStats s = compute_column_stats(data.data(), 7, 1, 0);
|
||||
REQUIRE(s.top_categories.size() >= 1);
|
||||
REQUIRE(s.top_categories[0].first == "x");
|
||||
REQUIRE(s.top_categories[0].second == 4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// indices: solo las filas indicadas
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: indices filtra filas correctamente") {
|
||||
// Columna: [10, 20, 30, 40, 50]
|
||||
// Solo filas 0, 2, 4 -> [10, 30, 50] -> mean=30
|
||||
std::vector<const char*> data = {"10", "20", "30", "40", "50"};
|
||||
int idx[] = {0, 2, 4};
|
||||
ColStats s = compute_column_stats(data.data(), 5, 1, 0, 100000, idx, 3);
|
||||
REQUIRE(s.total == 3);
|
||||
REQUIRE(s.numeric_count == 3);
|
||||
REQUIRE(absrel(s.mean, 30.0) < 1e-9);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// histograma: se genera para columnas numericas
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: histograma generado para numerica") {
|
||||
std::vector<const char*> data = {"1", "2", "3", "4", "5"};
|
||||
ColStats s = compute_column_stats(data.data(), 5, 1, 0);
|
||||
REQUIRE(s.hist.size() == (size_t)HIST_BINS);
|
||||
float total_hist = 0;
|
||||
for (float v : s.hist) total_hist += v;
|
||||
REQUIRE(total_hist == Catch::Approx(5.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// columna totalmente vacia
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: columna vacia retorna stats en cero") {
|
||||
std::vector<const char*> data = {"", nullptr, ""};
|
||||
ColStats s = compute_column_stats(data.data(), 3, 1, 0);
|
||||
REQUIRE(s.total == 3);
|
||||
REQUIRE(s.empty_count == 3);
|
||||
REQUIRE(s.numeric == false);
|
||||
REQUIRE(s.numeric_count == 0);
|
||||
REQUIRE(s.hist.empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// col fuera de rango devuelve stats por defecto
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_column_stats: col fuera de rango devuelve ColStats defecto") {
|
||||
std::vector<const char*> data = {"1", "2"};
|
||||
ColStats s = compute_column_stats(data.data(), 2, 1, 5);
|
||||
REQUIRE(s.total == 0);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Tests for data_table::compute_pipeline (cpp/functions/core/compute_pipeline.cpp).
|
||||
// Pure logic: no ImGui, no I/O. Exercises stage chaining.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/compute_pipeline.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
struct Table {
|
||||
std::vector<std::string> backing;
|
||||
std::vector<const char*> cells;
|
||||
std::vector<std::string> headers;
|
||||
std::vector<ColumnType> types;
|
||||
int rows = 0, cols = 0;
|
||||
|
||||
void add_row(std::initializer_list<const char*> row) {
|
||||
for (const char* s : row) backing.emplace_back(s ? s : "");
|
||||
++rows;
|
||||
}
|
||||
void finalize() {
|
||||
cols = headers.empty() ? 0 : (int)headers.size();
|
||||
cells.clear();
|
||||
cells.reserve(backing.size());
|
||||
for (const auto& s : backing) cells.push_back(s.c_str());
|
||||
}
|
||||
};
|
||||
|
||||
} // anon
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: empty stages returns passthrough
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_pipeline empty stages returns passthrough") {
|
||||
Table t;
|
||||
t.headers = {"x", "y"};
|
||||
t.types = {ColumnType::Int, ColumnType::Int};
|
||||
t.add_row({"1", "2"});
|
||||
t.add_row({"3", "4"});
|
||||
t.finalize();
|
||||
|
||||
StageOutput out = compute_pipeline(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, {});
|
||||
REQUIRE(out.rows == 2);
|
||||
REQUIRE(out.cols == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: single stage pipeline equals compute_stage directly
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_pipeline single stage equals compute_stage") {
|
||||
Table t;
|
||||
t.headers = {"dept", "amount"};
|
||||
t.types = {ColumnType::String, ColumnType::Float};
|
||||
t.add_row({"eng", "100"});
|
||||
t.add_row({"mktg", "200"});
|
||||
t.add_row({"eng", "150"});
|
||||
t.finalize();
|
||||
|
||||
Stage stage;
|
||||
Filter f; f.col = 0; f.op = Op::Eq; f.value = "eng";
|
||||
stage.filters.push_back(f);
|
||||
|
||||
StageOutput direct = compute_stage(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, stage);
|
||||
StageOutput via_pipe = compute_pipeline(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, {stage});
|
||||
REQUIRE(direct.rows == via_pipe.rows);
|
||||
REQUIRE(direct.cols == via_pipe.cols);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: two-stage chain — filter then group+sum
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_pipeline two stages chain filter then group") {
|
||||
Table t;
|
||||
t.headers = {"region", "type", "revenue"};
|
||||
t.types = {ColumnType::String, ColumnType::String, ColumnType::Float};
|
||||
t.add_row({"EU", "A", "100"});
|
||||
t.add_row({"US", "A", "200"});
|
||||
t.add_row({"EU", "B", "300"});
|
||||
t.add_row({"EU", "A", "50"});
|
||||
t.finalize();
|
||||
|
||||
// Stage 0: filter EU only.
|
||||
Stage s0;
|
||||
Filter f; f.col = 0; f.op = Op::Eq; f.value = "EU";
|
||||
s0.filters.push_back(f);
|
||||
|
||||
// Stage 1: group by type + sum revenue, sort desc.
|
||||
Stage s1;
|
||||
s1.breakouts = {"type"};
|
||||
Aggregation agg; agg.fn = AggFn::Sum; agg.col = "revenue";
|
||||
s1.aggregations.push_back(agg);
|
||||
SortClause sc; sc.col = "sum_revenue"; sc.desc = true;
|
||||
s1.sorts.push_back(sc);
|
||||
|
||||
StageOutput out = compute_pipeline(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, {s0, s1});
|
||||
|
||||
// EU rows: A=100+50=150, B=300. Sorted desc -> B first.
|
||||
REQUIRE(out.rows == 2);
|
||||
REQUIRE(std::string(out.cells[0 * out.cols + 0]) == "B");
|
||||
REQUIRE(std::string(out.cells[1 * out.cols + 0]) == "A");
|
||||
double rev_b = std::stod(out.cells[0 * out.cols + 1]);
|
||||
double rev_a = std::stod(out.cells[1 * out.cols + 1]);
|
||||
REQUIRE(rev_b == 300.0);
|
||||
REQUIRE(rev_a == 150.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: three-stage chain — group, then filter on aggregated column, then sort
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_pipeline three stage chain") {
|
||||
Table t;
|
||||
t.headers = {"cat", "val"};
|
||||
t.types = {ColumnType::String, ColumnType::Int};
|
||||
t.add_row({"A", "10"});
|
||||
t.add_row({"A", "20"});
|
||||
t.add_row({"B", "5"});
|
||||
t.add_row({"C", "100"});
|
||||
t.add_row({"C", "50"});
|
||||
t.finalize();
|
||||
|
||||
// Stage 0: group by cat, sum val.
|
||||
Stage s0;
|
||||
s0.breakouts = {"cat"};
|
||||
Aggregation agg; agg.fn = AggFn::Sum; agg.col = "val";
|
||||
s0.aggregations.push_back(agg);
|
||||
|
||||
// Stage 1: filter where sum_val > 10.
|
||||
Stage s1;
|
||||
Filter f; f.col = 1; f.op = Op::Gt; f.value = "10";
|
||||
s1.filters.push_back(f);
|
||||
|
||||
// Stage 2: sort asc by sum_val.
|
||||
Stage s2;
|
||||
SortClause sc; sc.col = "sum_val"; sc.desc = false;
|
||||
s2.sorts.push_back(sc);
|
||||
|
||||
StageOutput out = compute_pipeline(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, {s0, s1, s2});
|
||||
|
||||
// A=30, B=5 (filtered out), C=150. Sorted asc -> A(30), C(150).
|
||||
REQUIRE(out.rows == 2);
|
||||
REQUIRE(std::string(out.cells[0 * out.cols + 0]) == "A");
|
||||
REQUIRE(std::string(out.cells[1 * out.cols + 0]) == "C");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: pipeline with empty input table
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_pipeline empty table") {
|
||||
Stage s0;
|
||||
Filter f; f.col = 0; f.op = Op::Eq; f.value = "x";
|
||||
s0.filters.push_back(f);
|
||||
|
||||
StageOutput out = compute_pipeline(nullptr, 0, 0, {}, {}, {s0});
|
||||
REQUIRE(out.rows == 0);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Tests for data_table::compute_stage (cpp/functions/core/compute_stage.cpp).
|
||||
// Pure logic: no ImGui, no I/O. Exercises filter, group+sum, sort desc.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/compute_stage.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
// Build a flat row-major cell array from a vector-of-rows of cstrings.
|
||||
// Keeps string storage alive via `backing`.
|
||||
struct Table {
|
||||
std::vector<std::string> backing;
|
||||
std::vector<const char*> cells;
|
||||
std::vector<std::string> headers;
|
||||
std::vector<ColumnType> types;
|
||||
int rows = 0, cols = 0;
|
||||
|
||||
void add_row(std::initializer_list<const char*> row) {
|
||||
for (const char* s : row) {
|
||||
backing.emplace_back(s ? s : "");
|
||||
}
|
||||
++rows;
|
||||
}
|
||||
// Must call after all add_row calls.
|
||||
void finalize() {
|
||||
cols = headers.empty() ? 0 : (int)headers.size();
|
||||
cells.clear();
|
||||
cells.reserve(backing.size());
|
||||
for (const auto& s : backing) cells.push_back(s.c_str());
|
||||
}
|
||||
};
|
||||
|
||||
} // anon
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: simple passthrough with no filter/group/sort
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_stage passthrough returns all rows") {
|
||||
Table t;
|
||||
t.headers = {"name", "value"};
|
||||
t.types = {ColumnType::String, ColumnType::Float};
|
||||
t.add_row({"alice", "10.0"});
|
||||
t.add_row({"bob", "20.0"});
|
||||
t.add_row({"carol", "30.0"});
|
||||
t.finalize();
|
||||
|
||||
Stage stage; // empty stage = passthrough
|
||||
StageOutput out = compute_stage(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, stage);
|
||||
REQUIRE(out.rows == 3);
|
||||
REQUIRE(out.cols == 2);
|
||||
REQUIRE(out.headers[0] == "name");
|
||||
REQUIRE(out.headers[1] == "value");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: filter equals — simple filter keeps matching rows only
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_stage filter eq keeps matching rows") {
|
||||
Table t;
|
||||
t.headers = {"dept", "salary"};
|
||||
t.types = {ColumnType::String, ColumnType::Int};
|
||||
t.add_row({"eng", "90000"});
|
||||
t.add_row({"mktg", "70000"});
|
||||
t.add_row({"eng", "95000"});
|
||||
t.finalize();
|
||||
|
||||
Stage stage;
|
||||
Filter f; f.col = 0; f.op = Op::Eq; f.value = "eng";
|
||||
stage.filters.push_back(f);
|
||||
|
||||
StageOutput out = compute_stage(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, stage);
|
||||
REQUIRE(out.rows == 2);
|
||||
for (int r = 0; r < out.rows; ++r) {
|
||||
REQUIRE(std::string(out.cells[r * out.cols + 0]) == "eng");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: group + sum aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_stage group sum aggregates correctly") {
|
||||
Table t;
|
||||
t.headers = {"dept", "amount"};
|
||||
t.types = {ColumnType::String, ColumnType::Float};
|
||||
t.add_row({"eng", "100"});
|
||||
t.add_row({"mktg", "200"});
|
||||
t.add_row({"eng", "150"});
|
||||
t.add_row({"mktg", "50"});
|
||||
t.finalize();
|
||||
|
||||
Stage stage;
|
||||
stage.breakouts = {"dept"};
|
||||
Aggregation agg;
|
||||
agg.fn = AggFn::Sum;
|
||||
agg.col = "amount";
|
||||
stage.aggregations.push_back(agg);
|
||||
|
||||
StageOutput out = compute_stage(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, stage);
|
||||
REQUIRE(out.rows == 2);
|
||||
REQUIRE(out.cols == 2);
|
||||
REQUIRE(out.headers[0] == "dept");
|
||||
REQUIRE(out.headers[1] == "sum_amount");
|
||||
|
||||
// Find eng and mktg rows (order may vary without sort).
|
||||
double eng_total = -1.0, mktg_total = -1.0;
|
||||
for (int r = 0; r < out.rows; ++r) {
|
||||
std::string dept = out.cells[r * out.cols + 0];
|
||||
double val = std::stod(out.cells[r * out.cols + 1]);
|
||||
if (dept == "eng") eng_total = val;
|
||||
if (dept == "mktg") mktg_total = val;
|
||||
}
|
||||
REQUIRE(eng_total == 250.0);
|
||||
REQUIRE(mktg_total == 250.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: sort descending
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_stage sort desc reorders rows") {
|
||||
Table t;
|
||||
t.headers = {"score"};
|
||||
t.types = {ColumnType::Int};
|
||||
t.add_row({"10"});
|
||||
t.add_row({"50"});
|
||||
t.add_row({"30"});
|
||||
t.finalize();
|
||||
|
||||
Stage stage;
|
||||
SortClause sc; sc.col = "score"; sc.desc = true;
|
||||
stage.sorts.push_back(sc);
|
||||
|
||||
StageOutput out = compute_stage(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, stage);
|
||||
REQUIRE(out.rows == 3);
|
||||
REQUIRE(std::string(out.cells[0]) == "50");
|
||||
REQUIRE(std::string(out.cells[1]) == "30");
|
||||
REQUIRE(std::string(out.cells[2]) == "10");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: filter + group + sort combined
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_stage filter then group then sort asc") {
|
||||
Table t;
|
||||
t.headers = {"region", "type", "revenue"};
|
||||
t.types = {ColumnType::String, ColumnType::String, ColumnType::Float};
|
||||
t.add_row({"EU", "A", "100"});
|
||||
t.add_row({"US", "A", "200"});
|
||||
t.add_row({"EU", "B", "300"});
|
||||
t.add_row({"EU", "A", "50"});
|
||||
t.finalize();
|
||||
|
||||
Stage stage;
|
||||
// Keep only EU rows.
|
||||
Filter f; f.col = 0; f.op = Op::Eq; f.value = "EU";
|
||||
stage.filters.push_back(f);
|
||||
// Group by type.
|
||||
stage.breakouts = {"type"};
|
||||
Aggregation agg; agg.fn = AggFn::Sum; agg.col = "revenue";
|
||||
stage.aggregations.push_back(agg);
|
||||
// Sort by sum_revenue asc.
|
||||
SortClause sc; sc.col = "sum_revenue"; sc.desc = false;
|
||||
stage.sorts.push_back(sc);
|
||||
|
||||
StageOutput out = compute_stage(t.cells.data(), t.rows, t.cols,
|
||||
t.headers, t.types, stage);
|
||||
REQUIRE(out.rows == 2);
|
||||
// type A: 100+50=150; type B: 300. Sorted asc -> A first.
|
||||
REQUIRE(std::string(out.cells[0 * out.cols + 0]) == "A");
|
||||
REQUIRE(std::string(out.cells[1 * out.cols + 0]) == "B");
|
||||
double rev_a = std::stod(out.cells[0 * out.cols + 1]);
|
||||
double rev_b = std::stod(out.cells[1 * out.cols + 1]);
|
||||
REQUIRE(rev_a == 150.0);
|
||||
REQUIRE(rev_b == 300.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: apply_filters standalone
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("apply_filters returns correct row indices") {
|
||||
Table t;
|
||||
t.headers = {"val"};
|
||||
t.types = {ColumnType::Int};
|
||||
t.add_row({"5"});
|
||||
t.add_row({"10"});
|
||||
t.add_row({"15"});
|
||||
t.finalize();
|
||||
|
||||
Filter f; f.col = 0; f.op = Op::Gt; f.value = "7";
|
||||
auto idx = apply_filters(t.cells.data(), t.rows, t.cols, {f});
|
||||
REQUIRE(idx.size() == 2);
|
||||
REQUIRE(idx[0] == 1);
|
||||
REQUIRE(idx[1] == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: aggregation_alias default names
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("aggregation_alias produces expected names") {
|
||||
Aggregation count_agg;
|
||||
count_agg.fn = AggFn::Count;
|
||||
REQUIRE(aggregation_alias(count_agg) == "count");
|
||||
|
||||
Aggregation sum_agg;
|
||||
sum_agg.fn = AggFn::Sum; sum_agg.col = "revenue";
|
||||
REQUIRE(aggregation_alias(sum_agg) == "sum_revenue");
|
||||
|
||||
Aggregation pct_agg;
|
||||
pct_agg.fn = AggFn::Percentile; pct_agg.col = "latency"; pct_agg.arg = 0.95;
|
||||
REQUIRE(aggregation_alias(pct_agg) == "p95_latency");
|
||||
|
||||
Aggregation custom;
|
||||
custom.fn = AggFn::Sum; custom.col = "x"; custom.alias = "my_alias";
|
||||
REQUIRE(aggregation_alias(custom) == "my_alias");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: empty stage on empty table
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("compute_stage empty table empty stage") {
|
||||
Stage stage;
|
||||
StageOutput out = compute_stage(nullptr, 0, 0, {}, {}, stage);
|
||||
REQUIRE(out.rows == 0);
|
||||
REQUIRE(out.cols == 0);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Tests for data_table::join_tables (cpp/functions/core/join_tables).
|
||||
// Pure multi-key hash join — no ImGui, no I/O.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/join_tables.h"
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a TableInput from a flat list of string literals (row-major).
|
||||
// Ownership: the TableInput holds its own cells vector.
|
||||
struct TableData {
|
||||
std::vector<std::string> header_strs;
|
||||
std::vector<ColumnType> types;
|
||||
std::vector<const char*> cell_ptrs;
|
||||
std::vector<std::string> cell_strs;
|
||||
TableInput ti;
|
||||
|
||||
void build(const std::string& name,
|
||||
std::initializer_list<std::string> headers,
|
||||
std::initializer_list<std::string> cells_flat)
|
||||
{
|
||||
header_strs = std::vector<std::string>(headers);
|
||||
cell_strs = std::vector<std::string>(cells_flat);
|
||||
types.assign(header_strs.size(), ColumnType::Auto);
|
||||
cell_ptrs.clear();
|
||||
for (auto& s : cell_strs) cell_ptrs.push_back(s.c_str());
|
||||
ti.name = name;
|
||||
ti.headers = header_strs;
|
||||
ti.types = types;
|
||||
ti.cells = cell_ptrs.data();
|
||||
ti.cols = (int)header_strs.size();
|
||||
ti.rows = cell_ptrs.empty() ? 0 : (int)(cell_strs.size() / header_strs.size());
|
||||
}
|
||||
};
|
||||
|
||||
static Join make_join(JoinStrategy strat,
|
||||
std::initializer_list<std::pair<std::string,std::string>> on,
|
||||
const std::string& alias = "",
|
||||
std::initializer_list<std::string> fields = {})
|
||||
{
|
||||
Join j;
|
||||
j.strategy = strat;
|
||||
j.alias = alias;
|
||||
j.on = on;
|
||||
j.fields = fields;
|
||||
return j;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// inner join basico
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("inner join basico") {
|
||||
// left: id, name
|
||||
std::vector<std::string> lhdr = {"id", "name"};
|
||||
std::vector<ColumnType> ltyp = {ColumnType::Auto, ColumnType::Auto};
|
||||
std::vector<std::string> lraw = {"1","Alice", "2","Bob", "3","Carol"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
right.build("orders", {"user_id","amount"}, {"1","100", "2","200", "2","150"});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Inner, {{"id","user_id"}});
|
||||
StageOutput res = join_tables(lptr.data(), 3, 2, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
// Alice->100, Bob->200, Bob->150. Carol has no match.
|
||||
REQUIRE(res.rows == 3);
|
||||
REQUIRE(res.cols == 4);
|
||||
// Check headers: id, name, user_id, amount
|
||||
REQUIRE(res.headers[0] == "id");
|
||||
REQUIRE(res.headers[1] == "name");
|
||||
REQUIRE(res.headers[2] == "user_id");
|
||||
REQUIRE(res.headers[3] == "amount");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// left join con orphans
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("left join con orphans") {
|
||||
std::vector<std::string> lhdr = {"id", "name"};
|
||||
std::vector<ColumnType> ltyp = {ColumnType::Auto, ColumnType::Auto};
|
||||
std::vector<std::string> lraw = {"1","Alice", "2","Bob", "3","Carol"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
right.build("orders", {"user_id","amount"}, {"1","100"});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Left, {{"id","user_id"}});
|
||||
StageOutput res = join_tables(lptr.data(), 3, 2, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
// Alice matches, Bob and Carol are left orphans -> 3 rows total.
|
||||
REQUIRE(res.rows == 3);
|
||||
|
||||
// Verify the unmatched rows have empty right cells.
|
||||
// Row 0: Alice, amount=100
|
||||
REQUIRE(std::string(res.cells[0 * 4 + 3]) == "100");
|
||||
// Row 1: Bob, amount="" (orphan)
|
||||
REQUIRE(std::string(res.cells[1 * 4 + 3]) == "");
|
||||
// Row 2: Carol, amount="" (orphan)
|
||||
REQUIRE(std::string(res.cells[2 * 4 + 3]) == "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// right join con orphans
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("right join con orphans") {
|
||||
std::vector<std::string> lhdr = {"id", "name"};
|
||||
std::vector<ColumnType> ltyp = {ColumnType::Auto, ColumnType::Auto};
|
||||
// Only Alice present on left.
|
||||
std::vector<std::string> lraw = {"1","Alice"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
// user_id 1 and 2 on right; user_id 2 has no match on left.
|
||||
right.build("orders", {"user_id","amount"}, {"1","100", "2","200"});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Right, {{"id","user_id"}});
|
||||
StageOutput res = join_tables(lptr.data(), 1, 2, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
// Row 0: Alice+100 (matched), Row 1: ""+"2"+"200" (right orphan).
|
||||
REQUIRE(res.rows == 2);
|
||||
|
||||
// Row 0 has Alice's name.
|
||||
REQUIRE(std::string(res.cells[0 * 4 + 1]) == "Alice");
|
||||
// Row 1 left side is empty.
|
||||
REQUIRE(std::string(res.cells[1 * 4 + 0]) == "");
|
||||
REQUIRE(std::string(res.cells[1 * 4 + 1]) == "");
|
||||
// Row 1 right amount is 200.
|
||||
REQUIRE(std::string(res.cells[1 * 4 + 3]) == "200");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// full outer join
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("full outer join") {
|
||||
std::vector<std::string> lhdr = {"id","val"};
|
||||
std::vector<ColumnType> ltyp = {ColumnType::Auto, ColumnType::Auto};
|
||||
std::vector<std::string> lraw = {"A","1", "B","2"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
// B and C on right; A has no match on right.
|
||||
right.build("r", {"key","score"}, {"B","10", "C","30"});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Full, {{"id","key"}});
|
||||
StageOutput res = join_tables(lptr.data(), 2, 2, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
// A (left orphan), B (matched), C (right orphan) -> 3 rows.
|
||||
REQUIRE(res.rows == 3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// multi-key join (2 keys)
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("multi-key join con 2 keys") {
|
||||
// left: dept, year, budget
|
||||
std::vector<std::string> lhdr = {"dept","year","budget"};
|
||||
std::vector<ColumnType> ltyp(3, ColumnType::Auto);
|
||||
std::vector<std::string> lraw = {
|
||||
"eng","2024","100",
|
||||
"eng","2025","110",
|
||||
"hr", "2024","50"
|
||||
};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
// right: dept, year, headcount
|
||||
right.build("headcount", {"dept","year","headcount"}, {
|
||||
"eng","2024","20",
|
||||
"hr", "2024","5"
|
||||
// eng-2025 missing on right
|
||||
});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Inner, {{"dept","dept"},{"year","year"}});
|
||||
StageOutput res = join_tables(lptr.data(), 3, 3, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
// eng-2024 matches, hr-2024 matches, eng-2025 has no match -> 2 rows.
|
||||
REQUIRE(res.rows == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// key con duplicados (producto cartesiano por clave duplicada)
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("key con duplicados produce producto cartesiano") {
|
||||
// left: 1 row with id=X
|
||||
std::vector<std::string> lhdr = {"id","lval"};
|
||||
std::vector<ColumnType> ltyp(2, ColumnType::Auto);
|
||||
std::vector<std::string> lraw = {"X","L1"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
// right: 3 rows with key=X
|
||||
right.build("r", {"key","rval"}, {"X","R1", "X","R2", "X","R3"});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Inner, {{"id","key"}});
|
||||
StageOutput res = join_tables(lptr.data(), 1, 2, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
// 1 left row x 3 matching right rows = 3 output rows.
|
||||
REQUIRE(res.rows == 3);
|
||||
REQUIRE(std::string(res.cells[0 * 4 + 3]) == "R1");
|
||||
REQUIRE(std::string(res.cells[1 * 4 + 3]) == "R2");
|
||||
REQUIRE(std::string(res.cells[2 * 4 + 3]) == "R3");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// alias en columnas del derecho
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("alias prefija columnas del derecho") {
|
||||
std::vector<std::string> lhdr = {"id"};
|
||||
std::vector<ColumnType> ltyp = {ColumnType::Auto};
|
||||
std::vector<std::string> lraw = {"1"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
right.build("r", {"id","score"}, {"1","99"});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Inner, {{"id","id"}}, "r");
|
||||
StageOutput res = join_tables(lptr.data(), 1, 1, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
REQUIRE(res.cols == 3);
|
||||
REQUIRE(res.headers[1] == "r.id");
|
||||
REQUIRE(res.headers[2] == "r.score");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fields subset: solo algunas columnas del derecho
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("fields subset incluye solo columnas especificadas") {
|
||||
std::vector<std::string> lhdr = {"id"};
|
||||
std::vector<ColumnType> ltyp = {ColumnType::Auto};
|
||||
std::vector<std::string> lraw = {"1"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
right.build("r", {"id","score","notes"}, {"1","42","hello"});
|
||||
|
||||
Join jn = make_join(JoinStrategy::Inner, {{"id","id"}}, "", {"score"});
|
||||
StageOutput res = join_tables(lptr.data(), 1, 1, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
// Only left id + right score (not notes).
|
||||
REQUIRE(res.cols == 2);
|
||||
REQUIRE(res.headers[1] == "score");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tabla derecha vacia -> left join devuelve todas las filas de la izquierda
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("right table vacia con left join devuelve todas las filas izquierda") {
|
||||
std::vector<std::string> lhdr = {"id","val"};
|
||||
std::vector<ColumnType> ltyp(2, ColumnType::Auto);
|
||||
std::vector<std::string> lraw = {"1","a", "2","b", "3","c"};
|
||||
std::vector<const char*> lptr;
|
||||
for (auto& s : lraw) lptr.push_back(s.c_str());
|
||||
|
||||
TableData right;
|
||||
right.build("r", {"id","extra"}, {}); // 0 rows
|
||||
|
||||
Join jn = make_join(JoinStrategy::Left, {{"id","id"}});
|
||||
StageOutput res = join_tables(lptr.data(), 3, 2, lhdr, ltyp, right.ti, jn);
|
||||
|
||||
REQUIRE(res.rows == 3);
|
||||
// All right cells are empty.
|
||||
for (int r = 0; r < 3; ++r) {
|
||||
REQUIRE(std::string(res.cells[r * 4 + 2]) == "");
|
||||
REQUIRE(std::string(res.cells[r * 4 + 3]) == "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// test_llm_anthropic.cpp — Catch2 tests for llm_anthropic pure helpers.
|
||||
// Issue 0081. Does NOT make real HTTP calls. Uses FN_LLM_MOCK_RESPONSE for
|
||||
// call_api injection. Real roundtrip is a manual_test (requires API key).
|
||||
#include "catch_amalgamated.hpp"
|
||||
#include "core/llm_anthropic.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace llm_anthropic;
|
||||
using namespace data_table;
|
||||
|
||||
// ============================================================================
|
||||
// build_request_body (pure)
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("build_request_body: contains model field", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "show me total sales by region";
|
||||
in.col_names = {"region", "amount"};
|
||||
in.col_types = {ColumnType::String, ColumnType::Float};
|
||||
in.mode = OutputMode::TQL;
|
||||
|
||||
std::string body = build_request_body(in);
|
||||
REQUIRE(body.find("\"model\"") != std::string::npos);
|
||||
REQUIRE(body.find("claude") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("build_request_body: contains messages array", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "top 10 by revenue";
|
||||
std::string body = build_request_body(in);
|
||||
REQUIRE(body.find("\"messages\"") != std::string::npos);
|
||||
REQUIRE(body.find("\"user\"") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("build_request_body: question appears in user content", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "unique_question_marker_xyz";
|
||||
std::string body = build_request_body(in);
|
||||
// The question is JSON-escaped inside "content", so the marker must appear.
|
||||
REQUIRE(body.find("unique_question_marker_xyz") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("build_request_body: col_names appear in schema block", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "q";
|
||||
in.col_names = {"customer_id", "purchase_date", "total_amount"};
|
||||
in.col_types = {ColumnType::Int, ColumnType::Date, ColumnType::Float};
|
||||
std::string body = build_request_body(in);
|
||||
REQUIRE(body.find("customer_id") != std::string::npos);
|
||||
REQUIRE(body.find("purchase_date") != std::string::npos);
|
||||
REQUIRE(body.find("total_amount") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("build_request_body: custom model overrides default", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "q";
|
||||
in.model = "claude-opus-4-5";
|
||||
std::string body = build_request_body(in);
|
||||
REQUIRE(body.find("claude-opus-4-5") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("build_request_body: SQL mode produces SQL system prompt", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "q";
|
||||
in.mode = OutputMode::SQL;
|
||||
std::string body = build_request_body(in);
|
||||
REQUIRE(body.find("DuckDB") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("build_request_body: current TQL appears in body", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "q";
|
||||
in.tql_current = "return { version=1, stages={} }";
|
||||
std::string body = build_request_body(in);
|
||||
REQUIRE(body.find("version=1") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("build_request_body: joinable tables appear in schema", "[llm_anthropic]") {
|
||||
AskInput in;
|
||||
in.question = "q";
|
||||
in.joinable_names = {"products_table", "customers_table"};
|
||||
std::string body = build_request_body(in);
|
||||
REQUIRE(body.find("products_table") != std::string::npos);
|
||||
REQUIRE(body.find("customers_table") != std::string::npos);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// extract_code_block (pure)
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("extract_code_block: extracts lua fence", "[llm_anthropic]") {
|
||||
std::string raw = "Here is the TQL:\n```lua\nreturn { version=1 }\n```\nDone.";
|
||||
std::string code = extract_code_block(raw, "lua");
|
||||
REQUIRE(code == "return { version=1 }");
|
||||
}
|
||||
|
||||
TEST_CASE("extract_code_block: extracts sql fence", "[llm_anthropic]") {
|
||||
std::string raw = "```sql\nSELECT * FROM t;\n```";
|
||||
std::string code = extract_code_block(raw, "sql");
|
||||
REQUIRE(code == "SELECT * FROM t;");
|
||||
}
|
||||
|
||||
TEST_CASE("extract_code_block: fallback to plain fence without lang", "[llm_anthropic]") {
|
||||
std::string raw = "```\nSOME CODE\n```";
|
||||
std::string code = extract_code_block(raw, "lua");
|
||||
REQUIRE(code.find("SOME CODE") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("extract_code_block: no fence returns stripped raw", "[llm_anthropic]") {
|
||||
std::string raw = " return { version=1 } ";
|
||||
std::string code = extract_code_block(raw, "lua");
|
||||
REQUIRE(code == "return { version=1 }");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// parse_response_text (pure)
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("parse_response_text: extracts text from Anthropic response JSON", "[llm_anthropic]") {
|
||||
std::string json =
|
||||
R"({"id":"msg_01","content":[{"type":"text","text":"Hello world"}],"model":"claude-sonnet-4-6"})";
|
||||
std::string text = parse_response_text(json);
|
||||
REQUIRE(text == "Hello world");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_response_text: handles escaped newline in text", "[llm_anthropic]") {
|
||||
std::string json =
|
||||
R"({"content":[{"type":"text","text":"line1\nline2"}]})";
|
||||
std::string text = parse_response_text(json);
|
||||
REQUIRE(text.find("line1") != std::string::npos);
|
||||
REQUIRE(text.find("line2") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_response_text: empty text on missing field", "[llm_anthropic]") {
|
||||
std::string json = R"({"error":"no_key"})";
|
||||
std::string text = parse_response_text(json);
|
||||
REQUIRE(text.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// call_api: mock injection via FN_LLM_MOCK_RESPONSE
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("call_api: mock response injection via env var", "[llm_anthropic]") {
|
||||
// Set mock response so no real HTTP call is made.
|
||||
setenv("FN_LLM_MOCK_RESPONSE",
|
||||
R"({"content":[{"type":"text","text":"```lua\nreturn {}\n```"}]})",
|
||||
1);
|
||||
|
||||
std::string err;
|
||||
std::string resp = call_api("{}", "", err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(resp.find("content") != std::string::npos);
|
||||
|
||||
unsetenv("FN_LLM_MOCK_RESPONSE");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ask: end-to-end with mock (no real HTTP)
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("ask: mock roundtrip produces code block", "[llm_anthropic]") {
|
||||
setenv("FN_LLM_MOCK_RESPONSE",
|
||||
R"({"content":[{"type":"text","text":"```lua\nreturn { version=1 }\n```"}]})",
|
||||
1);
|
||||
|
||||
AskInput in;
|
||||
in.question = "show all rows";
|
||||
in.col_names = {"name", "value"};
|
||||
in.col_types = {ColumnType::String, ColumnType::Float};
|
||||
in.mode = OutputMode::TQL;
|
||||
|
||||
AskResult r = ask(in);
|
||||
REQUIRE(r.error.empty());
|
||||
REQUIRE(r.code.find("version=1") != std::string::npos);
|
||||
|
||||
unsetenv("FN_LLM_MOCK_RESPONSE");
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Tests para lua_engine_cpp_core.
|
||||
// Catch2 amalgamated. No requiere ImGui context.
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "core/lua_engine.h"
|
||||
|
||||
// Helper: compila + eval con RowCtx vacia (sin columnas).
|
||||
static std::string quick_eval(const std::string& formula, std::string* err = nullptr) {
|
||||
lua_engine::Engine* e = lua_engine::get();
|
||||
std::string compile_err;
|
||||
int id = lua_engine::compile(e, formula, &compile_err);
|
||||
if (id < 0) {
|
||||
if (err) *err = compile_err;
|
||||
return "";
|
||||
}
|
||||
lua_engine::RowCtx ctx;
|
||||
std::string eval_err;
|
||||
std::string result = lua_engine::eval(e, id, ctx, &eval_err);
|
||||
lua_engine::release(e, id);
|
||||
if (err) *err = eval_err;
|
||||
return result;
|
||||
}
|
||||
|
||||
TEST_CASE("lua_engine: eval expr simple", "[lua_engine]") {
|
||||
std::string err;
|
||||
std::string result = quick_eval("2 + 3", &err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(result == "5");
|
||||
}
|
||||
|
||||
TEST_CASE("lua_engine: eval con vars via RowCtx", "[lua_engine]") {
|
||||
// Prepara tabla: 1 fila, 2 columnas (price=10, qty=3).
|
||||
const char* price_str = "10";
|
||||
const char* qty_str = "3";
|
||||
const char* cells[] = { price_str, qty_str };
|
||||
|
||||
std::vector<std::string> headers = { "price", "qty" };
|
||||
std::unordered_map<std::string,int> name_to_col = { {"price", 0}, {"qty", 1} };
|
||||
|
||||
data_table::ColumnType types[] = {
|
||||
data_table::ColumnType::Float,
|
||||
data_table::ColumnType::Float
|
||||
};
|
||||
|
||||
lua_engine::RowCtx ctx;
|
||||
ctx.cells = cells;
|
||||
ctx.orig_cols = 2;
|
||||
ctx.row = 0;
|
||||
ctx.header_names = &headers;
|
||||
ctx.name_to_col = &name_to_col;
|
||||
ctx.types_orig = types;
|
||||
ctx.n_types_orig = 2;
|
||||
|
||||
lua_engine::Engine* e = lua_engine::get();
|
||||
std::string err;
|
||||
int id = lua_engine::compile(e, "[price] * [qty]", &err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(id >= 0);
|
||||
|
||||
std::string result = lua_engine::eval(e, id, ctx, &err);
|
||||
lua_engine::release(e, id);
|
||||
REQUIRE(err.empty());
|
||||
// 10 * 3 = 30 (integer)
|
||||
REQUIRE(result == "30");
|
||||
}
|
||||
|
||||
TEST_CASE("lua_engine: error en expr invalida", "[lua_engine]") {
|
||||
std::string err;
|
||||
int id = lua_engine::compile(lua_engine::get(), "this is not lua !!!", &err);
|
||||
REQUIRE(id < 0);
|
||||
REQUIRE(!err.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("lua_engine: sandbox bloquea io.open", "[lua_engine]") {
|
||||
std::string err;
|
||||
// io fue eliminado del sandbox; acceder a io.open debe producir error en runtime.
|
||||
std::string result = quick_eval("io.open('/etc/passwd', 'r')", &err);
|
||||
// Debe fallar (io es nil -> intento de indexar nil -> runtime error).
|
||||
REQUIRE(!err.empty());
|
||||
REQUIRE(result.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("lua_engine: preprocess convierte [col] a row[\"col\"]", "[lua_engine]") {
|
||||
std::string out = lua_engine::preprocess("[price] * [qty]");
|
||||
// Debe contener row["price"] y row["qty"], y auto-return al ser expresion.
|
||||
REQUIRE(out.find("row[\"price\"]") != std::string::npos);
|
||||
REQUIRE(out.find("row[\"qty\"]") != std::string::npos);
|
||||
REQUIRE(out.rfind("return", 0) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("lua_engine: fn.* builtins disponibles", "[lua_engine]") {
|
||||
std::string err;
|
||||
std::string result = quick_eval("fn.upper('hello')", &err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(result == "HELLO");
|
||||
}
|
||||
|
||||
TEST_CASE("lua_engine: shutdown y reinicio", "[lua_engine]") {
|
||||
lua_engine::shutdown();
|
||||
// Tras shutdown, get() debe crear un nuevo estado limpio.
|
||||
lua_engine::Engine* e = lua_engine::get();
|
||||
REQUIRE(e != nullptr);
|
||||
std::string err;
|
||||
std::string result = quick_eval("1 + 1", &err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(result == "2");
|
||||
lua_engine::shutdown();
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// test_tql_to_sql.cpp — Catch2 tests for tql_to_sql (pure SQL emitter).
|
||||
// Issue 0081. No DuckDB linked — only validates emitted SQL strings.
|
||||
#include "catch_amalgamated.hpp"
|
||||
#include "core/tql_to_sql.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
using namespace tql_to_sql;
|
||||
|
||||
// Helper: build a minimal State with one Stage.
|
||||
static State make_state_one_stage() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.active_stage = 0;
|
||||
return st;
|
||||
}
|
||||
|
||||
// Helper: build a TableInput for tests.
|
||||
static TableInput make_table(const std::string& name,
|
||||
const std::vector<std::string>& headers) {
|
||||
TableInput ti;
|
||||
ti.name = name;
|
||||
ti.headers = headers;
|
||||
return ti;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// transpile_expr tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("transpile_expr: simple arithmetic", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h = {"price", "qty"};
|
||||
std::string out = transpile_expr("[price] * [qty]", h, err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(out.find("\"price\"") != std::string::npos);
|
||||
REQUIRE(out.find("\"qty\"") != std::string::npos);
|
||||
REQUIRE(out.find(" * ") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: numeric literal", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h = {"x"};
|
||||
std::string out = transpile_expr("42", h, err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(out == "42");
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: string literal", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h = {};
|
||||
std::string out = transpile_expr("\"hello\"", h, err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(out == "'hello'");
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: boolean literals", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h;
|
||||
REQUIRE(transpile_expr("true", h, err) == "TRUE");
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(transpile_expr("false", h, err) == "FALSE");
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(transpile_expr("nil", h, err) == "NULL");
|
||||
REQUIRE(err.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: ternary if/then/else", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h = {"score"};
|
||||
std::string out = transpile_expr("if [score] > 90 then \"A\" else \"B\" end", h, err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(out.find("CASE WHEN") != std::string::npos);
|
||||
REQUIRE(out.find("THEN") != std::string::npos);
|
||||
REQUIRE(out.find("ELSE") != std::string::npos);
|
||||
REQUIRE(out.find("END") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: string concat", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h = {"first", "last"};
|
||||
std::string out = transpile_expr("[first] .. \" \" .. [last]", h, err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(out.find(" || ") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: whitelisted math function", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h = {"x"};
|
||||
std::string out = transpile_expr("math.abs([x])", h, err);
|
||||
REQUIRE(err.empty());
|
||||
REQUIRE(out.find("abs(") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: forbidden keyword returns error", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h;
|
||||
transpile_expr("function() end", h, err);
|
||||
REQUIRE_FALSE(err.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("transpile_expr: bare identifier not allowed", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
std::vector<std::string> h;
|
||||
transpile_expr("foo", h, err);
|
||||
REQUIRE_FALSE(err.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// is_transpilable tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("is_transpilable: simple expression is transpilable", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
bool ok = is_transpilable("[price] * 1.1", err);
|
||||
REQUIRE(ok);
|
||||
REQUIRE(err.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("is_transpilable: loop keyword not transpilable", "[tql_to_sql]") {
|
||||
std::string err;
|
||||
bool ok = is_transpilable("for i=1,10 do end", err);
|
||||
REQUIRE_FALSE(ok);
|
||||
REQUIRE_FALSE(err.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// emit_sql: select simple
|
||||
// ============================================================================
|
||||
|
||||
TEST_CASE("emit_sql: select simple single table", "[tql_to_sql]") {
|
||||
State st = make_state_one_stage();
|
||||
std::vector<TableInput> tables = { make_table("sales", {"name", "amount"}) };
|
||||
|
||||
SqlEmit e = emit_sql(st, tables);
|
||||
|
||||
REQUIRE(e.error.empty());
|
||||
REQUIRE(e.sql.find("WITH") != std::string::npos);
|
||||
REQUIRE(e.sql.find("t0") != std::string::npos);
|
||||
REQUIRE(e.sql.find("\"sales\"") != std::string::npos);
|
||||
REQUIRE(e.sql.find("\"name\"") != std::string::npos);
|
||||
REQUIRE(e.sql.find("\"amount\"") != std::string::npos);
|
||||
REQUIRE(e.sql.find("SELECT * FROM t0") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("emit_sql: filter eq produces WHERE with placeholder", "[tql_to_sql]") {
|
||||
State st = make_state_one_stage();
|
||||
Filter f;
|
||||
f.col = 0; // "region"
|
||||
f.op = Op::Eq;
|
||||
f.value = "North";
|
||||
st.stages[0].filters.push_back(f);
|
||||
|
||||
std::vector<TableInput> tables = { make_table("sales", {"region", "amount"}) };
|
||||
SqlEmit e = emit_sql(st, tables);
|
||||
|
||||
REQUIRE(e.error.empty());
|
||||
REQUIRE(e.sql.find("WHERE") != std::string::npos);
|
||||
REQUIRE(e.sql.find(" = ") != std::string::npos);
|
||||
REQUIRE(e.sql.find("?") != std::string::npos);
|
||||
REQUIRE(e.params.size() == 1);
|
||||
REQUIRE(e.params[0] == "North");
|
||||
}
|
||||
|
||||
TEST_CASE("emit_sql: sort produces ORDER BY", "[tql_to_sql]") {
|
||||
State st = make_state_one_stage();
|
||||
SortClause sc;
|
||||
sc.col = "amount";
|
||||
sc.desc = true;
|
||||
st.stages[0].sorts.push_back(sc);
|
||||
|
||||
std::vector<TableInput> tables = { make_table("sales", {"region", "amount"}) };
|
||||
SqlEmit e = emit_sql(st, tables);
|
||||
|
||||
REQUIRE(e.error.empty());
|
||||
REQUIRE(e.sql.find("ORDER BY") != std::string::npos);
|
||||
REQUIRE(e.sql.find("DESC") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("emit_sql: group by + sum aggregation produces correct SQL", "[tql_to_sql]") {
|
||||
State st;
|
||||
// Stage 0: passthrough
|
||||
st.stages.push_back(Stage{});
|
||||
// Stage 1: breakout + aggregation
|
||||
Stage s1;
|
||||
s1.breakouts.push_back("region");
|
||||
Aggregation ag;
|
||||
ag.fn = AggFn::Sum;
|
||||
ag.col = "amount";
|
||||
s1.aggregations.push_back(ag);
|
||||
st.stages.push_back(s1);
|
||||
st.active_stage = 1;
|
||||
|
||||
std::vector<TableInput> tables = { make_table("sales", {"region", "amount"}) };
|
||||
SqlEmit e = emit_sql(st, tables, 1);
|
||||
|
||||
REQUIRE(e.error.empty());
|
||||
REQUIRE(e.sql.find("GROUP BY") != std::string::npos);
|
||||
REQUIRE(e.sql.find("SUM(") != std::string::npos);
|
||||
REQUIRE(e.sql.find("\"region\"") != std::string::npos);
|
||||
REQUIRE(e.sql.find("SELECT * FROM t1") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("emit_sql: join two tables produces JOIN clause", "[tql_to_sql]") {
|
||||
State st = make_state_one_stage();
|
||||
Join jn;
|
||||
jn.alias = "cat";
|
||||
jn.source = "categories";
|
||||
jn.on.push_back({"cat_id", "id"});
|
||||
jn.strategy = JoinStrategy::Left;
|
||||
st.joins.push_back(jn);
|
||||
st.main_source = "sales";
|
||||
|
||||
std::vector<TableInput> tables = {
|
||||
make_table("sales", {"cat_id", "amount"}),
|
||||
make_table("categories", {"id", "name"}),
|
||||
};
|
||||
SqlEmit e = emit_sql(st, tables);
|
||||
|
||||
REQUIRE(e.error.empty());
|
||||
REQUIRE(e.sql.find("LEFT JOIN") != std::string::npos);
|
||||
REQUIRE(e.sql.find("\"categories\"") != std::string::npos);
|
||||
REQUIRE(e.sql.find("\"cat\"") != std::string::npos);
|
||||
REQUIRE(e.sql.find(" ON ") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("emit_sql: empty state returns error", "[tql_to_sql]") {
|
||||
State st; // no stages
|
||||
std::vector<TableInput> tables = { make_table("t", {"a"}) };
|
||||
SqlEmit e = emit_sql(st, tables);
|
||||
REQUIRE_FALSE(e.error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("emit_sql: empty tables returns error", "[tql_to_sql]") {
|
||||
State st = make_state_one_stage();
|
||||
std::vector<TableInput> tables;
|
||||
SqlEmit e = emit_sql(st, tables);
|
||||
REQUIRE_FALSE(e.error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("emit_sql: contains filter produces LIKE with wildcards", "[tql_to_sql]") {
|
||||
State st = make_state_one_stage();
|
||||
Filter f;
|
||||
f.col = 0;
|
||||
f.op = Op::Contains;
|
||||
f.value = "foo";
|
||||
st.stages[0].filters.push_back(f);
|
||||
|
||||
std::vector<TableInput> tables = { make_table("t", {"name"}) };
|
||||
SqlEmit e = emit_sql(st, tables);
|
||||
|
||||
REQUIRE(e.error.empty());
|
||||
REQUIRE(e.sql.find("LIKE") != std::string::npos);
|
||||
REQUIRE(e.params.size() == 1);
|
||||
REQUIRE(e.params[0] == "%foo%");
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Tests parciales para viz::render (cpp/functions/viz/viz_render.cpp).
|
||||
//
|
||||
// render() requiere un contexto ImGui + ImPlot vivo — no se puede ejercitar
|
||||
// en tests headless. Este archivo cubre SOLO las funciones helper publicas
|
||||
// (first_numeric_col, first_category_col, extract_numeric, extract_category)
|
||||
// que son logica pura sin dependencia de ImGui/ImPlot.
|
||||
//
|
||||
// Smoke real del dispatcher: primitives_gallery --capture (golden images, issue 0048).
|
||||
// Issue 0081-G.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
// Incluir solo el header de tipos (no ImGui, no ImPlot) para construir
|
||||
// StageOutput de prueba.
|
||||
#include "core/data_table_types.h"
|
||||
|
||||
// Declaraciones adelantadas de los helpers publicos (evita incluir viz_render.h
|
||||
// que arrastra imgui.h). Replicar aqui es legal para tests headless.
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace data_table { struct StageOutput; }
|
||||
|
||||
// Incluir la implementacion completa via el .h para obtener las firmas
|
||||
// publicas. viz_render.h incluye imgui.h, que en tests sin contexto GLFW
|
||||
// es solo tipos — safe para compilar (no linkamos fn_framework aqui).
|
||||
//
|
||||
// Para evitar el pull-in de imgui.h (que necesita GLFW o al menos el stub),
|
||||
// re-declaramos solo las funciones helper que necesitamos testear.
|
||||
// Las implementaciones estan en viz_render.cpp que linkamos directamente.
|
||||
|
||||
namespace viz {
|
||||
int first_numeric_col(const data_table::StageOutput& out);
|
||||
int first_category_col(const data_table::StageOutput& out);
|
||||
std::vector<double> extract_numeric(const data_table::StageOutput& out, int col);
|
||||
std::vector<std::string> extract_category(const data_table::StageOutput& out, int col);
|
||||
} // namespace viz
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: construir un StageOutput simple para tests
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
struct TestTable {
|
||||
std::vector<std::string> backing;
|
||||
std::vector<const char*> cells;
|
||||
std::vector<std::string> headers;
|
||||
std::vector<ColumnType> types;
|
||||
int rows = 0, cols = 0;
|
||||
|
||||
void add_row(std::initializer_list<const char*> row) {
|
||||
for (const char* s : row) backing.emplace_back(s ? s : "");
|
||||
++rows;
|
||||
}
|
||||
|
||||
StageOutput build() {
|
||||
StageOutput out;
|
||||
cols = (int)headers.size();
|
||||
cells.clear();
|
||||
for (const auto& s : backing) cells.push_back(s.c_str());
|
||||
out.headers = headers;
|
||||
out.types = types;
|
||||
out.rows = rows;
|
||||
out.cols = cols;
|
||||
out.cells = cells;
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
} // anon
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// first_numeric_col
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("first_numeric_col returns -1 on empty output", "[viz_render]") {
|
||||
StageOutput out;
|
||||
REQUIRE(viz::first_numeric_col(out) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("first_numeric_col returns 0 for all-numeric output", "[viz_render]") {
|
||||
TestTable t;
|
||||
t.headers = {"a", "b"};
|
||||
t.types = {ColumnType::Float, ColumnType::Int};
|
||||
t.add_row({"1.0", "2"});
|
||||
auto out = t.build();
|
||||
REQUIRE(viz::first_numeric_col(out) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("first_numeric_col skips string columns", "[viz_render]") {
|
||||
TestTable t;
|
||||
t.headers = {"cat", "val"};
|
||||
t.types = {ColumnType::String, ColumnType::Float};
|
||||
t.add_row({"alfa", "3.14"});
|
||||
auto out = t.build();
|
||||
// Primera col es String -> primera numerica es col 1
|
||||
REQUIRE(viz::first_numeric_col(out) == 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// first_category_col
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("first_category_col returns -1 on all-numeric output", "[viz_render]") {
|
||||
TestTable t;
|
||||
t.headers = {"x", "y"};
|
||||
t.types = {ColumnType::Int, ColumnType::Float};
|
||||
t.add_row({"1", "2.0"});
|
||||
auto out = t.build();
|
||||
REQUIRE(viz::first_category_col(out) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("first_category_col returns first string column", "[viz_render]") {
|
||||
TestTable t;
|
||||
t.headers = {"num", "cat"};
|
||||
t.types = {ColumnType::Int, ColumnType::String};
|
||||
t.add_row({"42", "hello"});
|
||||
auto out = t.build();
|
||||
REQUIRE(viz::first_category_col(out) == 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extract_numeric
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("extract_numeric returns empty for out-of-range col", "[viz_render]") {
|
||||
TestTable t;
|
||||
t.headers = {"x"};
|
||||
t.types = {ColumnType::Float};
|
||||
t.add_row({"1.5"});
|
||||
auto out = t.build();
|
||||
// col -1
|
||||
REQUIRE(viz::extract_numeric(out, -1).empty());
|
||||
// col >= cols
|
||||
REQUIRE(viz::extract_numeric(out, 5).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("extract_numeric returns NaN for unparseable cells", "[viz_render]") {
|
||||
TestTable t;
|
||||
t.headers = {"val"};
|
||||
t.types = {ColumnType::String};
|
||||
t.add_row({"abc"});
|
||||
t.add_row({"3.14"});
|
||||
t.add_row({""});
|
||||
auto out = t.build();
|
||||
auto v = viz::extract_numeric(out, 0);
|
||||
REQUIRE(v.size() == 3);
|
||||
REQUIRE(std::isnan(v[0]));
|
||||
REQUIRE(!std::isnan(v[1]));
|
||||
REQUIRE(std::abs(v[1] - 3.14) < 1e-9);
|
||||
REQUIRE(std::isnan(v[2]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extract_category
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("extract_category returns empty for out-of-range col", "[viz_render]") {
|
||||
TestTable t;
|
||||
t.headers = {"s"};
|
||||
t.types = {ColumnType::String};
|
||||
t.add_row({"hello"});
|
||||
auto out = t.build();
|
||||
REQUIRE(viz::extract_category(out, -1).empty());
|
||||
REQUIRE(viz::extract_category(out, 10).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("extract_category returns empty strings for null cells", "[viz_render]") {
|
||||
// Construimos un StageOutput con un cell ptr nulo manualmente
|
||||
StageOutput out;
|
||||
out.headers = {"s"};
|
||||
out.types = {ColumnType::String};
|
||||
out.rows = 2;
|
||||
out.cols = 1;
|
||||
// cell[0] = nullptr, cell[1] = "hello"
|
||||
static const char* hello = "hello";
|
||||
out.cells = {nullptr, hello};
|
||||
auto v = viz::extract_category(out, 0);
|
||||
REQUIRE(v.size() == 2);
|
||||
REQUIRE(v[0].empty());
|
||||
REQUIRE(v[1] == "hello");
|
||||
}
|
||||
Reference in New Issue
Block a user