dc373e4b2b
- playground/tables/CMakeLists.txt - playground/tables/data_table.cpp - playground/tables/self_test.cpp - playground/tables/tql_duckdb.cpp - playground/tables/tql_duckdb.h Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2922 lines
127 KiB
C++
2922 lines
127 KiB
C++
// E2E self-test del playground tables. Ejercita la logica pura
|
|
// (data_table_logic) sin ImGui. Build target separado:
|
|
//
|
|
// tables_playground_self_test -> linux
|
|
// tables_playground_self_test.exe -> windows
|
|
//
|
|
// Exit 0 = todos los checks pasan, 1 = falla.
|
|
|
|
#include "data_table_logic.h"
|
|
#include "llm_anthropic.h"
|
|
#include "lua_engine.h"
|
|
#include "tql.h"
|
|
#include "tql_to_sql.h"
|
|
#ifdef FN_TQL_DUCKDB
|
|
# include "tql_duckdb.h"
|
|
#endif
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
int failed = 0;
|
|
int passed = 0;
|
|
|
|
void check(bool cond, const char* name) {
|
|
if (cond) { passed++; std::printf("PASS %s\n", name); }
|
|
else { failed++; std::printf("FAIL %s\n", name); }
|
|
}
|
|
|
|
} // namespace
|
|
|
|
using namespace data_table;
|
|
|
|
// Test helpers: imitan la API antigua sort_col/sort_desc sobre el nuevo
|
|
// modelo de SortClause-by-name. Usan la convencion "@<idx>" para indices
|
|
// posicionales (compatible con compute_visible_rows).
|
|
namespace {
|
|
void set_sort_idx(State& st, int idx, bool desc) {
|
|
st.ensure_stage0();
|
|
st.stages[0].sorts.clear();
|
|
if (idx < 0) return;
|
|
char buf[16]; std::snprintf(buf, sizeof(buf), "@%d", idx);
|
|
st.stages[0].sorts.push_back({buf, desc});
|
|
}
|
|
void set_sort_desc(State& st, bool desc) {
|
|
st.ensure_stage0();
|
|
if (st.stages[0].sorts.empty()) return;
|
|
st.stages[0].sorts.front().desc = desc;
|
|
}
|
|
int sort_col_idx(const State& st) {
|
|
const Stage& s = st.raw();
|
|
if (s.sorts.empty()) return -1;
|
|
const std::string& c = s.sorts.front().col;
|
|
if (c.size() < 2 || c[0] != '@') return -1;
|
|
return std::atoi(c.c_str() + 1);
|
|
}
|
|
bool sort_col_desc(const State& st) {
|
|
const Stage& s = st.raw();
|
|
if (s.sorts.empty()) return false;
|
|
return s.sorts.front().desc;
|
|
}
|
|
} // namespace
|
|
|
|
int main() {
|
|
// --- parse_number ---
|
|
double v = 0;
|
|
check(parse_number("1.23", v) && v == 1.23, "parse_number 1.23");
|
|
check(parse_number("42", v) && v == 42.0, "parse_number 42");
|
|
check(parse_number("-7.5", v) && v == -7.5, "parse_number -7.5");
|
|
check(!parse_number("abc", v), "parse_number abc rejected");
|
|
check(!parse_number("12x", v), "parse_number 12x rejected");
|
|
check(!parse_number("", v), "parse_number empty rejected");
|
|
check(!parse_number(nullptr, v), "parse_number null rejected");
|
|
|
|
// --- compare numerico ---
|
|
check( compare("10", "2", Op::Gt), "10 > 2 numerico");
|
|
check(!compare("10", "2", Op::Lt), "10 < 2 numerico false");
|
|
check( compare("2", "10", Op::Lt), "2 < 10 numerico");
|
|
check( compare("5", "5", Op::Eq), "5 == 5 numerico");
|
|
check( compare("5", "5", Op::Gte), "5 >= 5 numerico");
|
|
check( compare("5", "5", Op::Lte), "5 <= 5 numerico");
|
|
check( compare("5", "5", Op::Neq) == false, "5 != 5 numerico false");
|
|
|
|
// --- compare lexical (cuando no son numeros) ---
|
|
check( compare("go", "go", Op::Eq), "lexical eq");
|
|
check( compare("go", "py", Op::Neq), "lexical neq");
|
|
check( compare("py", "go", Op::Gt), "lexical gt");
|
|
check( compare("ab", "ac", Op::Lt), "lexical lt");
|
|
|
|
// --- compute_visible_rows: filter ---
|
|
const char* cells[] = {
|
|
"a","1",
|
|
"b","2",
|
|
"c","3",
|
|
"a","4",
|
|
};
|
|
State st;
|
|
st.raw().filters.push_back({0, Op::Eq, "a"});
|
|
auto rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 2 && rows[0] == 0 && rows[1] == 3, "filter col0 = a");
|
|
|
|
// --- filter numerico ---
|
|
st.raw().filters.clear();
|
|
st.raw().filters.push_back({1, Op::Gt, "2"});
|
|
rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 2 && rows[0] == 2 && rows[1] == 3, "filter col1 > 2");
|
|
|
|
// --- combinacion: > 1 AND col0 != b ---
|
|
st.raw().filters.clear();
|
|
st.raw().filters.push_back({1, Op::Gt, "1"});
|
|
st.raw().filters.push_back({0, Op::Neq, "b"});
|
|
rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 2 && rows[0] == 2 && rows[1] == 3, "filter combinado AND");
|
|
|
|
// --- sort ascendente numerico ---
|
|
st.raw().filters.clear();
|
|
set_sort_idx(st, 1, false);
|
|
set_sort_desc(st, false);
|
|
rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 4 && rows[0] == 0 && rows[3] == 3, "sort asc numerico");
|
|
|
|
// --- sort descendente numerico ---
|
|
set_sort_desc(st, true);
|
|
rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 4 && rows[0] == 3 && rows[3] == 0, "sort desc numerico");
|
|
|
|
// --- sort lexical ---
|
|
set_sort_idx(st, 0, false);
|
|
set_sort_desc(st, false);
|
|
rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 4 && std::strcmp(cells[rows[0]*2], "a") == 0
|
|
&& std::strcmp(cells[rows[3]*2], "c") == 0, "sort asc lexical");
|
|
|
|
// --- filter + sort combinado ---
|
|
set_sort_idx(st, 1, false);
|
|
set_sort_desc(st, true);
|
|
st.raw().filters.push_back({0, Op::Eq, "a"});
|
|
rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 2 && rows[0] == 3 && rows[1] == 0, "filter+sort combinado");
|
|
|
|
// --- filter sobre columna inexistente: se ignora ---
|
|
st.raw().filters.clear();
|
|
st.raw().filters.push_back({99, Op::Eq, "x"});
|
|
set_sort_idx(st, -1, false);
|
|
rows = compute_visible_rows(cells, 4, 2, st);
|
|
check(rows.size() == 4, "filter col fuera de rango ignorado");
|
|
|
|
// --- col_order default identidad tras init ---
|
|
State st2;
|
|
st2.col_order = {0, 1, 2, 3};
|
|
check(st2.col_order.size() == 4 && st2.col_order[0] == 0 && st2.col_order[3] == 3,
|
|
"col_order identidad");
|
|
|
|
// --- col_order no afecta compute_visible_rows (sort/filter trabajan sobre col dataset) ---
|
|
st2.col_order = {3, 2, 1, 0};
|
|
set_sort_idx(st2, 1, false);
|
|
set_sort_desc(st2, false);
|
|
auto r2 = compute_visible_rows(cells, 4, 2, st2);
|
|
check(r2.size() == 4 && r2[0] == 0 && r2[3] == 3,
|
|
"col_order no afecta semantica sort/filter");
|
|
|
|
// --- reorder_column: drag DERECHA (si<di) ---
|
|
{
|
|
State s; s.col_order = {0, 1, 2, 3};
|
|
reorder_column(s, 0, 2);
|
|
// Esperado: 0 va a la posicion donde estaba 2 -> [1,2,0,3]
|
|
check(s.col_order.size() == 4 &&
|
|
s.col_order[0] == 1 && s.col_order[1] == 2 &&
|
|
s.col_order[2] == 0 && s.col_order[3] == 3,
|
|
"reorder derecha 0->2 = [1,2,0,3]");
|
|
}
|
|
// --- reorder_column: drag IZQUIERDA (si>di) ---
|
|
{
|
|
State s; s.col_order = {0, 1, 2, 3};
|
|
reorder_column(s, 3, 1);
|
|
// Esperado: 3 va a la posicion donde estaba 1 -> [0,3,1,2]
|
|
check(s.col_order.size() == 4 &&
|
|
s.col_order[0] == 0 && s.col_order[1] == 3 &&
|
|
s.col_order[2] == 1 && s.col_order[3] == 2,
|
|
"reorder izquierda 3->1 = [0,3,1,2]");
|
|
}
|
|
// --- reorder_column: adyacente derecha ---
|
|
{
|
|
State s; s.col_order = {0, 1, 2, 3};
|
|
reorder_column(s, 1, 2);
|
|
// 1->2: [0,2,1,3]
|
|
check(s.col_order[0] == 0 && s.col_order[1] == 2 &&
|
|
s.col_order[2] == 1 && s.col_order[3] == 3,
|
|
"reorder adyacente derecha 1->2");
|
|
}
|
|
// --- reorder_column: no-op src==dst ---
|
|
{
|
|
State s; s.col_order = {0, 1, 2, 3};
|
|
reorder_column(s, 2, 2);
|
|
check(s.col_order[0] == 0 && s.col_order[1] == 1 &&
|
|
s.col_order[2] == 2 && s.col_order[3] == 3,
|
|
"reorder no-op src==dst");
|
|
}
|
|
// --- reorder_column: src o dst fuera del array ---
|
|
{
|
|
State s; s.col_order = {0, 1, 2};
|
|
reorder_column(s, 99, 0);
|
|
check(s.col_order[0] == 0 && s.col_order[1] == 1 && s.col_order[2] == 2,
|
|
"reorder src fuera de rango = no-op");
|
|
}
|
|
|
|
// --- tipos mixtos: int / float / bool / date ---
|
|
const char* mixed[] = {
|
|
"alpha", "1", "1.2", "true", "2025-01-15",
|
|
"beta", "2", "0.9", "false", "2025-06-01",
|
|
"gamma", "10","0.45", "true", "2024-12-31",
|
|
};
|
|
{
|
|
State s; s.raw().filters.push_back({1, Op::Gt, "2"}); // int col1 > 2 (numerico: 10>2)
|
|
auto r = compute_visible_rows(mixed, 3, 5, s);
|
|
check(r.size() == 1 && r[0] == 2, "filtro int numerico col1 > 2");
|
|
}
|
|
{
|
|
State s; s.raw().filters.push_back({2, Op::Lt, "1.0"}); // float col2 < 1.0
|
|
auto r = compute_visible_rows(mixed, 3, 5, s);
|
|
check(r.size() == 2 && r[0] == 1 && r[1] == 2, "filtro float col2 < 1.0");
|
|
}
|
|
{
|
|
State s; s.raw().filters.push_back({3, Op::Eq, "true"}); // bool col3 == true
|
|
auto r = compute_visible_rows(mixed, 3, 5, s);
|
|
check(r.size() == 2 && r[0] == 0 && r[1] == 2, "filtro bool col3 == true");
|
|
}
|
|
{
|
|
State s; s.raw().filters.push_back({4, Op::Gte, "2025-01-01"}); // date col4 >= 2025-01-01 (lexical)
|
|
auto r = compute_visible_rows(mixed, 3, 5, s);
|
|
check(r.size() == 2 && r[0] == 0 && r[1] == 1, "filtro date col4 >= 2025-01-01");
|
|
}
|
|
{
|
|
State s; set_sort_idx(s, 2, false); set_sort_desc(s, true); // sort float desc
|
|
auto r = compute_visible_rows(mixed, 3, 5, s);
|
|
check(r.size() == 3 && r[0] == 0 && r[1] == 1 && r[2] == 2,
|
|
"sort float desc");
|
|
}
|
|
{
|
|
State s; set_sort_idx(s, 4, false); set_sort_desc(s, false); // sort date asc (lexical)
|
|
auto r = compute_visible_rows(mixed, 3, 5, s);
|
|
check(r.size() == 3 && r[0] == 2 && r[1] == 0 && r[2] == 1,
|
|
"sort date asc cronologico");
|
|
}
|
|
|
|
// --- compute_column_stats ---
|
|
{
|
|
// Col numerica con un vacio
|
|
const char* m[] = {
|
|
"1",
|
|
"2",
|
|
"",
|
|
"5",
|
|
"5",
|
|
};
|
|
// 5 rows x 1 col
|
|
const char* m_flat[] = {"1","2","","5","5"};
|
|
auto s = compute_column_stats(m_flat, 5, 1, 0);
|
|
check(s.total == 5 && s.empty_count == 1, "stats: total + empty_count");
|
|
check(s.numeric == true && s.numeric_count == 4, "stats: numeric flag + count");
|
|
check(s.min == 1.0 && s.max == 5.0, "stats: min/max numerico");
|
|
check(s.sum == 13.0, "stats: sum");
|
|
check(s.mean == 13.0/4.0, "stats: mean ignora vacios");
|
|
check(s.unique_count == 3, "stats: unique 3 (1,2,5)");
|
|
}
|
|
{
|
|
// Col mixta: parsea como string (no numeric)
|
|
const char* m[] = {"go","py","go","cpp"};
|
|
auto s = compute_column_stats(m, 4, 1, 0);
|
|
check(s.numeric == false, "stats: lexical no es numeric");
|
|
check(s.unique_count == 3, "stats: unique 3 (go,py,cpp)");
|
|
check(s.empty_count == 0, "stats: sin empties");
|
|
}
|
|
{
|
|
// Cap de uniques
|
|
const char* m[] = {"a","b","c","d","e"};
|
|
auto s = compute_column_stats(m, 5, 1, 0, /*unique_cap=*/2);
|
|
check(s.unique_capped == true, "stats: unique_capped flag");
|
|
check(s.unique_count <= 2, "stats: unique respeta cap");
|
|
}
|
|
{
|
|
// Bool col
|
|
const char* m[] = {"true","false","true","true"};
|
|
auto s = compute_column_stats(m, 4, 1, 0);
|
|
check(s.numeric == false, "stats: bool no es numeric");
|
|
check(s.unique_count == 2, "stats: bool unique = 2");
|
|
}
|
|
{
|
|
// Col fuera de rango
|
|
const char* m[] = {"x"};
|
|
auto s = compute_column_stats(m, 1, 1, 99);
|
|
check(s.total == 0, "stats: col fuera de rango devuelve vacio");
|
|
}
|
|
{
|
|
// Percentiles sobre {1..9}
|
|
const char* m[] = {"1","2","3","4","5","6","7","8","9"};
|
|
auto s = compute_column_stats(m, 9, 1, 0);
|
|
check(s.numeric && s.numeric_count == 9, "stats: 9 nums");
|
|
check(s.p25 == 3.0, "stats: p25 = 3");
|
|
check(s.p50 == 5.0, "stats: p50 = 5 (mediana)");
|
|
check(s.p75 == 7.0, "stats: p75 = 7");
|
|
check((int)s.hist.size() == HIST_BINS, "stats: hist tiene HIST_BINS bins");
|
|
float sum = 0.f; for (float x : s.hist) sum += x;
|
|
check((int)sum == 9, "stats: hist suma = numeric_count");
|
|
}
|
|
{
|
|
// Histograma con todos iguales -> bin central tiene todo
|
|
const char* m[] = {"5","5","5","5"};
|
|
auto s = compute_column_stats(m, 4, 1, 0);
|
|
check(s.min == 5.0 && s.max == 5.0, "stats: min==max homogeneo");
|
|
check(s.hist[HIST_BINS / 2] == 4.0f, "stats: hist degenerado pone todo en bin central");
|
|
}
|
|
{
|
|
// Stats con indices: SOLO filas indicadas se contabilizan.
|
|
const char* m_flat[] = {"1","2","3","4","5","6","7","8","9"};
|
|
int indices[] = {0, 2, 4}; // valores 1, 3, 5
|
|
auto s = compute_column_stats(m_flat, 9, 1, 0, 100000, indices, 3);
|
|
check(s.total == 3, "stats(idx): total = n_indices");
|
|
check(s.numeric_count == 3, "stats(idx): numeric_count");
|
|
check(s.min == 1.0 && s.max == 5.0, "stats(idx): min/max sobre subset");
|
|
check(s.mean == 3.0, "stats(idx): mean = 3");
|
|
check(s.p50 == 3.0, "stats(idx): mediana subset");
|
|
check(s.unique_count == 3, "stats(idx): unique subset");
|
|
}
|
|
{
|
|
// Stats reactivo a filtro: compute con visible_rows tras filtrar
|
|
const char* m_flat[] = {"a","1", "b","2", "a","3", "b","4"};
|
|
State st;
|
|
st.raw().filters.push_back({0, Op::Eq, "a"});
|
|
auto vis = compute_visible_rows(m_flat, 4, 2, st);
|
|
// valores col 1 filtrados: rows 0,2 -> "1","3"
|
|
auto s = compute_column_stats(m_flat, 4, 2, 1, 100000,
|
|
vis.data(), (int)vis.size());
|
|
check(s.total == 2, "stats reactivo: total = 2 tras filter");
|
|
check(s.numeric_count == 2, "stats reactivo: numeric_count");
|
|
check(s.min == 1.0, "stats reactivo: min sobre subset filtrado");
|
|
check(s.max == 3.0, "stats reactivo: max sobre subset filtrado");
|
|
check(s.mean == 2.0, "stats reactivo: mean sobre subset filtrado");
|
|
}
|
|
{
|
|
// Indices vacios = scan completo (n_indices=0 hace fallback)
|
|
const char* m[] = {"1","2","3"};
|
|
auto s = compute_column_stats(m, 3, 1, 0, 100000, nullptr, 0);
|
|
check(s.total == 3, "stats: indices null -> scan completo");
|
|
}
|
|
|
|
// --- Ops nuevas: Contains / NotContains / StartsWith / EndsWith ---
|
|
check( compare("hello_world", "world", Op::Contains), "contains hello_world has world");
|
|
check(!compare("hello", "xxx", Op::Contains), "!contains hello/xxx");
|
|
check( compare("hello", "xxx", Op::NotContains), "notcontains hello/xxx");
|
|
check(!compare("hello_world", "world", Op::NotContains), "!notcontains hello_world/world");
|
|
check( compare("hello_world", "hello", Op::StartsWith), "starts hello_world/hello");
|
|
check(!compare("hello_world", "world", Op::StartsWith), "!starts hello_world/world");
|
|
check( compare("hello_world", "world", Op::EndsWith), "ends hello_world/world");
|
|
check(!compare("hello_world", "hello", Op::EndsWith), "!ends hello_world/hello");
|
|
check( compare("a", "", Op::Contains), "contains empty needle = true");
|
|
check(!compare("a", "", Op::NotContains), "notcontains empty needle = false");
|
|
check( compare("anything", "", Op::StartsWith), "starts empty prefix = true");
|
|
check( compare("anything", "", Op::EndsWith), "ends empty suffix = true");
|
|
check(!compare("ab", "abcd", Op::StartsWith), "starts needle longer than hay = false");
|
|
check(!compare("ab", "abcd", Op::EndsWith), "ends needle longer than hay = false");
|
|
check(op_is_string_only(Op::Contains) && op_is_string_only(Op::NotContains),
|
|
"op_is_string_only contains/notcontains");
|
|
check(op_is_string_only(Op::StartsWith) && op_is_string_only(Op::EndsWith),
|
|
"op_is_string_only starts/ends");
|
|
check(!op_is_string_only(Op::Eq) && !op_is_string_only(Op::Gt),
|
|
"op_is_string_only false para = y >");
|
|
|
|
// --- Filtros nuevos integrados con compute_visible_rows ---
|
|
{
|
|
const char* m[] = {
|
|
"fn_alpha", "go",
|
|
"fn_beta", "py",
|
|
"fn_gamma", "go",
|
|
"lib_x", "cpp",
|
|
};
|
|
State st; st.raw().filters.push_back({0, Op::StartsWith, "fn_"});
|
|
auto r = compute_visible_rows(m, 4, 2, st);
|
|
check(r.size() == 3, "filter starts_with fn_");
|
|
st.raw().filters.clear();
|
|
st.raw().filters.push_back({0, Op::EndsWith, "alpha"});
|
|
r = compute_visible_rows(m, 4, 2, st);
|
|
check(r.size() == 1 && r[0] == 0, "filter ends_with alpha");
|
|
st.raw().filters.clear();
|
|
st.raw().filters.push_back({0, Op::Contains, "lib"});
|
|
r = compute_visible_rows(m, 4, 2, st);
|
|
check(r.size() == 1 && r[0] == 3, "filter contains lib");
|
|
st.raw().filters.clear();
|
|
st.raw().filters.push_back({1, Op::NotContains, "p"});
|
|
r = compute_visible_rows(m, 4, 2, st);
|
|
// p contiene a "py" y "cpp"; quedan rows con lang="go" (0, 2)
|
|
check(r.size() == 2 && r[0] == 0 && r[1] == 2, "filter notcontains p");
|
|
}
|
|
|
|
// --- Range filter como 2 filtros encadenados ---
|
|
{
|
|
const char* m[] = {"1","2","3","4","5","6","7","8","9","10"};
|
|
State st;
|
|
st.raw().filters.push_back({0, Op::Gte, "3"});
|
|
st.raw().filters.push_back({0, Op::Lte, "7"});
|
|
auto r = compute_visible_rows(m, 10, 1, st);
|
|
check(r.size() == 5 && r[0] == 2 && r[4] == 6, "range [3..7] AND chain");
|
|
}
|
|
|
|
// --- top_categories ---
|
|
{
|
|
const char* m[] = {"go","py","go","cpp","go","py","cpp","cpp","go"};
|
|
auto s = compute_column_stats(m, 9, 1, 0);
|
|
check(s.top_categories.size() == 3, "top_categories size = 3 distintos");
|
|
// go=4, cpp=3, py=2
|
|
check(s.top_categories[0].first == "go" && s.top_categories[0].second == 4,
|
|
"top_categories[0] = go,4");
|
|
check(s.top_categories[1].first == "cpp" && s.top_categories[1].second == 3,
|
|
"top_categories[1] = cpp,3");
|
|
check(s.top_categories[2].first == "py" && s.top_categories[2].second == 2,
|
|
"top_categories[2] = py,2");
|
|
}
|
|
|
|
// --- csv_escape ---
|
|
check(csv_escape("simple") == "simple", "csv_escape: sin caracteres especiales");
|
|
check(csv_escape("a,b") == "\"a,b\"", "csv_escape: coma -> quotes");
|
|
check(csv_escape("a\"b") == "\"a\"\"b\"", "csv_escape: quote doblada");
|
|
check(csv_escape("a\nb") == "\"a\nb\"", "csv_escape: newline -> quotes");
|
|
check(csv_escape(nullptr) == "", "csv_escape: null -> empty");
|
|
|
|
// --- build_tsv: rect selection con headers ---
|
|
{
|
|
const char* cells_t[] = {
|
|
"1","a","X",
|
|
"2","b","Y",
|
|
"3","c","Z",
|
|
};
|
|
const char* headers_t[] = {"num","letter","tag"};
|
|
std::vector<int> col_order = {0, 1, 2};
|
|
std::vector<bool> col_vis = {true, true, true};
|
|
std::vector<int> visible = {0, 1, 2};
|
|
// Selecciona rect (rows 0..1, cols 1..2) -> letter+tag, rows a,X / b,Y
|
|
auto tsv = build_tsv(cells_t, 3, 3, headers_t, col_order, col_vis, visible,
|
|
0, 1, 1, 2);
|
|
std::string expected = "letter\ttag\na\tX\nb\tY\n";
|
|
check(tsv == expected, "build_tsv rect 0..1 x 1..2 + headers");
|
|
}
|
|
{
|
|
// build_tsv con columna oculta dentro del rect -> se omite
|
|
const char* cells_t[] = {"1","a","X","2","b","Y"};
|
|
const char* headers_t[] = {"num","letter","tag"};
|
|
std::vector<int> col_order = {0, 1, 2};
|
|
std::vector<bool> col_vis = {true, false, true}; // letter oculto
|
|
std::vector<int> visible = {0, 1};
|
|
auto tsv = build_tsv(cells_t, 2, 3, headers_t, col_order, col_vis, visible,
|
|
0, 1, 0, 2);
|
|
std::string expected = "num\ttag\n1\tX\n2\tY\n";
|
|
check(tsv == expected, "build_tsv salta columna oculta");
|
|
}
|
|
{
|
|
// build_tsv respeta col_order custom
|
|
const char* cells_t[] = {"1","a","2","b"};
|
|
const char* headers_t[] = {"num","letter"};
|
|
std::vector<int> col_order = {1, 0}; // letter primero
|
|
std::vector<bool> col_vis = {true, true};
|
|
std::vector<int> visible = {0, 1};
|
|
auto tsv = build_tsv(cells_t, 2, 2, headers_t, col_order, col_vis, visible,
|
|
0, 1, 0, 1);
|
|
std::string expected = "letter\tnum\na\t1\nb\t2\n";
|
|
check(tsv == expected, "build_tsv respeta col_order reordenado");
|
|
}
|
|
|
|
// --- build_csv: full filtered view con escape ---
|
|
{
|
|
const char* cells_c[] = {
|
|
"x", "1",
|
|
"y,z", "2",
|
|
"w\"q","3",
|
|
};
|
|
const char* headers_c[] = {"name","n"};
|
|
std::vector<int> col_order = {0, 1};
|
|
std::vector<bool> col_vis = {true, true};
|
|
std::vector<int> visible = {0, 1, 2};
|
|
auto csv = build_csv(cells_c, 3, 2, headers_c, col_order, col_vis, visible);
|
|
std::string expected = "name,n\nx,1\n\"y,z\",2\n\"w\"\"q\",3\n";
|
|
check(csv == expected, "build_csv con escape de coma y quote");
|
|
}
|
|
{
|
|
// build_csv vacio si no hay rows visibles
|
|
const char* cells_c[] = {"x","1"};
|
|
const char* headers_c[] = {"name","n"};
|
|
std::vector<int> col_order = {0, 1};
|
|
std::vector<bool> col_vis = {true, true};
|
|
std::vector<int> visible; // ninguna fila visible
|
|
auto csv = build_csv(cells_c, 1, 2, headers_c, col_order, col_vis, visible);
|
|
check(csv == "name,n\n", "build_csv solo headers si filter vacia rows");
|
|
}
|
|
|
|
// --- ColumnType: auto_detect_type ---
|
|
{
|
|
const char* m[] = {"1","2","3","4"};
|
|
check(auto_detect_type(m, 4, 1, 0) == ColumnType::Int, "detect Int puro");
|
|
}
|
|
{
|
|
const char* m[] = {"1","2.5","3"};
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::Float, "detect Float (mix int+float)");
|
|
}
|
|
{
|
|
const char* m[] = {"true","false","true"};
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::Bool, "detect Bool");
|
|
}
|
|
{
|
|
const char* m[] = {"2025-01-15","2025-06-30","2024-12-31"};
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::Date, "detect Date ISO");
|
|
}
|
|
{
|
|
const char* m[] = {"{\"k\":1}","[1,2,3]","{}"};
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::Json, "detect Json");
|
|
}
|
|
{
|
|
const char* m[] = {"hello","world","foo"};
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "detect String");
|
|
}
|
|
{
|
|
const char* m[] = {"1","hello","2"};
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "mix int+string -> String");
|
|
}
|
|
{
|
|
const char* m[] = {"true","yes","false"}; // 'yes' no es bool literal estricto
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "bool laxo -> String");
|
|
}
|
|
{
|
|
const char* m[] = {"","",""}; // todo vacio
|
|
check(auto_detect_type(m, 3, 1, 0) == ColumnType::String, "todo vacio -> String");
|
|
}
|
|
|
|
// --- ops_for_type ---
|
|
{
|
|
auto o = ops_for_type(ColumnType::Int);
|
|
check(o.size() == 6, "ops Int = 6");
|
|
bool has_gt = false; for (Op x : o) if (x == Op::Gt) has_gt = true;
|
|
check(has_gt, "ops Int incluye >");
|
|
}
|
|
{
|
|
auto o = ops_for_type(ColumnType::Float);
|
|
check(o.size() == 6, "ops Float = 6");
|
|
}
|
|
{
|
|
auto o = ops_for_type(ColumnType::Date);
|
|
check(o.size() == 6, "ops Date = 6 (lexical = cronologico)");
|
|
}
|
|
{
|
|
auto o = ops_for_type(ColumnType::Bool);
|
|
check(o.size() == 2, "ops Bool = 2 (= y !=)");
|
|
check(o[0] == Op::Eq && o[1] == Op::Neq, "ops Bool [Eq, Neq]");
|
|
}
|
|
{
|
|
auto o = ops_for_type(ColumnType::Json);
|
|
check(o.size() == 4, "ops Json = 4");
|
|
bool has_contains = false; for (Op x : o) if (x == Op::Contains) has_contains = true;
|
|
check(has_contains, "ops Json incluye contains");
|
|
}
|
|
{
|
|
auto o = ops_for_type(ColumnType::String);
|
|
check(o.size() == 6, "ops String = 6");
|
|
bool has_starts = false; for (Op x : o) if (x == Op::StartsWith) has_starts = true;
|
|
check(has_starts, "ops String incluye starts");
|
|
}
|
|
|
|
// --- effective_type ---
|
|
{
|
|
const char* m[] = {"1","2","3"};
|
|
check(effective_type(ColumnType::Bool, m, 3, 1, 0) == ColumnType::Bool,
|
|
"effective: declared Bool gana sobre datos numericos");
|
|
check(effective_type(ColumnType::Auto, m, 3, 1, 0) == ColumnType::Int,
|
|
"effective: Auto resuelve a Int via auto_detect");
|
|
}
|
|
|
|
// --- lua_engine: compile + eval + sandbox ---
|
|
{
|
|
auto* eng = lua_engine::get();
|
|
const char* cells_lua[] = {
|
|
"alpha", "10",
|
|
"beta", "20",
|
|
"gamma", "30",
|
|
};
|
|
std::vector<std::string> hn = {"name", "qty"};
|
|
std::unordered_map<std::string, int> n2c = {{"name", 0}, {"qty", 1}};
|
|
auto mk_ctx = [&](int r){
|
|
lua_engine::RowCtx ctx;
|
|
ctx.cells = cells_lua;
|
|
ctx.orig_cols = 2;
|
|
ctx.row = r;
|
|
ctx.header_names = &hn;
|
|
ctx.name_to_col = &n2c;
|
|
return ctx;
|
|
};
|
|
|
|
std::string err;
|
|
int id = lua_engine::compile(eng, "return row.qty * 2", &err);
|
|
check(id >= 0, "lua: compile arithmetic OK");
|
|
check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "20", "lua: eval 10*2 = 20");
|
|
check(lua_engine::eval(eng, id, mk_ctx(2), &err) == "60", "lua: eval 30*2 = 60");
|
|
lua_engine::release(eng, id);
|
|
|
|
id = lua_engine::compile(eng, "return fn.upper(row.name)", &err);
|
|
check(id >= 0, "lua: compile builtin OK");
|
|
check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "ALPHA", "lua: fn.upper");
|
|
lua_engine::release(eng, id);
|
|
|
|
id = lua_engine::compile(eng,
|
|
"if tonumber(row.qty) >= 20 then return 'high' else return 'low' end", &err);
|
|
check(id >= 0, "lua: compile if/else OK");
|
|
check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "low", "lua: if/else low");
|
|
check(lua_engine::eval(eng, id, mk_ctx(1), &err) == "high", "lua: if/else high");
|
|
lua_engine::release(eng, id);
|
|
|
|
id = lua_engine::compile(eng, "return io == nil", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true",
|
|
"lua sandbox: io is nil");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return require == nil", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true",
|
|
"lua sandbox: require is nil");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return dofile == nil", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true",
|
|
"lua sandbox: dofile is nil");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return os.execute == nil", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true",
|
|
"lua sandbox: os.execute is nil");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return type(os.date) == 'function'", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true",
|
|
"lua sandbox: os.date preservado");
|
|
lua_engine::release(eng, id);
|
|
|
|
err.clear();
|
|
id = lua_engine::compile(eng, "return row.qty *", &err);
|
|
check(id == -1 && !err.empty(), "lua: error sintaxis devuelve -1 + err");
|
|
|
|
id = lua_engine::compile(eng, "error('boom')", &err);
|
|
check(id >= 0, "lua: compile error() OK");
|
|
err.clear();
|
|
std::string out = lua_engine::eval(eng, id, mk_ctx(0), &err);
|
|
check(out == "" && !err.empty(), "lua: runtime error -> '' + err");
|
|
lua_engine::release(eng, id);
|
|
|
|
id = lua_engine::compile(eng, "return fn.length('hello')", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "5", "lua: fn.length");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return fn.concat('a', '-', 'b')", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "a-b", "lua: fn.concat");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return fn.contains('foobar', 'oob')", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", "lua: fn.contains");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return fn.starts_with('hello_world', 'hello')", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true", "lua: fn.starts_with");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return fn.year('2025-09-10')", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "2025", "lua: fn.year");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return fn.month('2025-09-10')", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "9", "lua: fn.month");
|
|
lua_engine::release(eng, id);
|
|
|
|
id = lua_engine::compile(eng, "return row[2]", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "10", "lua: row[2] = qty");
|
|
lua_engine::release(eng, id);
|
|
id = lua_engine::compile(eng, "return row.nope == nil", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "true",
|
|
"lua: col inexistente -> nil");
|
|
lua_engine::release(eng, id);
|
|
}
|
|
|
|
// --- lua_engine v2: [col] preprocesser, type-aware push, recursion ---
|
|
{
|
|
auto* eng = lua_engine::get();
|
|
// Dataset con tipos declarados
|
|
const char* cells2[] = {
|
|
"alpha", "10", "1.5", "true", "2025-01-15",
|
|
"beta", "20", "2.5", "false", "2024-06-01",
|
|
"gamma", "30", "3.5", "true", "2026-12-31",
|
|
};
|
|
std::vector<std::string> hn2 = {"name", "qty", "size", "flag", "dt"};
|
|
std::unordered_map<std::string, int> n2c2 = {
|
|
{"name", 0}, {"qty", 1}, {"size", 2}, {"flag", 3}, {"dt", 4}
|
|
};
|
|
ColumnType types2[] = {
|
|
ColumnType::String, ColumnType::Int, ColumnType::Float,
|
|
ColumnType::Bool, ColumnType::Date
|
|
};
|
|
std::vector<DerivedColumn> derived;
|
|
std::unordered_map<std::string, int> dn2i;
|
|
auto mk_ctx = [&](int r){
|
|
lua_engine::RowCtx ctx;
|
|
ctx.cells = cells2;
|
|
ctx.orig_cols = 5;
|
|
ctx.row = r;
|
|
ctx.header_names = &hn2;
|
|
ctx.name_to_col = &n2c2;
|
|
ctx.types_orig = types2;
|
|
ctx.n_types_orig = 5;
|
|
ctx.derived = &derived;
|
|
ctx.derived_name_to_idx = &dn2i;
|
|
return ctx;
|
|
};
|
|
|
|
std::string err;
|
|
// [col] sintaxis basica
|
|
int id = lua_engine::compile(eng, "return [qty] + 1", &err);
|
|
check(id >= 0, "lua v2: compile [qty] + 1");
|
|
check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "11", "lua v2: [qty]+1 row0 = 11");
|
|
lua_engine::release(eng, id);
|
|
|
|
// Auto-return: expresion suelta sin return
|
|
id = lua_engine::compile(eng, "[qty] + [size]", &err);
|
|
check(id >= 0, "lua v2: auto-return compile");
|
|
// Int 10 + Float 1.5 -> 11.5
|
|
check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "11.5",
|
|
"lua v2: auto-return [qty]+[size] = 11.5");
|
|
lua_engine::release(eng, id);
|
|
|
|
// Type-aware push: Int * 2 = integer
|
|
id = lua_engine::compile(eng, "[qty] * 2", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(1), &err) == "40",
|
|
"lua v2: Int*2 = integer (40 no 40.0)");
|
|
lua_engine::release(eng, id);
|
|
|
|
// Bool push: if [flag] then ...
|
|
id = lua_engine::compile(eng, "if [flag] then return 'yes' else return 'no' end", &err);
|
|
check(id >= 0, "lua v2: bool if compile");
|
|
check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "yes", "lua v2: flag=true -> yes");
|
|
check(lua_engine::eval(eng, id, mk_ctx(1), &err) == "no", "lua v2: flag=false -> no");
|
|
lua_engine::release(eng, id);
|
|
|
|
// Date push: string
|
|
id = lua_engine::compile(eng, "fn.year([dt])", &err);
|
|
check(id >= 0, "lua v2: fn.year([dt]) compile");
|
|
check(lua_engine::eval(eng, id, mk_ctx(0), &err) == "2025", "lua v2: year row0 = 2025");
|
|
check(lua_engine::eval(eng, id, mk_ctx(2), &err) == "2026", "lua v2: year row2 = 2026");
|
|
lua_engine::release(eng, id);
|
|
|
|
// String concat
|
|
id = lua_engine::compile(eng, "[name] .. '-' .. [qty]", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "alpha-10",
|
|
"lua v2: string concat [name].'-'.[qty]");
|
|
lua_engine::release(eng, id);
|
|
|
|
// [col] dentro de string literal: NO se traduce
|
|
id = lua_engine::compile(eng, "return '[qty]'", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "[qty]",
|
|
"lua v2: string literal preserva [qty]");
|
|
lua_engine::release(eng, id);
|
|
|
|
// [col] dentro de comentario corto: NO se traduce
|
|
id = lua_engine::compile(eng, "-- [qty] is ignored\nreturn [qty]", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "10",
|
|
"lua v2: short comment preserva [qty]");
|
|
lua_engine::release(eng, id);
|
|
|
|
// [col] dentro de comentario largo: NO se traduce
|
|
id = lua_engine::compile(eng, "--[[ [qty] is here ]]\nreturn [qty]", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "10",
|
|
"lua v2: long comment preserva [qty]");
|
|
lua_engine::release(eng, id);
|
|
|
|
// t[1] indice numerico: NO se traduce
|
|
id = lua_engine::compile(eng, "local t = {7,8,9}\nreturn t[1]", &err);
|
|
check(id >= 0 && lua_engine::eval(eng, id, mk_ctx(0), &err) == "7",
|
|
"lua v2: indice numerico t[1] intacto");
|
|
lua_engine::release(eng, id);
|
|
|
|
// UTF-8 en nombre de col
|
|
std::vector<std::string> hn_utf = {"año", "qty"};
|
|
std::unordered_map<std::string, int> n2c_utf = {{"año", 0}, {"qty", 1}};
|
|
const char* cells_utf[] = {"2025", "10", "2026", "20"};
|
|
ColumnType types_utf[] = {ColumnType::Int, ColumnType::Int};
|
|
std::vector<DerivedColumn> empty_d;
|
|
std::unordered_map<std::string, int> empty_dn;
|
|
auto mk_utf = [&](int r){
|
|
lua_engine::RowCtx c;
|
|
c.cells = cells_utf; c.orig_cols = 2; c.row = r;
|
|
c.header_names = &hn_utf; c.name_to_col = &n2c_utf;
|
|
c.types_orig = types_utf; c.n_types_orig = 2;
|
|
c.derived = &empty_d; c.derived_name_to_idx = &empty_dn;
|
|
return c;
|
|
};
|
|
id = lua_engine::compile(eng, "[año] + 1", &err);
|
|
check(id >= 0, "lua v2: compile [año] UTF-8");
|
|
check(lua_engine::eval(eng, id, mk_utf(0), &err) == "2026",
|
|
"lua v2: [año] UTF-8 row0 = 2026");
|
|
lua_engine::release(eng, id);
|
|
|
|
// Recursivo: derived A refs orig, derived B refs A
|
|
// A = "[qty] * 2" (Int)
|
|
// B = "[A] + 100" (Int)
|
|
int idA = lua_engine::compile(eng, "[qty] * 2", &err);
|
|
check(idA >= 0, "lua v2: compile derived A");
|
|
DerivedColumn dA; dA.source_col = -1; dA.type = ColumnType::Int;
|
|
dA.name = "A"; dA.formula = "[qty] * 2"; dA.lua_id = idA;
|
|
derived.push_back(dA);
|
|
dn2i["A"] = 0;
|
|
|
|
int idB = lua_engine::compile(eng, "[A] + 100", &err);
|
|
check(idB >= 0, "lua v2: compile derived B (refs A)");
|
|
DerivedColumn dB; dB.source_col = -1; dB.type = ColumnType::Int;
|
|
dB.name = "B"; dB.formula = "[A] + 100"; dB.lua_id = idB;
|
|
derived.push_back(dB);
|
|
dn2i["B"] = 1;
|
|
|
|
// row0: qty=10, A=10*2=20, B=20+100=120
|
|
check(lua_engine::eval(eng, idA, mk_ctx(0), &err) == "20",
|
|
"lua v2: derived A = 20");
|
|
check(lua_engine::eval(eng, idB, mk_ctx(0), &err) == "120",
|
|
"lua v2: derived B = A + 100 = 120 (recursive)");
|
|
|
|
// Cadena de 3 niveles: C = [B] * 2
|
|
int idC = lua_engine::compile(eng, "[B] * 2", &err);
|
|
check(idC >= 0, "lua v2: compile derived C (refs B)");
|
|
DerivedColumn dC; dC.source_col = -1; dC.type = ColumnType::Int;
|
|
dC.name = "C"; dC.formula = "[B] * 2"; dC.lua_id = idC;
|
|
derived.push_back(dC);
|
|
dn2i["C"] = 2;
|
|
check(lua_engine::eval(eng, idC, mk_ctx(0), &err) == "240",
|
|
"lua v2: chain C -> B -> A -> qty = 240");
|
|
|
|
// Ciclo: D refs E, E refs D -> nil propaga
|
|
int idD = lua_engine::compile(eng, "[E] + 1", &err);
|
|
check(idD >= 0, "lua v2: compile D (refs E)");
|
|
DerivedColumn dD; dD.source_col=-1; dD.type=ColumnType::Int;
|
|
dD.name="D"; dD.formula="[E]+1"; dD.lua_id=idD;
|
|
derived.push_back(dD); dn2i["D"] = 3;
|
|
|
|
int idE = lua_engine::compile(eng, "[D] + 1", &err);
|
|
check(idE >= 0, "lua v2: compile E (refs D)");
|
|
DerivedColumn dE; dE.source_col=-1; dE.type=ColumnType::Int;
|
|
dE.name="E"; dE.formula="[D]+1"; dE.lua_id=idE;
|
|
derived.push_back(dE); dn2i["E"] = 4;
|
|
|
|
// Evaluar D debe romper el ciclo: [E] devuelve nil, nil+1 error,
|
|
// pcall captura -> eval devuelve "" + err
|
|
err.clear();
|
|
std::string r = lua_engine::eval(eng, idD, mk_ctx(0), &err);
|
|
check(r.empty(), "lua v2: ciclo D<->E devuelve vacio sin crash");
|
|
|
|
lua_engine::release(eng, idA);
|
|
lua_engine::release(eng, idB);
|
|
lua_engine::release(eng, idC);
|
|
lua_engine::release(eng, idD);
|
|
lua_engine::release(eng, idE);
|
|
derived.clear();
|
|
dn2i.clear();
|
|
|
|
// Retipo puro (sin formula) accesible via row.<name>
|
|
derived.push_back({0, ColumnType::String, "name_str", "", -1, ""}); // source_col=0 (name)
|
|
dn2i["name_str"] = 0;
|
|
int idF = lua_engine::compile(eng, "[name_str] .. '_X'", &err);
|
|
check(idF >= 0, "lua v2: compile usando retipo puro");
|
|
check(lua_engine::eval(eng, idF, mk_ctx(0), &err) == "alpha_X",
|
|
"lua v2: row[retipo_puro] funciona");
|
|
lua_engine::release(eng, idF);
|
|
}
|
|
|
|
// --- autocomplete helpers: find_open_bracket + insert_column_ref ---
|
|
{
|
|
std::string ft;
|
|
// Cursor justo despues de "["
|
|
int idx = find_open_bracket("foo [", 5, 5, ft);
|
|
check(idx == 4 && ft == "", "ac: find_open_bracket cursor tras [");
|
|
idx = find_open_bracket("foo [abc", 8, 8, ft);
|
|
check(idx == 4 && ft == "abc", "ac: filter 'abc' tras [");
|
|
idx = find_open_bracket("foo [a] + 1", 11, 11, ft);
|
|
check(idx == -1, "ac: bracket cerrado -> -1");
|
|
idx = find_open_bracket("foo [a\nbar", 10, 10, ft);
|
|
check(idx == -1, "ac: newline interrumpe");
|
|
idx = find_open_bracket("nada", 4, 4, ft);
|
|
check(idx == -1, "ac: sin bracket -> -1");
|
|
idx = find_open_bracket("[xy", 3, 3, ft);
|
|
check(idx == 0 && ft == "xy", "ac: bracket al inicio");
|
|
idx = find_open_bracket("a [b] + [c", 10, 10, ft);
|
|
check(idx == 8 && ft == "c", "ac: segundo bracket abierto");
|
|
}
|
|
{
|
|
int nc = 0;
|
|
std::string r = insert_column_ref("foo [", 4, 5, "size_kb", nc);
|
|
check(r == "foo [size_kb]" && nc == 13, "ac: insert tras [ -> [size_kb]");
|
|
r = insert_column_ref("foo [ab", 4, 7, "size_kb", nc);
|
|
check(r == "foo [size_kb]" && nc == 13, "ac: reemplaza filter tecleado");
|
|
r = insert_column_ref("[a] + [", 6, 7, "qty", nc);
|
|
check(r == "[a] + [qty]" && nc == 11, "ac: insert preserva prefijo");
|
|
r = insert_column_ref("[a", 0, 2, "name", nc);
|
|
check(r == "[name]" && nc == 6, "ac: reemplaza [a -> [name]");
|
|
// Edge: start fuera de rango
|
|
r = insert_column_ref("hi", -1, 1, "n", nc);
|
|
check(r == "hi", "ac: start invalido = no-op");
|
|
r = insert_column_ref("hi", 0, 99, "n", nc);
|
|
check(r == "hi", "ac: cursor invalido = no-op");
|
|
}
|
|
|
|
// --- preprocess() expuesto: brackets + auto-return ---
|
|
{
|
|
check(lua_engine::preprocess("[a] + [b]") == "return row[\"a\"] + row[\"b\"]",
|
|
"preprocess: [a]+[b] -> return row[\"a\"] + row[\"b\"]");
|
|
check(lua_engine::preprocess("return [a]") == "return row[\"a\"]",
|
|
"preprocess: con return explicito no duplica");
|
|
check(lua_engine::preprocess("if [a] then return 1 end")
|
|
== "if row[\"a\"] then return 1 end",
|
|
"preprocess: if no añade return");
|
|
check(lua_engine::preprocess("'[a]'") == "return '[a]'",
|
|
"preprocess: string literal preserva [a]");
|
|
check(lua_engine::preprocess("-- [a]\nreturn 1")
|
|
== "-- [a]\nreturn 1",
|
|
"preprocess: short comment preserva [a]");
|
|
check(lua_engine::preprocess("[a b]") == "return row[\"a b\"]",
|
|
"preprocess: nombre con espacio");
|
|
}
|
|
|
|
// --- TQL: aggregation_alias + aggregation_type ---
|
|
{
|
|
check(aggregation_alias({AggFn::Count}) == "count", "tql alias count");
|
|
check(aggregation_alias({AggFn::Avg, "size_kb"}) == "avg_size_kb", "tql alias avg_size_kb");
|
|
check(aggregation_alias({AggFn::Distinct, "name"}) == "distinct_name", "tql alias distinct_name");
|
|
Aggregation p95; p95.fn = AggFn::Percentile; p95.col = "size_kb"; p95.arg = 0.95;
|
|
check(aggregation_alias(p95) == "p95_size_kb", "tql alias p95_size_kb");
|
|
Aggregation aliased; aliased.fn = AggFn::Sum; aliased.col = "x"; aliased.alias = "total";
|
|
check(aggregation_alias(aliased) == "total", "tql alias usa alias explicito");
|
|
|
|
std::vector<std::string> hdrs = {"lang", "size_kb", "name"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Float, ColumnType::String};
|
|
check(aggregation_type({AggFn::Count}, hdrs, tps) == ColumnType::Int, "tql type count = Int");
|
|
check(aggregation_type({AggFn::Distinct, "name"}, hdrs, tps) == ColumnType::Int, "tql type distinct = Int");
|
|
check(aggregation_type({AggFn::Avg, "size_kb"}, hdrs, tps) == ColumnType::Float, "tql type avg = Float");
|
|
check(aggregation_type({AggFn::Min, "name"}, hdrs, tps) == ColumnType::String, "tql type min(string) = String");
|
|
check(aggregation_type({AggFn::Min, "size_kb"}, hdrs, tps) == ColumnType::Float, "tql type min(float) = Float");
|
|
}
|
|
|
|
// --- TQL: compute_stage passthrough (filter + sort sin group) ---
|
|
{
|
|
const char* cells_t[] = {
|
|
"go", "10",
|
|
"py", "20",
|
|
"go", "30",
|
|
"cpp", "5",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int};
|
|
Stage s;
|
|
s.filters.push_back({0, Op::Eq, "go"});
|
|
s.sorts.push_back({"n", true});
|
|
auto out = compute_stage(cells_t, 4, 2, hdrs, tps, s);
|
|
check(out.rows == 2 && out.cols == 2, "tql passthrough rows + cols");
|
|
check(std::string(out.cells[0]) == "go" && std::string(out.cells[1]) == "30",
|
|
"tql passthrough sort desc por n: 30 primero");
|
|
check(std::string(out.cells[2]) == "go" && std::string(out.cells[3]) == "10",
|
|
"tql passthrough sort desc: 10 segundo");
|
|
}
|
|
|
|
// --- TQL: compute_stage group by 1 col + count ---
|
|
{
|
|
const char* cells_t[] = {
|
|
"go", "10",
|
|
"py", "20",
|
|
"go", "30",
|
|
"cpp", "5",
|
|
"go", "15",
|
|
"py", "25",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int};
|
|
Stage s;
|
|
s.breakouts.push_back("lang");
|
|
s.aggregations.push_back({AggFn::Count});
|
|
s.aggregations.push_back({AggFn::Avg, "n"});
|
|
s.aggregations.push_back({AggFn::Sum, "n"});
|
|
s.sorts.push_back({"count", true});
|
|
auto out = compute_stage(cells_t, 6, 2, hdrs, tps, s);
|
|
check(out.cols == 4, "tql group: cols = breakouts + aggs");
|
|
check(out.rows == 3, "tql group: 3 grupos (go/py/cpp)");
|
|
// headers
|
|
check(out.headers[0] == "lang" && out.headers[1] == "count" &&
|
|
out.headers[2] == "avg_n" && out.headers[3] == "sum_n",
|
|
"tql group: headers correctos");
|
|
// sort desc por count -> go (3) primero, py (2) segundo, cpp (1) ultimo
|
|
check(std::string(out.cells[0*4+0]) == "go" &&
|
|
std::string(out.cells[0*4+1]) == "3",
|
|
"tql group row0: lang=go count=3");
|
|
check(std::string(out.cells[1*4+0]) == "py" &&
|
|
std::string(out.cells[1*4+1]) == "2",
|
|
"tql group row1: lang=py count=2");
|
|
// avg de go: (10+30+15)/3 = 18.33 (formatear como %.4g = "18.33")
|
|
// sum de go: 55
|
|
check(std::string(out.cells[0*4+2]).find("18.33") != std::string::npos,
|
|
"tql group: avg_n go ~ 18.33");
|
|
check(std::string(out.cells[0*4+3]) == "55", "tql group: sum_n go = 55");
|
|
}
|
|
|
|
// --- TQL: compute_stage 2 breakouts + multiple aggs ---
|
|
{
|
|
const char* cells_t[] = {
|
|
"go", "core", "10",
|
|
"go", "infra", "20",
|
|
"py", "core", "30",
|
|
"go", "core", "40",
|
|
"py", "infra", "50",
|
|
"py", "core", "60",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "domain", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::String, ColumnType::Int};
|
|
Stage s;
|
|
s.breakouts.push_back("lang");
|
|
s.breakouts.push_back("domain");
|
|
s.aggregations.push_back({AggFn::Count});
|
|
s.aggregations.push_back({AggFn::Min, "n"});
|
|
s.aggregations.push_back({AggFn::Max, "n"});
|
|
auto out = compute_stage(cells_t, 6, 3, hdrs, tps, s);
|
|
check(out.rows == 4, "tql 2 breakouts: 4 grupos (go/core, go/infra, py/core, py/infra)");
|
|
check(out.cols == 5, "tql 2 breakouts: 5 cols");
|
|
}
|
|
|
|
// --- TQL: percentile + median + stddev ---
|
|
{
|
|
const char* cells_t[] = {
|
|
"a", "1",
|
|
"a", "2",
|
|
"a", "3",
|
|
"a", "4",
|
|
"a", "5",
|
|
"a", "6",
|
|
"a", "7",
|
|
"a", "8",
|
|
"a", "9",
|
|
};
|
|
std::vector<std::string> hdrs = {"k", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int};
|
|
Stage s;
|
|
s.breakouts.push_back("k");
|
|
s.aggregations.push_back({AggFn::Median, "n"});
|
|
s.aggregations.push_back({AggFn::P25, "n"});
|
|
s.aggregations.push_back({AggFn::P75, "n"});
|
|
Aggregation p90; p90.fn = AggFn::P90; p90.col = "n";
|
|
s.aggregations.push_back(p90);
|
|
Aggregation pct; pct.fn = AggFn::Percentile; pct.col = "n"; pct.arg = 0.5;
|
|
s.aggregations.push_back(pct);
|
|
s.aggregations.push_back({AggFn::Stddev, "n"});
|
|
auto out = compute_stage(cells_t, 9, 2, hdrs, tps, s);
|
|
check(out.rows == 1, "tql percentiles: 1 grupo");
|
|
// headers: k, median_n, p25_n, p75_n, p90_n, p50_n, stddev_n
|
|
check(out.headers[1] == "median_n", "tql median alias");
|
|
check(out.headers[2] == "p25_n", "tql p25 alias");
|
|
check(out.headers[4] == "p90_n", "tql p90 alias");
|
|
check(out.headers[5] == "p50_n", "tql percentile generico -> p50_n");
|
|
check(out.headers[6] == "stddev_n", "tql stddev alias");
|
|
// median = 5
|
|
check(std::string(out.cells[1]) == "5", "tql median(1..9) = 5");
|
|
// p25 = 3, p75 = 7
|
|
check(std::string(out.cells[2]) == "3", "tql p25(1..9) = 3");
|
|
check(std::string(out.cells[3]) == "7", "tql p75(1..9) = 7");
|
|
}
|
|
|
|
// --- TQL: distinct counts ---
|
|
{
|
|
const char* cells_t[] = {
|
|
"go", "filter",
|
|
"go", "map",
|
|
"go", "filter",
|
|
"py", "sma",
|
|
"py", "sma",
|
|
"py", "ema",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "name"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::String};
|
|
Stage s;
|
|
s.breakouts.push_back("lang");
|
|
s.aggregations.push_back({AggFn::Distinct, "name"});
|
|
auto out = compute_stage(cells_t, 6, 2, hdrs, tps, s);
|
|
check(out.rows == 2, "tql distinct: 2 grupos");
|
|
// go: distinct {filter, map} = 2
|
|
// py: distinct {sma, ema} = 2
|
|
for (int r = 0; r < 2; ++r) {
|
|
check(std::string(out.cells[r * 2 + 1]) == "2",
|
|
"tql distinct cuenta unicos");
|
|
}
|
|
}
|
|
|
|
// --- TQL: stage chain (output of stage 0 feeds stage 1) ---
|
|
{
|
|
// Stage 0: filter lang=go -> passthrough.
|
|
// Stage 1: group by domain, count + avg n.
|
|
const char* cells_t[] = {
|
|
"go", "core", "10",
|
|
"go", "infra", "20",
|
|
"py", "core", "30",
|
|
"go", "core", "40",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "domain", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::String, ColumnType::Int};
|
|
Stage s0;
|
|
s0.filters.push_back({0, Op::Eq, "go"});
|
|
auto out0 = compute_stage(cells_t, 4, 3, hdrs, tps, s0);
|
|
check(out0.rows == 3, "tql chain stage0: filtra a 3 filas");
|
|
|
|
Stage s1;
|
|
s1.breakouts.push_back("domain");
|
|
s1.aggregations.push_back({AggFn::Count});
|
|
s1.aggregations.push_back({AggFn::Avg, "n"});
|
|
auto out1 = compute_stage(out0.cells.data(), out0.rows, out0.cols,
|
|
out0.headers, out0.types, s1);
|
|
check(out1.rows == 2, "tql chain stage1: 2 grupos (core/infra)");
|
|
check(out1.headers[0] == "domain" && out1.headers[1] == "count" &&
|
|
out1.headers[2] == "avg_n",
|
|
"tql chain stage1: headers");
|
|
}
|
|
|
|
// --- TQL emit ---
|
|
{
|
|
State st;
|
|
std::vector<std::string> hdrs = {"lang", "n", "name"};
|
|
// Empty state -> minimal
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int, ColumnType::String};
|
|
std::string out = tql::emit(st, hdrs, tps);
|
|
check(out.find("stages") != std::string::npos, "tql emit: contiene stages");
|
|
|
|
// Con filters + sort
|
|
st.raw().filters.push_back({0, Op::Eq, "go"});
|
|
st.raw().filters.push_back({1, Op::Gte, "10"});
|
|
set_sort_idx(st, 1, false);
|
|
set_sort_desc(st, true);
|
|
out = tql::emit(st, hdrs, tps);
|
|
check(out.find("filter") != std::string::npos, "tql emit: incluye filter");
|
|
check(out.find("\"=\"") != std::string::npos, "tql emit: op =");
|
|
check(out.find("\"lang\"") != std::string::npos, "tql emit: col lang");
|
|
check(out.find("\"go\"") != std::string::npos, "tql emit: value go");
|
|
check(out.find("\">=\"") != std::string::npos, "tql emit: op >=");
|
|
check(out.find("sort") != std::string::npos, "tql emit: incluye sort");
|
|
check(out.find("\"desc\"") != std::string::npos, "tql emit: sort dir desc");
|
|
}
|
|
|
|
// --- TQL apply ---
|
|
{
|
|
State st;
|
|
std::vector<std::string> hdrs = {"lang", "n", "name"};
|
|
const char* cells_t[] = {
|
|
"go", "10", "filter",
|
|
"py", "20", "sma",
|
|
"go", "30", "map",
|
|
};
|
|
std::string text = R"LUA(
|
|
return {
|
|
stages = {
|
|
{
|
|
filter = {
|
|
{"=", "lang", "go"},
|
|
{">=", "n", "10"},
|
|
},
|
|
sort = { {"desc", "n"} },
|
|
}
|
|
}
|
|
})LUA";
|
|
std::string err;
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{}, cells_t, 3, 3, &err);
|
|
check(ok, "tql apply: parsea OK");
|
|
check(st.raw().filters.size() == 2, "tql apply: 2 filters");
|
|
check(st.raw().filters[0].col == 0 && st.raw().filters[0].op == Op::Eq &&
|
|
st.raw().filters[0].value == "go", "tql apply: filter 0 = lang=go");
|
|
check(st.raw().filters[1].col == 1 && st.raw().filters[1].op == Op::Gte &&
|
|
st.raw().filters[1].value == "10", "tql apply: filter 1 = n>=10");
|
|
// sort se almacena por nombre en el nuevo modelo (no por indice).
|
|
check(st.raw().sorts.size() == 1 && st.raw().sorts[0].col == "n" &&
|
|
st.raw().sorts[0].desc == true,
|
|
"tql apply: sort desc por n (by name)");
|
|
}
|
|
|
|
// --- TQL apply error: invalid Lua ---
|
|
{
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
bool ok = tql::apply("return {{{ not valid lua", st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(!ok && !err.empty(), "tql apply: lua invalido -> false + err");
|
|
}
|
|
|
|
// --- TQL apply error: root no es tabla ---
|
|
{
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
bool ok = tql::apply("return 42", st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(!ok && err.find("table") != std::string::npos,
|
|
"tql apply: root no-tabla -> error");
|
|
}
|
|
|
|
// --- TQL round-trip: emit -> apply -> compare ---
|
|
{
|
|
State st0;
|
|
std::vector<std::string> hdrs = {"lang", "n"};
|
|
st0.raw().filters.push_back({0, Op::Contains, "g"});
|
|
st0.raw().filters.push_back({1, Op::Lt, "100"});
|
|
set_sort_idx(st0, 0, false);
|
|
set_sort_desc(st0, false);
|
|
|
|
std::vector<ColumnType> tps_rt = {ColumnType::String, ColumnType::Int};
|
|
std::string text = tql::emit(st0, hdrs, tps_rt);
|
|
|
|
State st1;
|
|
const char* cells_t[] = {"go","1","py","2"};
|
|
std::string err;
|
|
bool ok = tql::apply(text, st1, hdrs, tps_rt, cells_t, 2, 2, &err);
|
|
check(ok, "tql round-trip: apply OK");
|
|
check(st1.raw().filters.size() == 2, "tql round-trip: 2 filters preservados");
|
|
check(st1.raw().filters[0].col == 0 && st1.raw().filters[0].op == Op::Contains &&
|
|
st1.raw().filters[0].value == "g",
|
|
"tql round-trip: contains preservado");
|
|
check(st1.raw().filters[1].op == Op::Lt && st1.raw().filters[1].value == "100",
|
|
"tql round-trip: < preservado");
|
|
// En el round-trip el sort se preserva por nombre. El helper
|
|
// set_sort_idx emite con sintaxis "@N" que el round-trip respeta.
|
|
check(st1.raw().sorts.size() == 1 && st1.raw().sorts[0].desc == false,
|
|
"tql round-trip: sort asc preservado");
|
|
}
|
|
|
|
// --- TQL apply: expressions compila + auto-detect tipo ---
|
|
{
|
|
State st;
|
|
std::vector<std::string> hdrs = {"size_kb", "name"};
|
|
const char* cells_t[] = {
|
|
"1.5", "alpha",
|
|
"2.0", "beta",
|
|
"3.5", "gamma",
|
|
};
|
|
std::string text = R"LUA(
|
|
return {
|
|
stages = {
|
|
{
|
|
expressions = {
|
|
size_bytes = "[size_kb] * 1024",
|
|
double_size = "[size_kb] * 2",
|
|
},
|
|
}
|
|
}
|
|
})LUA";
|
|
std::string err;
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{}, cells_t, 3, 2, &err);
|
|
check(ok, "tql apply expressions: OK");
|
|
check(st.raw().derived.size() == 2, "tql apply: 2 derived cols");
|
|
// Verifica que tienen lua_id valido y formula
|
|
for (const auto& d : st.raw().derived) {
|
|
check(d.lua_id >= 0 && !d.formula.empty(),
|
|
"tql apply: derived compiled OK");
|
|
}
|
|
}
|
|
|
|
// --- TQL columns + color round-trip ---
|
|
{
|
|
check(tql::color_to_hex(0xFFFF0000) == "#0000ff", "tql color: blue 0xFFFF0000 -> #0000ff");
|
|
check(tql::color_to_hex(0x80808080) == "#80808080", "tql color: con alpha");
|
|
check(tql::hex_to_color("#0000ff") == 0xFFFF0000, "tql hex: #0000ff -> blue full alpha");
|
|
check(tql::hex_to_color("#80808080") == 0x80808080, "tql hex: roundtrip con alpha");
|
|
check(tql::column_type_from_string("int") == ColumnType::Int, "tql ctype: int");
|
|
check(tql::column_type_from_string("bool") == ColumnType::Bool, "tql ctype: bool");
|
|
check(tql::column_type_from_string("date") == ColumnType::Date, "tql ctype: date");
|
|
check(tql::column_type_from_string("zzz") == ColumnType::Auto, "tql ctype: unknown -> auto");
|
|
}
|
|
{
|
|
// Emit columns con visibilidad + color rules
|
|
State st;
|
|
std::vector<std::string> hdrs = {"lang", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int};
|
|
st.col_visible = {true, false};
|
|
st.col_order = {1, 0};
|
|
st.color_rules.push_back({0, "go", 0xFF6BB586});
|
|
|
|
std::string out = tql::emit(st, hdrs, tps);
|
|
check(out.find("columns") != std::string::npos, "tql emit: include columns");
|
|
check(out.find("visible = false") != std::string::npos, "tql emit: visible=false");
|
|
check(out.find("visible = true") != std::string::npos, "tql emit: visible=true");
|
|
check(out.find("color_rules") != std::string::npos, "tql emit: include color_rules");
|
|
check(out.find("display = \"table\"") != std::string::npos, "tql emit: display table");
|
|
check(out.find("visualization_settings") != std::string::npos, "tql emit: viz settings");
|
|
}
|
|
{
|
|
// Round-trip columns: emit -> apply -> compare visibility/order/color_rules
|
|
State st0;
|
|
std::vector<std::string> hdrs = {"lang", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int};
|
|
st0.col_visible = {true, false};
|
|
st0.col_order = {1, 0};
|
|
st0.color_rules.push_back({0, "py", 0xFFB5866B});
|
|
|
|
std::string text = tql::emit(st0, hdrs, tps);
|
|
|
|
State st1;
|
|
const char* cells_t[] = {"go","1","py","2"};
|
|
std::string err;
|
|
bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 2, 2, &err);
|
|
check(ok, "tql round-trip columns: apply OK");
|
|
check(st1.col_visible.size() == 2 && !st1.col_visible[1],
|
|
"tql round-trip: visible[1] = false preservado");
|
|
check(st1.col_order.size() == 2 && st1.col_order[0] == 1 && st1.col_order[1] == 0,
|
|
"tql round-trip: col_order [1,0] preservado");
|
|
check(st1.color_rules.size() == 1 &&
|
|
st1.color_rules[0].col == 0 &&
|
|
st1.color_rules[0].equals == "py" &&
|
|
st1.color_rules[0].color == 0xFFB5866B,
|
|
"tql round-trip: color_rule preservado");
|
|
}
|
|
{
|
|
// Apply con expression + columns: type del derived va via columns.type
|
|
State st;
|
|
std::vector<std::string> hdrs = {"size_kb"};
|
|
std::vector<ColumnType> tps = {ColumnType::Float};
|
|
const char* cells_t[] = {"1.5", "2.0", "3.5"};
|
|
std::string text = R"LUA(
|
|
return {
|
|
stages = {
|
|
{
|
|
expressions = { size_bytes = "[size_kb] * 1024" }
|
|
}
|
|
},
|
|
columns = {
|
|
{name = "size_kb", type = "float", visible = true, order = 1},
|
|
{name = "size_bytes", type = "int", visible = true, order = 2,
|
|
color_rules = {{equals = "1536", color = "#86b56b"}}},
|
|
}
|
|
})LUA";
|
|
std::string err;
|
|
bool ok = tql::apply(text, st, hdrs, tps, cells_t, 3, 1, &err);
|
|
check(ok, "tql apply: stages + columns combo");
|
|
check(st.raw().derived.size() == 1, "tql apply: derived col size_bytes creada");
|
|
// type override de auto-detect: columns dice "int", aunque auto-detect daria Float
|
|
check(st.raw().derived[0].type == ColumnType::Int,
|
|
"tql apply: columns.type sobrescribe auto-detect derived");
|
|
// color_rule sobre derived col (idx orig_cols+0 = 1)
|
|
check(st.color_rules.size() == 1 &&
|
|
st.color_rules[0].col == 1 &&
|
|
st.color_rules[0].equals == "1536",
|
|
"tql apply: color_rule sobre derived col");
|
|
// col_order = [size_kb=0, size_bytes=1]
|
|
check(st.col_order.size() == 2 && st.col_order[0] == 0 && st.col_order[1] == 1,
|
|
"tql apply: col_order desde columns.order");
|
|
}
|
|
|
|
// --- lua_string_literal ---
|
|
{
|
|
check(tql::lua_string_literal("simple") == "\"simple\"", "tql literal: simple");
|
|
check(tql::lua_string_literal("a\"b") == "\"a\\\"b\"", "tql literal: quote escape");
|
|
check(tql::lua_string_literal("a\\b") == "\"a\\\\b\"", "tql literal: backslash escape");
|
|
check(tql::lua_string_literal("a\nb") == "\"a\\nb\"", "tql literal: newline escape");
|
|
}
|
|
|
|
// --- Phase 3.1: derived eval sobre stage output ---
|
|
{
|
|
const char* cells_t[] = {
|
|
"go", "core", "10",
|
|
"go", "viz", "20",
|
|
"py", "core", "30",
|
|
"go", "core", "40",
|
|
"py", "viz", "50",
|
|
"py", "core", "60",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "domain", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::String, ColumnType::Int};
|
|
Stage s1;
|
|
s1.breakouts.push_back("lang");
|
|
s1.aggregations.push_back({AggFn::Count});
|
|
s1.aggregations.push_back({AggFn::Sum, "n"});
|
|
auto out1 = compute_stage(cells_t, 6, 3, hdrs, tps, s1);
|
|
|
|
auto* eng = lua_engine::get();
|
|
std::string err;
|
|
int id = lua_engine::compile(eng, "[count] * [sum_n]", &err);
|
|
check(id >= 0, "phase3.1: compile derived sobre stage output");
|
|
std::vector<std::string> out_hn = out1.headers;
|
|
std::unordered_map<std::string, int> n2c;
|
|
for (size_t i = 0; i < out_hn.size(); ++i) n2c[out_hn[i]] = (int)i;
|
|
std::vector<std::string> results;
|
|
for (int r = 0; r < out1.rows; ++r) {
|
|
lua_engine::RowCtx ctx;
|
|
ctx.cells = out1.cells.data();
|
|
ctx.orig_cols = out1.cols;
|
|
ctx.row = r;
|
|
ctx.header_names = &out_hn;
|
|
ctx.name_to_col = &n2c;
|
|
ctx.types_orig = out1.types.data();
|
|
ctx.n_types_orig = out1.cols;
|
|
std::string e;
|
|
results.push_back(lua_engine::eval(eng, id, ctx, &e));
|
|
}
|
|
int go_idx = -1, py_idx = -1;
|
|
for (int r = 0; r < out1.rows; ++r) {
|
|
const char* lang = out1.cells[r * out1.cols + 0];
|
|
if (std::strcmp(lang, "go") == 0) go_idx = r;
|
|
if (std::strcmp(lang, "py") == 0) py_idx = r;
|
|
}
|
|
check(go_idx >= 0 && py_idx >= 0, "phase3.1: encontrar grupos go y py");
|
|
check(results[go_idx] == "210", "phase3.1: go count*sum_n = 210");
|
|
check(results[py_idx] == "420", "phase3.1: py count*sum_n = 420");
|
|
lua_engine::release(eng, id);
|
|
}
|
|
{
|
|
// Recursividad: derived B sobre stage output referencia derived A.
|
|
const char* cells_t[] = {
|
|
"go", "x",
|
|
"go", "y",
|
|
"py", "z",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "name"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::String};
|
|
Stage s1;
|
|
s1.breakouts.push_back("lang");
|
|
s1.aggregations.push_back({AggFn::Count});
|
|
auto out1 = compute_stage(cells_t, 3, 2, hdrs, tps, s1);
|
|
|
|
auto* eng = lua_engine::get();
|
|
std::string err;
|
|
int idA = lua_engine::compile(eng, "[count] + 100", &err);
|
|
check(idA >= 0, "phase3.1: compile derived A sobre stage output");
|
|
std::vector<std::string> out_hn = out1.headers;
|
|
std::unordered_map<std::string, int> n2c;
|
|
for (size_t i = 0; i < out_hn.size(); ++i) n2c[out_hn[i]] = (int)i;
|
|
std::vector<DerivedColumn> der;
|
|
der.push_back({-1, ColumnType::Int, "A", "[count] + 100", idA, ""});
|
|
std::unordered_map<std::string, int> dn2i;
|
|
dn2i["A"] = 0;
|
|
|
|
int idB = lua_engine::compile(eng, "[A] * 2", &err);
|
|
check(idB >= 0, "phase3.1: compile derived B refs A");
|
|
|
|
std::vector<std::string> resB;
|
|
for (int r = 0; r < out1.rows; ++r) {
|
|
lua_engine::RowCtx ctx;
|
|
ctx.cells = out1.cells.data();
|
|
ctx.orig_cols = out1.cols;
|
|
ctx.row = r;
|
|
ctx.header_names = &out_hn;
|
|
ctx.name_to_col = &n2c;
|
|
ctx.types_orig = out1.types.data();
|
|
ctx.n_types_orig = out1.cols;
|
|
ctx.derived = &der;
|
|
ctx.derived_name_to_idx = &dn2i;
|
|
std::string e;
|
|
resB.push_back(lua_engine::eval(eng, idB, ctx, &e));
|
|
}
|
|
int go_idx = -1, py_idx = -1;
|
|
for (int r = 0; r < out1.rows; ++r) {
|
|
const char* lang = out1.cells[r * out1.cols + 0];
|
|
if (std::strcmp(lang, "go") == 0) go_idx = r;
|
|
if (std::strcmp(lang, "py") == 0) py_idx = r;
|
|
}
|
|
check(resB[go_idx] == "204", "phase3.1: derived B chain (count+100)*2 = 204 go");
|
|
check(resB[py_idx] == "202", "phase3.1: derived B chain (count+100)*2 = 202 py");
|
|
lua_engine::release(eng, idA);
|
|
lua_engine::release(eng, idB);
|
|
}
|
|
|
|
// --- column_type_name + icon no nulos ---
|
|
{
|
|
const ColumnType all[] = { ColumnType::Auto, ColumnType::String, ColumnType::Int,
|
|
ColumnType::Float, ColumnType::Bool, ColumnType::Date,
|
|
ColumnType::Json };
|
|
for (auto t : all) {
|
|
check(column_type_name(t) != nullptr, "column_type_name no null");
|
|
check(column_type_icon(t) != nullptr, "column_type_icon no null");
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Phase 3: stages vector, multi-stage TQL emit/apply, drill-down.
|
|
// ----------------------------------------------------------------
|
|
|
|
// --- State::ensure_stage0 crea stage 0 si vacio ---
|
|
{
|
|
State st;
|
|
check(st.stages.empty(), "phase3 state: stages vacio inicial");
|
|
st.ensure_stage0();
|
|
check(st.stages.size() == 1, "phase3 state: ensure_stage0 crea uno");
|
|
check(st.active_stage == 0, "phase3 state: active_stage default 0");
|
|
}
|
|
|
|
// --- raw() y active() devuelven la misma stage cuando active=0 ---
|
|
{
|
|
State st;
|
|
Stage& r = st.raw();
|
|
r.filters.push_back({0, Op::Eq, "x"});
|
|
check(st.active().filters.size() == 1, "phase3 state: active==raw cuando active=0");
|
|
check(st.stages[0].filters.size() == 1, "phase3 state: stages[0] visible via raw()");
|
|
}
|
|
|
|
// --- make_drill_filter helper ---
|
|
{
|
|
Filter f = make_drill_filter(2, "go");
|
|
check(f.col == 2 && f.op == Op::Eq && f.value == "go",
|
|
"phase3 drill: make_drill_filter retorna Op::Eq");
|
|
}
|
|
|
|
// --- Multi-stage TQL emit: state con stage 0 + stage 1 ---
|
|
{
|
|
State st;
|
|
st.ensure_stage0();
|
|
st.stages[0].filters.push_back({0, Op::Eq, "go"});
|
|
Stage s1;
|
|
s1.breakouts.push_back("domain");
|
|
s1.aggregations.push_back({AggFn::Count});
|
|
s1.aggregations.push_back({AggFn::Avg, "n"});
|
|
s1.sorts.push_back({"count", true});
|
|
st.stages.push_back(std::move(s1));
|
|
|
|
std::vector<std::string> hdrs = {"lang", "domain", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::String, ColumnType::Int};
|
|
std::string out = tql::emit(st, hdrs, tps);
|
|
|
|
check(out.find("breakout") != std::string::npos, "phase3 emit: contiene breakout");
|
|
check(out.find("\"domain\"") != std::string::npos, "phase3 emit: col domain en breakout");
|
|
check(out.find("aggregation") != std::string::npos, "phase3 emit: contiene aggregation");
|
|
check(out.find("\"count\"") != std::string::npos, "phase3 emit: agg count");
|
|
check(out.find("\"avg\"") != std::string::npos, "phase3 emit: agg avg");
|
|
// 2 stages
|
|
size_t first = out.find(" {");
|
|
size_t second = out.find(" {", first + 1);
|
|
check(first != std::string::npos && second != std::string::npos,
|
|
"phase3 emit: dos stage entries");
|
|
}
|
|
|
|
// --- Multi-stage TQL apply: stages chain ---
|
|
{
|
|
State st;
|
|
std::vector<std::string> hdrs = {"lang", "domain", "n"};
|
|
const char* cells_t[] = {
|
|
"go", "core", "10",
|
|
"go", "infra", "20",
|
|
"py", "core", "30",
|
|
"go", "core", "40",
|
|
};
|
|
std::string text = R"LUA(
|
|
return {
|
|
stages = {
|
|
{ filter = { {"=", "lang", "go"} } },
|
|
{
|
|
breakout = {"domain"},
|
|
aggregation = { {"count"}, {"avg", "n"} },
|
|
sort = { {"desc", "count"} },
|
|
},
|
|
}
|
|
})LUA";
|
|
std::string err;
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{}, cells_t, 4, 3, &err);
|
|
check(ok, "phase3 apply: parsea multi-stage");
|
|
check(st.stages.size() == 2, "phase3 apply: 2 stages creados");
|
|
check(st.stages[0].filters.size() == 1 &&
|
|
st.stages[0].filters[0].col == 0 &&
|
|
st.stages[0].filters[0].value == "go",
|
|
"phase3 apply: stage 0 filter lang=go");
|
|
check(st.stages[1].breakouts.size() == 1 &&
|
|
st.stages[1].breakouts[0] == "domain",
|
|
"phase3 apply: stage 1 breakout=domain");
|
|
check(st.stages[1].aggregations.size() == 2,
|
|
"phase3 apply: stage 1 tiene 2 aggregations");
|
|
check(st.stages[1].aggregations[0].fn == AggFn::Count &&
|
|
st.stages[1].aggregations[1].fn == AggFn::Avg &&
|
|
st.stages[1].aggregations[1].col == "n",
|
|
"phase3 apply: aggregations [count, avg(n)]");
|
|
check(st.stages[1].sorts.size() == 1 &&
|
|
st.stages[1].sorts[0].col == "count" &&
|
|
st.stages[1].sorts[0].desc == true,
|
|
"phase3 apply: stage 1 sort desc count");
|
|
}
|
|
|
|
// --- Chain execution: stage 0 feeds stage 1 (verifica compute_stage cadena) ---
|
|
{
|
|
std::vector<std::string> hdrs = {"lang", "domain", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::String, ColumnType::Int};
|
|
const char* cells_t[] = {
|
|
"go", "core", "10",
|
|
"go", "infra", "20",
|
|
"py", "core", "30",
|
|
"go", "core", "40",
|
|
};
|
|
Stage s0;
|
|
s0.filters.push_back({0, Op::Eq, "go"}); // lang=go -> 3 rows
|
|
auto out0 = compute_stage(cells_t, 4, 3, hdrs, tps, s0);
|
|
check(out0.rows == 3, "phase3 chain: stage 0 produce 3 filas");
|
|
|
|
// Stage 1 sobre out0
|
|
Stage s1;
|
|
s1.breakouts.push_back("domain");
|
|
s1.aggregations.push_back({AggFn::Count});
|
|
auto out1 = compute_stage(out0.cells.data(), out0.rows, out0.cols,
|
|
out0.headers, out0.types, s1);
|
|
check(out1.rows == 2, "phase3 chain: stage 1 produce 2 grupos (core,infra)");
|
|
check(out1.cols == 2, "phase3 chain: stage 1 cols = breakout+count");
|
|
check(out1.headers[0] == "domain" && out1.headers[1] == "count",
|
|
"phase3 chain: stage 1 headers");
|
|
}
|
|
|
|
// --- Round-trip multi-stage emit -> apply -> compare ---
|
|
{
|
|
State st0;
|
|
st0.ensure_stage0();
|
|
Stage s1;
|
|
s1.breakouts.push_back("lang");
|
|
s1.aggregations.push_back({AggFn::Count});
|
|
s1.aggregations.push_back({AggFn::Sum, "n"});
|
|
st0.stages.push_back(std::move(s1));
|
|
|
|
std::vector<std::string> hdrs = {"lang", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int};
|
|
std::string text = tql::emit(st0, hdrs, tps);
|
|
|
|
State st1;
|
|
const char* cells_t[] = {"go","10","py","20"};
|
|
std::string err;
|
|
bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 2, 2, &err);
|
|
check(ok, "phase3 round-trip: apply OK");
|
|
check(st1.stages.size() == 2, "phase3 round-trip: 2 stages preservados");
|
|
check(st1.stages[1].breakouts.size() == 1 &&
|
|
st1.stages[1].breakouts[0] == "lang",
|
|
"phase3 round-trip: breakout preservado");
|
|
check(st1.stages[1].aggregations.size() == 2,
|
|
"phase3 round-trip: 2 aggregations preservadas");
|
|
check(st1.stages[1].aggregations[1].fn == AggFn::Sum &&
|
|
st1.stages[1].aggregations[1].col == "n",
|
|
"phase3 round-trip: sum(n) preservado");
|
|
}
|
|
|
|
// --- Emit con percentile: incluye arg ---
|
|
{
|
|
State st;
|
|
st.ensure_stage0();
|
|
Stage s1;
|
|
s1.breakouts.push_back("k");
|
|
Aggregation pct; pct.fn = AggFn::Percentile; pct.col = "n"; pct.arg = 0.95;
|
|
s1.aggregations.push_back(pct);
|
|
st.stages.push_back(std::move(s1));
|
|
|
|
std::vector<std::string> hdrs = {"k", "n"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int};
|
|
std::string out = tql::emit(st, hdrs, tps);
|
|
check(out.find("\"percentile\"") != std::string::npos,
|
|
"phase3 emit percentile: fn token");
|
|
check(out.find("0.95") != std::string::npos,
|
|
"phase3 emit percentile: arg 0.95");
|
|
}
|
|
|
|
// --- Drill-down logica: anadir Filter al stage previo ---
|
|
{
|
|
// Setup: state con 2 stages. Stage 1 groups by lang. Drill on lang=go
|
|
// anade Filter{lang=go} a stage 0 y active=0.
|
|
State st;
|
|
st.ensure_stage0();
|
|
Stage s1;
|
|
s1.breakouts.push_back("lang");
|
|
s1.aggregations.push_back({AggFn::Count});
|
|
st.stages.push_back(std::move(s1));
|
|
st.active_stage = 1;
|
|
|
|
// Simular drill: agregar make_drill_filter(0, "go") a stage 0.
|
|
st.stages[0].filters.push_back(make_drill_filter(0, "go"));
|
|
st.active_stage = 0;
|
|
|
|
check(st.stages[0].filters.size() == 1,
|
|
"phase3 drill: filter anadido a stage 0");
|
|
check(st.stages[0].filters[0].op == Op::Eq &&
|
|
st.stages[0].filters[0].value == "go",
|
|
"phase3 drill: filter Op::Eq value=go");
|
|
check(st.stages.size() == 2,
|
|
"phase3 drill: stage 1 NO se borra (preserva camino)");
|
|
check(st.active_stage == 0,
|
|
"phase3 drill: active vuelve a stage 0");
|
|
}
|
|
|
|
// === phase5: TQL validacion schema ===
|
|
{
|
|
// version missing -> ok con warning
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
bool ok = tql::apply("return { display=\"table\", stages={}, columns={} }",
|
|
st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(ok, "phase5: version missing acepta");
|
|
check(err.find("version missing") != std::string::npos,
|
|
"phase5: warning version missing presente");
|
|
}
|
|
{
|
|
// version != 1 -> fail
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
bool ok = tql::apply("return { version=999, stages={}, columns={} }",
|
|
st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(!ok, "phase5: version != 1 rechaza");
|
|
check(err.find("unsupported") != std::string::npos,
|
|
"phase5: error de version explicito");
|
|
}
|
|
{
|
|
// version no-numero -> fail
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
bool ok = tql::apply("return { version=\"x\", stages={}, columns={} }",
|
|
st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(!ok, "phase5: version no-number rechaza");
|
|
}
|
|
{
|
|
// unknown filter col -> warning
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a", "b"};
|
|
const char* cells_t[] = {"x","1"};
|
|
std::string err;
|
|
std::string text =
|
|
"return { version=1, stages={ { filter={{\"=\", \"missing\", \"v\"}} } }, columns={} }";
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{},
|
|
cells_t, 1, 2, &err);
|
|
check(ok, "phase5: filter col desconocido NO bloquea");
|
|
check(err.find("filter col") != std::string::npos && err.find("missing") != std::string::npos,
|
|
"phase5: warning filter col desconocido");
|
|
}
|
|
{
|
|
// unknown agg fn -> warning
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a", "b"};
|
|
const char* cells_t[] = {"x","1"};
|
|
std::string err;
|
|
std::string text =
|
|
"return { version=1, stages={ {}, "
|
|
"{ breakout={\"a\"}, aggregation={ {\"weirdfn\", \"b\"} } } }, columns={} }";
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{},
|
|
cells_t, 1, 2, &err);
|
|
check(ok, "phase5: agg fn desconocida NO bloquea");
|
|
check(err.find("aggregation fn") != std::string::npos,
|
|
"phase5: warning agg fn desconocida");
|
|
}
|
|
{
|
|
// agg sin col cuando la requiere -> warning
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a", "b"};
|
|
std::string err;
|
|
std::string text =
|
|
"return { version=1, stages={ {}, "
|
|
"{ breakout={\"a\"}, aggregation={ {\"sum\"} } } }, columns={} }";
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 2, &err);
|
|
check(ok, "phase5: agg sum sin col NO bloquea");
|
|
check(err.find("requires a column") != std::string::npos,
|
|
"phase5: warning agg sin col");
|
|
}
|
|
{
|
|
// unknown sort dir -> warning
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
std::string text =
|
|
"return { version=1, stages={ { sort={ {\"sideways\", \"a\"} } } }, columns={} }";
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(ok, "phase5: sort dir desconocida NO bloquea");
|
|
check(err.find("sort dir") != std::string::npos,
|
|
"phase5: warning sort dir desconocida");
|
|
}
|
|
{
|
|
// unknown filter op -> warning
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
std::string text =
|
|
"return { version=1, stages={ { filter={ {\"~~\", \"a\", \"v\"} } } }, columns={} }";
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(ok, "phase5: filter op desconocida NO bloquea");
|
|
check(err.find("filter op") != std::string::npos,
|
|
"phase5: warning filter op desconocida");
|
|
}
|
|
{
|
|
// TQL valido -> err vacio
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a", "b"};
|
|
const char* cells_t[] = {"x","1","y","2"};
|
|
std::string err;
|
|
std::string text =
|
|
"return { version=1, stages={ "
|
|
"{ filter={ {\"=\",\"a\",\"x\"} }, sort={ {\"asc\",\"a\"} } }, "
|
|
"{ breakout={\"a\"}, aggregation={ {\"count\"}, {\"sum\",\"b\"} } } "
|
|
"}, columns={} }";
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{},
|
|
cells_t, 2, 2, &err);
|
|
check(ok && err.empty(), "phase5: TQL valido sin warnings");
|
|
}
|
|
{
|
|
// emit() incluye cheatsheet header
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::vector<ColumnType> tps = {ColumnType::String};
|
|
std::string out = tql::emit(st, hdrs, tps);
|
|
check(out.find("-- TQL v1") != std::string::npos,
|
|
"phase5: emit incluye comentario cheatsheet");
|
|
check(out.find("-- Stage 0 (Raw)") != std::string::npos,
|
|
"phase5: emit incluye explicacion de stages");
|
|
}
|
|
|
|
// === phase6: ViewMode tokens + TQL display round-trip ===
|
|
{
|
|
check(std::string(view_mode_token(ViewMode::Table)) == "table",
|
|
"phase6: token table");
|
|
check(std::string(view_mode_token(ViewMode::Bar)) == "bar",
|
|
"phase6: token bar");
|
|
check(std::string(view_mode_token(ViewMode::Histogram)) == "histogram",
|
|
"phase6: token histogram");
|
|
check(view_mode_from_token("scatter") == ViewMode::Scatter,
|
|
"phase6: from token scatter");
|
|
check(view_mode_from_token("kpi_grid") == ViewMode::KPIGrid,
|
|
"phase6: from token kpi_grid");
|
|
check(view_mode_from_token("nonsense") == ViewMode::Table,
|
|
"phase6: token desconocida -> Table default");
|
|
int n; const ViewMode* arr = all_view_modes(&n);
|
|
check(arr != nullptr && n >= 20, "phase6: all_view_modes >= 20");
|
|
check(view_mode_min_cols(ViewMode::Bubble) == 3,
|
|
"phase6: Bubble requiere 3 cols");
|
|
check(view_mode_needs_category(ViewMode::Pie) == true,
|
|
"phase6: Pie necesita category");
|
|
check(view_mode_needs_numeric(ViewMode::Histogram) == true,
|
|
"phase6: Histogram necesita numeric");
|
|
}
|
|
{
|
|
// emit + apply preservan display
|
|
State st0;
|
|
st0.display = ViewMode::Scatter;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::vector<ColumnType> tps = {ColumnType::Int};
|
|
std::string text = tql::emit(st0, hdrs, tps);
|
|
check(text.find("display = \"scatter\"") != std::string::npos,
|
|
"phase6: emit contiene display=scatter");
|
|
|
|
State st1;
|
|
const char* cells_t[] = {"1","2","3"};
|
|
std::string err;
|
|
bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 3, 1, &err);
|
|
check(ok, "phase6: apply ok");
|
|
check(st1.display == ViewMode::Scatter,
|
|
"phase6: display preservado tras round-trip");
|
|
}
|
|
{
|
|
// display desconocido -> Table default + warning
|
|
State st;
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::string err;
|
|
std::string text =
|
|
"return { version=1, display=\"weird\", stages={}, columns={} }";
|
|
bool ok = tql::apply(text, st, hdrs, std::vector<ColumnType>{}, nullptr, 0, 1, &err);
|
|
check(ok, "phase6: display unknown NO bloquea");
|
|
check(st.display == ViewMode::Table,
|
|
"phase6: display unknown -> Table default");
|
|
check(err.find("unknown display") != std::string::npos,
|
|
"phase6: warning unknown display");
|
|
}
|
|
|
|
// === phase7b: TQL views round-trip ===
|
|
{
|
|
State st0;
|
|
st0.display = ViewMode::Bar;
|
|
st0.viz_config.cat_col = "country";
|
|
st0.viz_config.y_cols = {"sales"};
|
|
st0.viz_config.primary_color = 0xFF00FF00;
|
|
|
|
VizPanel p;
|
|
p.display = ViewMode::Pie;
|
|
p.config.cat_col = "country";
|
|
p.config.y_cols = {"profit"};
|
|
p.config.hist_bins = 0;
|
|
p.config.show_legend = false;
|
|
st0.extra_panels.push_back(p);
|
|
|
|
VizPanel p2;
|
|
p2.display = ViewMode::Histogram;
|
|
p2.config.y_cols = {"sales"};
|
|
p2.config.hist_bins = 20;
|
|
st0.extra_panels.push_back(p2);
|
|
|
|
std::vector<std::string> hdrs = {"country", "sales", "profit"};
|
|
std::vector<ColumnType> tps = {ColumnType::String, ColumnType::Int, ColumnType::Int};
|
|
std::string text = tql::emit(st0, hdrs, tps);
|
|
check(text.find("views = {") != std::string::npos,
|
|
"phase7b: emit contiene bloque views");
|
|
check(text.find("display = \"bar\"") != std::string::npos,
|
|
"phase7b: emit panel 0 display=bar");
|
|
check(text.find("display = \"pie\"") != std::string::npos,
|
|
"phase7b: emit panel 1 display=pie");
|
|
check(text.find("display = \"histogram\"") != std::string::npos,
|
|
"phase7b: emit panel 2 display=histogram");
|
|
|
|
State st1;
|
|
const char* cells_t[] = {"es","100","20","fr","200","30"};
|
|
std::string err;
|
|
bool ok = tql::apply(text, st1, hdrs, tps, cells_t, 2, 3, &err);
|
|
check(ok, "phase7b: apply views ok");
|
|
check(st1.display == ViewMode::Bar, "phase7b: main display preservado");
|
|
check(st1.viz_config.cat_col == "country",
|
|
"phase7b: main cat_col preservado");
|
|
check(st1.extra_panels.size() == 2,
|
|
"phase7b: 2 extra panels preservados");
|
|
if (st1.extra_panels.size() >= 2) {
|
|
check(st1.extra_panels[0].display == ViewMode::Pie,
|
|
"phase7b: extra[0] = pie");
|
|
check(st1.extra_panels[1].display == ViewMode::Histogram,
|
|
"phase7b: extra[1] = histogram");
|
|
check(st1.extra_panels[1].config.hist_bins == 20,
|
|
"phase7b: hist_bins preservado");
|
|
check(st1.extra_panels[0].config.show_legend == false,
|
|
"phase7b: show_legend=false preservado");
|
|
}
|
|
}
|
|
|
|
// === phase9: joins MBQL-style ===
|
|
{
|
|
// Left table: users
|
|
std::vector<std::string> lh = {"id", "name"};
|
|
std::vector<ColumnType> lt = {ColumnType::Int, ColumnType::String};
|
|
const char* lc[] = {"1","alice", "2","bob", "3","carol"};
|
|
|
|
// Right table: orders
|
|
TableInput right;
|
|
right.name = "orders";
|
|
right.headers = {"user_id", "amount"};
|
|
right.types = {ColumnType::Int, ColumnType::Int};
|
|
const char* rc[] = {"1","100", "1","200", "2","50", "4","999"};
|
|
right.cells = rc;
|
|
right.rows = 4;
|
|
right.cols = 2;
|
|
|
|
Join jn;
|
|
jn.alias = "o";
|
|
jn.source = "orders";
|
|
jn.on = {{"id", "user_id"}};
|
|
jn.strategy = JoinStrategy::Inner;
|
|
|
|
auto out = join_tables(lc, 3, 2, lh, lt, right, jn);
|
|
check(out.cols == 4, "phase9 inner: 4 cols");
|
|
check(out.rows == 3, "phase9 inner: 3 matches (1+1+2 minus carol)");
|
|
check(out.headers[2] == "o.user_id",
|
|
"phase9 inner: header prefijado alias.col");
|
|
check(out.headers[3] == "o.amount",
|
|
"phase9 inner: header amount prefijado");
|
|
|
|
// Left join: alice/alice/bob/carol(empty)
|
|
jn.strategy = JoinStrategy::Left;
|
|
auto out_l = join_tables(lc, 3, 2, lh, lt, right, jn);
|
|
check(out_l.rows == 4, "phase9 left: 4 filas (3 matches + carol empty)");
|
|
|
|
// Right join: alice/alice/bob/empty(user 4)
|
|
jn.strategy = JoinStrategy::Right;
|
|
auto out_r = join_tables(lc, 3, 2, lh, lt, right, jn);
|
|
check(out_r.rows == 4, "phase9 right: 4 filas (3 matches + user 4 empty)");
|
|
|
|
// Full join: 3 matches + carol-empty + user4-empty = 5
|
|
jn.strategy = JoinStrategy::Full;
|
|
auto out_f = join_tables(lc, 3, 2, lh, lt, right, jn);
|
|
check(out_f.rows == 5, "phase9 full: 5 filas");
|
|
|
|
// Sin alias -> headers del right sin prefijo (preservar nombre)
|
|
jn.alias = "";
|
|
jn.strategy = JoinStrategy::Inner;
|
|
auto out_nopfx = join_tables(lc, 3, 2, lh, lt, right, jn);
|
|
check(out_nopfx.headers[2] == "user_id",
|
|
"phase9: sin alias headers no prefijados");
|
|
|
|
// Fields filter: solo "amount"
|
|
jn.alias = "o";
|
|
jn.fields = {"amount"};
|
|
auto out_ff = join_tables(lc, 3, 2, lh, lt, right, jn);
|
|
check(out_ff.cols == 3, "phase9: fields filter -> solo 1 col del right");
|
|
check(out_ff.headers[2] == "o.amount",
|
|
"phase9: fields filter respeta alias");
|
|
}
|
|
{
|
|
// Multi-key join
|
|
std::vector<std::string> lh = {"y", "m", "v"};
|
|
std::vector<ColumnType> lt = {ColumnType::Int, ColumnType::Int, ColumnType::Int};
|
|
const char* lc[] = {"2020","1","10", "2020","2","20", "2021","1","30"};
|
|
|
|
TableInput right;
|
|
right.name = "tax";
|
|
right.headers = {"year", "month", "rate"};
|
|
right.types = {ColumnType::Int, ColumnType::Int, ColumnType::Float};
|
|
const char* rc[] = {"2020","1","0.1", "2020","2","0.15", "2021","1","0.2"};
|
|
right.cells = rc; right.rows = 3; right.cols = 3;
|
|
|
|
Join jn;
|
|
jn.alias = "t";
|
|
jn.source = "tax";
|
|
jn.on = {{"y","year"}, {"m","month"}};
|
|
jn.strategy = JoinStrategy::Inner;
|
|
|
|
auto out = join_tables(lc, 3, 3, lh, lt, right, jn);
|
|
check(out.rows == 3, "phase9 multi-key: 3 matches");
|
|
check(out.cols == 6, "phase9 multi-key: 3 left + 3 right");
|
|
}
|
|
{
|
|
// TQL main_source round-trip
|
|
State st0;
|
|
st0.main_source = "users";
|
|
std::vector<std::string> hdrs = {"a"};
|
|
std::vector<ColumnType> tps = {ColumnType::String};
|
|
std::string text = tql::emit(st0, hdrs, tps);
|
|
check(text.find("main_source = \"users\"") != std::string::npos,
|
|
"phase9 TQL: emit main_source");
|
|
State st1;
|
|
std::string err;
|
|
bool ok = tql::apply(text, st1, hdrs, tps, nullptr, 0, 1, &err);
|
|
check(ok, "phase9 TQL: apply main_source ok");
|
|
check(st1.main_source == "users", "phase9 TQL: main_source preservado");
|
|
}
|
|
{
|
|
// TQL emit/apply joins
|
|
State st0;
|
|
Join jn;
|
|
jn.alias = "o"; jn.source = "orders"; jn.strategy = JoinStrategy::Inner;
|
|
jn.on = {{"user_id", "user_id"}, {"region", "region"}};
|
|
jn.fields = {"amount", "tax"};
|
|
st0.joins.push_back(jn);
|
|
|
|
std::vector<std::string> hdrs = {"user_id","region","name"};
|
|
std::vector<ColumnType> tps = {ColumnType::Int, ColumnType::String, ColumnType::String};
|
|
std::string text = tql::emit(st0, hdrs, tps);
|
|
check(text.find("joins = {") != std::string::npos, "phase9 TQL: emit joins block");
|
|
check(text.find("strategy = \"inner\"") != std::string::npos, "phase9 TQL: emit strategy");
|
|
check(text.find("fields = {") != std::string::npos, "phase9 TQL: emit fields");
|
|
|
|
State st1;
|
|
std::string err;
|
|
bool ok = tql::apply(text, st1, hdrs, tps, nullptr, 0, 3, &err);
|
|
check(ok, "phase9 TQL: apply ok");
|
|
check(st1.joins.size() == 1, "phase9 TQL: 1 join preservado");
|
|
if (!st1.joins.empty()) {
|
|
check(st1.joins[0].alias == "o", "phase9 TQL: alias preservado");
|
|
check(st1.joins[0].strategy == JoinStrategy::Inner, "phase9 TQL: strategy preservada");
|
|
check(st1.joins[0].on.size() == 2, "phase9 TQL: multi-key on preservada");
|
|
check(st1.joins[0].fields.size() == 2, "phase9 TQL: fields preservados");
|
|
}
|
|
}
|
|
{
|
|
// resolve_main_idx
|
|
std::vector<TableInput> empty;
|
|
check(resolve_main_idx(empty, "") == -1, "phase9: tables vacio -> -1");
|
|
TableInput a; a.name = "a";
|
|
TableInput b; b.name = "b";
|
|
TableInput c; c.name = "c";
|
|
std::vector<TableInput> t3 = {a, b, c};
|
|
check(resolve_main_idx(t3, "") == 0, "phase9: source vacio -> idx 0");
|
|
check(resolve_main_idx(t3, "b") == 1, "phase9: source match -> idx exacto");
|
|
check(resolve_main_idx(t3, "c") == 2, "phase9: source match c -> 2");
|
|
check(resolve_main_idx(t3, "nope") == 0, "phase9: source desconocido -> idx 0");
|
|
}
|
|
{
|
|
// Strategy tokens round-trip
|
|
check(std::string(join_strategy_token(JoinStrategy::Left)) == "left", "phase9: token left");
|
|
check(std::string(join_strategy_token(JoinStrategy::Inner)) == "inner","phase9: token inner");
|
|
check(join_strategy_from_token("right") == JoinStrategy::Right, "phase9: parse right");
|
|
check(join_strategy_from_token("full") == JoinStrategy::Full, "phase9: parse full");
|
|
check(join_strategy_from_token("nope") == JoinStrategy::Left, "phase9: parse fallback left");
|
|
}
|
|
|
|
// === phase10: drill extendido ===
|
|
{
|
|
// truncate_date — granularities sobre 2026-05-12 (martes).
|
|
std::string d = "2026-05-12";
|
|
check(truncate_date(d, DateGranularity::Year) == "2026", "phase10: trunc year");
|
|
check(truncate_date(d, DateGranularity::Month) == "2026-05", "phase10: trunc month");
|
|
check(truncate_date(d, DateGranularity::Day) == "2026-05-12", "phase10: trunc day");
|
|
check(truncate_date(d, DateGranularity::Week) == "2026-05-11", "phase10: trunc week (Mon)");
|
|
check(truncate_date("2026-05-12T14:33:01", DateGranularity::Hour) == "2026-05-12T14",
|
|
"phase10: trunc hour");
|
|
check(truncate_date("not-a-date", DateGranularity::Month) == "not-a-date",
|
|
"phase10: trunc passthrough invalido");
|
|
check(truncate_date(d, DateGranularity::None) == d, "phase10: trunc None == identidad");
|
|
}
|
|
|
|
{
|
|
// auto_date_granularity
|
|
check(auto_date_granularity("2024-01-01", "2026-05-12") == DateGranularity::Year,
|
|
"phase10: auto year >2y");
|
|
check(auto_date_granularity("2026-01-01", "2026-05-12") == DateGranularity::Month,
|
|
"phase10: auto month >60d");
|
|
check(auto_date_granularity("2026-04-15", "2026-05-12") == DateGranularity::Week,
|
|
"phase10: auto week >14d");
|
|
check(auto_date_granularity("2026-05-05", "2026-05-12") == DateGranularity::Day,
|
|
"phase10: auto day <=14d");
|
|
check(auto_date_granularity("bad", "2026-05-12") == DateGranularity::Day,
|
|
"phase10: auto fallback day");
|
|
}
|
|
|
|
{
|
|
// parse_breakout_granularity
|
|
std::string col;
|
|
check(parse_breakout_granularity("ts:month", col) == DateGranularity::Month,
|
|
"phase10: parse breakout month");
|
|
check(col == "ts", "phase10: parse breakout col stripped");
|
|
check(parse_breakout_granularity("ts", col) == DateGranularity::None,
|
|
"phase10: parse breakout sin sufijo None");
|
|
check(col == "ts", "phase10: col sin sufijo intacto");
|
|
check(parse_breakout_granularity("ts:wat", col) == DateGranularity::None,
|
|
"phase10: sufijo desconocido None");
|
|
check(col == "ts:wat", "phase10: col preserva sufijo desconocido");
|
|
}
|
|
|
|
{
|
|
// compose_breakout
|
|
check(compose_breakout("ts", DateGranularity::None) == "ts", "phase10: compose None");
|
|
check(compose_breakout("ts", DateGranularity::Month) == "ts:month", "phase10: compose month");
|
|
check(compose_breakout("ts", DateGranularity::Year) == "ts:year", "phase10: compose year");
|
|
// round-trip parse(compose)
|
|
std::string col;
|
|
auto g = parse_breakout_granularity(compose_breakout("foo", DateGranularity::Week), col);
|
|
check(g == DateGranularity::Week && col == "foo", "phase10: compose+parse round-trip");
|
|
}
|
|
|
|
{
|
|
// column_min_max
|
|
const char* cells[] = {
|
|
"2026-03-01",
|
|
"2026-01-15",
|
|
"",
|
|
"2026-05-12",
|
|
"2026-02-22",
|
|
};
|
|
std::string lo, hi;
|
|
column_min_max(cells, 5, 1, 0, lo, hi);
|
|
check(lo == "2026-01-15" && hi == "2026-05-12", "phase10: column_min_max ISO ordena lexical");
|
|
|
|
const char* empty_cells[] = {"", "", ""};
|
|
column_min_max(empty_cells, 3, 1, 0, lo, hi);
|
|
check(lo.empty() && hi.empty(), "phase10: column_min_max sin datos -> vacio");
|
|
|
|
column_min_max(cells, 5, 1, 5, lo, hi); // col fuera de rango
|
|
check(lo.empty() && hi.empty(), "phase10: column_min_max col fuera de rango -> vacio");
|
|
}
|
|
|
|
{
|
|
// tokens round-trip granularity
|
|
check(date_granularity_from_token("year") == DateGranularity::Year, "phase10: token year");
|
|
check(date_granularity_from_token("month") == DateGranularity::Month, "phase10: token month");
|
|
check(date_granularity_from_token("week") == DateGranularity::Week, "phase10: token week");
|
|
check(date_granularity_from_token("day") == DateGranularity::Day, "phase10: token day");
|
|
check(date_granularity_from_token("hour") == DateGranularity::Hour, "phase10: token hour");
|
|
check(date_granularity_from_token("nope") == DateGranularity::None, "phase10: token fallback None");
|
|
check(std::string(date_granularity_token(DateGranularity::Month)) == "month",
|
|
"phase10: emit month");
|
|
check(std::string(date_granularity_token(DateGranularity::None)) == "",
|
|
"phase10: emit None empty");
|
|
}
|
|
|
|
{
|
|
// build_preset_filters
|
|
auto f7 = build_preset_filters(FilterPreset::Last7d, 2, "2026-05-12");
|
|
check(f7.size() == 1, "phase10: Last7d -> 1 filter");
|
|
check(f7[0].col == 2 && f7[0].op == Op::Gte && f7[0].value == "2026-05-05",
|
|
"phase10: Last7d -> Gte 2026-05-05");
|
|
|
|
auto f30 = build_preset_filters(FilterPreset::Last30d, 2, "2026-05-12");
|
|
check(f30[0].value == "2026-04-12", "phase10: Last30d -> 2026-04-12");
|
|
|
|
auto f90 = build_preset_filters(FilterPreset::Last90d, 2, "2026-05-12");
|
|
check(f90[0].value == "2026-02-11", "phase10: Last90d -> 2026-02-11");
|
|
|
|
auto fn0 = build_preset_filters(FilterPreset::ExcludeNulls, 3, "");
|
|
check(fn0.size() == 1 && fn0[0].op == Op::Neq && fn0[0].value == "",
|
|
"phase10: ExcludeNulls -> Neq ''");
|
|
|
|
auto fnz = build_preset_filters(FilterPreset::NonZero, 4, "");
|
|
check(fnz.size() == 2, "phase10: NonZero -> 2 filters");
|
|
check(fnz[0].op == Op::Neq && fnz[0].value == "" &&
|
|
fnz[1].op == Op::Neq && fnz[1].value == "0",
|
|
"phase10: NonZero -> Neq '' AND Neq '0'");
|
|
|
|
auto fbad = build_preset_filters(FilterPreset::Last7d, 2, "bad-date");
|
|
check(fbad.empty(), "phase10: Last7d con today invalido -> empty");
|
|
}
|
|
|
|
{
|
|
// TQL round-trip: breakout con sufijo :granularity.
|
|
State st0;
|
|
st0.stages.resize(2);
|
|
st0.stages[1].breakouts = {"ts:month"};
|
|
Aggregation a; a.fn = AggFn::Count; a.alias = "n";
|
|
st0.stages[1].aggregations.push_back(a);
|
|
|
|
std::vector<std::string> hdrs = {"ts", "amount"};
|
|
std::vector<ColumnType> tys = {ColumnType::Date, ColumnType::Float};
|
|
int eff = 2;
|
|
std::string text = tql::emit(st0, hdrs, tys);
|
|
check(text.find("\"ts:month\"") != std::string::npos,
|
|
"phase10 TQL: emit breakout granularity sufijo");
|
|
|
|
std::string err;
|
|
State st1;
|
|
bool ok = tql::apply(text, st1, hdrs, tys, nullptr, 2, eff, &err);
|
|
check(ok, "phase10 TQL: apply round-trip ok");
|
|
check(st1.stages.size() >= 2 && st1.stages[1].breakouts.size() == 1 &&
|
|
st1.stages[1].breakouts[0] == "ts:month",
|
|
"phase10 TQL: breakout granularity preservada");
|
|
}
|
|
|
|
{
|
|
// compute_stage aplica truncado de fecha cuando hay :granularity.
|
|
const char* cells[] = {
|
|
"2026-01-15", "10",
|
|
"2026-01-22", "20",
|
|
"2026-02-03", "30",
|
|
"2026-03-11", "40",
|
|
};
|
|
std::vector<std::string> hdrs = {"ts", "amount"};
|
|
std::vector<ColumnType> tys = {ColumnType::Date, ColumnType::Float};
|
|
Stage s1;
|
|
s1.breakouts = {"ts:month"};
|
|
Aggregation ag; ag.fn = AggFn::Count; ag.alias = "n";
|
|
s1.aggregations.push_back(ag);
|
|
auto out = compute_stage(cells, 4, 2, hdrs, tys, s1);
|
|
check(out.rows == 3, "phase10: trunc month -> 3 grupos (Jan/Feb/Mar)");
|
|
check(out.headers[0] == "ts:month", "phase10: header preserva sufijo");
|
|
// Verifica que algun valor de breakout es "2026-01"
|
|
bool found_jan = false;
|
|
for (int r = 0; r < out.rows; ++r) {
|
|
if (std::string(out.cells[r * out.cols + 0]) == "2026-01") found_jan = true;
|
|
}
|
|
check(found_jan, "phase10: trunc value '2026-01' presente");
|
|
}
|
|
|
|
// === phase10 hit-tests para click-to-drill ===
|
|
{
|
|
// nearest_index_1d
|
|
double xs[] = {0, 1, 2, 3, 4};
|
|
check(nearest_index_1d(0.0, xs, 5) == 0, "phase10 hit: nearest_1d exact 0");
|
|
check(nearest_index_1d(2.4, xs, 5) == 2, "phase10 hit: nearest_1d 2.4 -> 2");
|
|
check(nearest_index_1d(2.6, xs, 5) == 3, "phase10 hit: nearest_1d 2.6 -> 3");
|
|
check(nearest_index_1d(-1.0, xs, 5) == 0, "phase10 hit: nearest_1d clamp left");
|
|
check(nearest_index_1d(99.0, xs, 5) == 4, "phase10 hit: nearest_1d clamp right");
|
|
check(nearest_index_1d(0.0, nullptr, 0) == -1, "phase10 hit: nearest_1d empty -> -1");
|
|
}
|
|
|
|
{
|
|
// nearest_index_2d
|
|
double xs[] = {0, 10, 5, 5};
|
|
double ys[] = {0, 0, 10, 5};
|
|
check(nearest_index_2d(0.1, 0.1, xs, ys, 4) == 0, "phase10 hit: nearest_2d cerca de (0,0)");
|
|
check(nearest_index_2d(9.9, 0.0, xs, ys, 4) == 1, "phase10 hit: nearest_2d cerca de (10,0)");
|
|
check(nearest_index_2d(5.0, 4.9, xs, ys, 4) == 3, "phase10 hit: nearest_2d cerca de (5,5)");
|
|
check(nearest_index_2d(0, 0, nullptr, nullptr, 0) == -1, "phase10 hit: nearest_2d empty -> -1");
|
|
}
|
|
|
|
{
|
|
// pie_angle (convencion ImPlot: 0 = top, sentido horario)
|
|
const double PI = 3.14159265358979323846;
|
|
double a;
|
|
a = pie_angle(0.5, 0.5, 0.5, 0.0); // top
|
|
check(std::fabs(a - 0.0) < 1e-9, "phase10 hit: pie_angle top = 0");
|
|
a = pie_angle(0.5, 0.5, 1.0, 0.5); // right -> PI/2
|
|
check(std::fabs(a - PI/2) < 1e-9, "phase10 hit: pie_angle right = PI/2");
|
|
a = pie_angle(0.5, 0.5, 0.5, 1.0); // bottom -> PI
|
|
check(std::fabs(a - PI) < 1e-9, "phase10 hit: pie_angle bottom = PI");
|
|
a = pie_angle(0.5, 0.5, 0.0, 0.5); // left -> 3*PI/2
|
|
check(std::fabs(a - 3*PI/2) < 1e-9, "phase10 hit: pie_angle left = 3PI/2");
|
|
}
|
|
|
|
{
|
|
// pie_slice_at_angle: 4 slices iguales -> cada uno cubre PI/2.
|
|
double sums[] = {1.0, 1.0, 1.0, 1.0};
|
|
const double PI = 3.14159265358979323846;
|
|
check(pie_slice_at_angle(0.0, sums, 4) == 0, "phase10 hit: slice 0 (top)");
|
|
check(pie_slice_at_angle(PI/4, sums, 4) == 0, "phase10 hit: slice 0 (mid)");
|
|
check(pie_slice_at_angle(PI/2 + 0.1, sums, 4) == 1, "phase10 hit: slice 1");
|
|
check(pie_slice_at_angle(PI + 0.1, sums, 4) == 2, "phase10 hit: slice 2");
|
|
check(pie_slice_at_angle(3*PI/2 + 0.1, sums, 4) == 3, "phase10 hit: slice 3");
|
|
|
|
double zeros[] = {0.0, 0.0};
|
|
check(pie_slice_at_angle(0.5, zeros, 2) == -1, "phase10 hit: total 0 -> -1");
|
|
check(pie_slice_at_angle(0.0, nullptr, 0) == -1, "phase10 hit: empty -> -1");
|
|
|
|
double neg[] = {1.0, -1.0};
|
|
check(pie_slice_at_angle(0.5, neg, 2) == -1, "phase10 hit: neg sum -> -1");
|
|
}
|
|
|
|
{
|
|
// heatmap_cell_at
|
|
int rr, cc;
|
|
heatmap_cell_at(1.5, 2.5, 4, 3, rr, cc);
|
|
check(rr == 2 && cc == 1, "phase10 hit: heatmap (1.5,2.5) en 4x3 -> r2 c1");
|
|
heatmap_cell_at(-1, 0, 4, 3, rr, cc);
|
|
check(rr == -1 && cc == -1, "phase10 hit: heatmap fuera de rango");
|
|
heatmap_cell_at(0, 0, 0, 0, rr, cc);
|
|
check(rr == -1 && cc == -1, "phase10 hit: heatmap empty");
|
|
}
|
|
|
|
{
|
|
// E2E click-to-drill: simular pipeline stage1 agrupado, click en row idx 2.
|
|
State st;
|
|
st.stages.resize(2);
|
|
std::vector<std::string> hdrs = {"lang", "n"};
|
|
std::vector<ColumnType> tys = {ColumnType::String, ColumnType::Int};
|
|
st.stages[1].breakouts.push_back("lang");
|
|
st.stages[1].aggregations.push_back({AggFn::Count});
|
|
st.active_stage = 1;
|
|
|
|
// Stage 1 output simulado (3 grupos).
|
|
const char* g_cells[] = {
|
|
"go", "3",
|
|
"py", "2",
|
|
"cpp", "1",
|
|
};
|
|
StageOutput so;
|
|
so.cells.insert(so.cells.end(), g_cells, g_cells + 6);
|
|
so.rows = 3;
|
|
so.cols = 2;
|
|
so.headers = {"lang", "count"};
|
|
|
|
// Simular click en row idx 2 (cpp).
|
|
int clicked_row = 2;
|
|
int n_brk = (int)st.stages[1].breakouts.size();
|
|
check(n_brk == 1, "phase10 e2e: 1 breakout");
|
|
const char* v = so.cells[clicked_row * so.cols + 0];
|
|
std::string col_clean;
|
|
parse_breakout_granularity(so.headers[0], col_clean);
|
|
check(col_clean == "lang", "phase10 e2e: col_clean stripped OK");
|
|
st.stages[0].filters.push_back(make_drill_filter(0, v));
|
|
st.active_stage = 0;
|
|
|
|
check(st.active_stage == 0, "phase10 e2e: active retrocede a 0");
|
|
check(st.stages[0].filters.size() == 1, "phase10 e2e: 1 filter anadido");
|
|
check(st.stages[0].filters[0].col == 0 &&
|
|
st.stages[0].filters[0].op == Op::Eq &&
|
|
st.stages[0].filters[0].value == "cpp",
|
|
"phase10 e2e: filter Op::Eq col=0 value=cpp");
|
|
}
|
|
|
|
// === phase10 drill history (apply/undo step) ===
|
|
{
|
|
State st;
|
|
st.stages.resize(2);
|
|
st.active_stage = 1;
|
|
|
|
DrillStep step;
|
|
step.target_stage = 0;
|
|
step.filter_pos = 0;
|
|
step.prev_active_stage = 1;
|
|
step.added = make_drill_filter(0, "go");
|
|
|
|
check(apply_drill_step(st, step), "phase10 hist: apply ok");
|
|
check(st.stages[0].filters.size() == 1, "phase10 hist: filter anadido");
|
|
check(st.stages[0].filters[0].value == "go", "phase10 hist: value preservado");
|
|
check(st.active_stage == 0, "phase10 hist: active = target");
|
|
|
|
check(undo_drill_step(st, step), "phase10 hist: undo ok");
|
|
check(st.stages[0].filters.empty(), "phase10 hist: filter eliminado");
|
|
check(st.active_stage == 1, "phase10 hist: active restaurado");
|
|
|
|
// Redo
|
|
check(apply_drill_step(st, step), "phase10 hist: redo ok");
|
|
check(st.stages[0].filters.size() == 1, "phase10 hist: redo filter de vuelta");
|
|
check(st.active_stage == 0, "phase10 hist: redo active retorna");
|
|
|
|
// Edge: target fuera de rango
|
|
DrillStep bad;
|
|
bad.target_stage = 99;
|
|
check(!apply_drill_step(st, bad), "phase10 hist: apply fuera de rango -> false");
|
|
check(!undo_drill_step(st, bad), "phase10 hist: undo fuera de rango -> false");
|
|
|
|
// Edge: pos invalida
|
|
DrillStep bad_pos = step;
|
|
bad_pos.filter_pos = 99;
|
|
check(!undo_drill_step(st, bad_pos), "phase10 hist: undo pos invalida -> false");
|
|
}
|
|
|
|
// === phase10 drill history: back/forward stack semantics simulado ===
|
|
{
|
|
State st;
|
|
st.stages.resize(3);
|
|
st.active_stage = 2;
|
|
|
|
std::vector<DrillStep> back_stack;
|
|
std::vector<DrillStep> fwd_stack;
|
|
|
|
auto drill = [&](int from, int target, int pos, int col, const std::string& v) {
|
|
DrillStep s;
|
|
s.target_stage = target;
|
|
s.filter_pos = pos;
|
|
s.prev_active_stage = from;
|
|
s.added = make_drill_filter(col, v);
|
|
apply_drill_step(st, s);
|
|
back_stack.push_back(s);
|
|
fwd_stack.clear();
|
|
};
|
|
|
|
drill(2, 1, 0, 0, "go");
|
|
check(st.stages[1].filters.size() == 1, "phase10 hist seq: drill1 aplicado");
|
|
drill(1, 0, 0, 1, "10");
|
|
check(st.stages[0].filters.size() == 1, "phase10 hist seq: drill2 aplicado");
|
|
check(back_stack.size() == 2, "phase10 hist seq: back stack 2");
|
|
check(fwd_stack.empty(), "phase10 hist seq: forward limpio");
|
|
|
|
// Back x1
|
|
DrillStep s = back_stack.back(); back_stack.pop_back();
|
|
undo_drill_step(st, s);
|
|
fwd_stack.push_back(s);
|
|
check(st.stages[0].filters.empty(), "phase10 hist seq: back deshace drill2");
|
|
check(st.active_stage == 1, "phase10 hist seq: back restaura active=1");
|
|
check(fwd_stack.size() == 1, "phase10 hist seq: fwd stack 1");
|
|
|
|
// Forward x1
|
|
s = fwd_stack.back(); fwd_stack.pop_back();
|
|
apply_drill_step(st, s);
|
|
back_stack.push_back(s);
|
|
check(st.stages[0].filters.size() == 1, "phase10 hist seq: forward reaplica");
|
|
check(st.active_stage == 0, "phase10 hist seq: forward active=0");
|
|
}
|
|
|
|
// === phase10 row inspector (row_to_tsv + build_filters_from_row) ===
|
|
{
|
|
const char* cells[] = {
|
|
"go", "10", "filter",
|
|
"py", "20", "sma",
|
|
"go", "30", "map",
|
|
};
|
|
std::vector<std::string> hdrs = {"lang", "n", "fn"};
|
|
|
|
std::string tsv = row_to_tsv(cells, 3, 3, 1, hdrs);
|
|
check(tsv == "lang\tn\tfn\r\npy\t20\tsma\r\n",
|
|
"phase10 inspect: row_to_tsv layout");
|
|
|
|
check(row_to_tsv(cells, 3, 3, -1, hdrs).empty(), "phase10 inspect: tsv neg row -> empty");
|
|
check(row_to_tsv(cells, 3, 3, 5, hdrs).empty(), "phase10 inspect: tsv row oob -> empty");
|
|
check(row_to_tsv(cells, 3, 0, 0, hdrs).empty(), "phase10 inspect: tsv cols=0 -> empty");
|
|
|
|
auto fs = build_filters_from_row(cells, 3, 3, 0);
|
|
check(fs.size() == 3, "phase10 inspect: 3 filters de row 0");
|
|
check(fs[0].col == 0 && fs[0].op == Op::Eq && fs[0].value == "go",
|
|
"phase10 inspect: filter[0] col=0 op=Eq value=go");
|
|
check(fs[2].value == "filter", "phase10 inspect: filter[2] value=filter");
|
|
|
|
// Row con celda vacia -> filter saltado
|
|
const char* sparse[] = {"a", "", "c"};
|
|
auto fs2 = build_filters_from_row(sparse, 1, 3, 0);
|
|
check(fs2.size() == 2 && fs2[0].col == 0 && fs2[1].col == 2,
|
|
"phase10 inspect: cells vacios salteados");
|
|
|
|
check(build_filters_from_row(cells, 3, 3, -1).empty(),
|
|
"phase10 inspect: build_filters row invalido -> empty");
|
|
}
|
|
|
|
// === phase10 drill-up ===
|
|
{
|
|
State st;
|
|
st.stages.resize(3);
|
|
st.active_stage = 2;
|
|
check(drill_up(st), "phase10 up: 2->1 ok");
|
|
check(st.active_stage == 1, "phase10 up: active=1");
|
|
check(drill_up(st), "phase10 up: 1->0 ok");
|
|
check(st.active_stage == 0, "phase10 up: active=0");
|
|
check(!drill_up(st), "phase10 up: 0 -> false");
|
|
check(st.active_stage == 0, "phase10 up: queda en 0");
|
|
|
|
// Filters no se mueven
|
|
State st2;
|
|
st2.stages.resize(2);
|
|
st2.active_stage = 1;
|
|
st2.stages[1].filters.push_back({0, Op::Eq, "x"});
|
|
drill_up(st2);
|
|
check(st2.stages[0].filters.empty() && st2.stages[1].filters.size() == 1,
|
|
"phase10 up: filters quedan en su stage");
|
|
|
|
State empty_st;
|
|
check(!drill_up(empty_st), "phase10 up: stages vacio -> false");
|
|
}
|
|
|
|
// === phase11: Lua subset validator + transpiler ===
|
|
{
|
|
std::string err;
|
|
// Subset OK: literales + ops
|
|
std::string e1 = tql_to_sql::transpile_expr("1 + 2", {}, err);
|
|
check(err.empty() && e1.find("1 + 2") != std::string::npos,
|
|
"phase11 lua: literal arith");
|
|
|
|
std::string e2 = tql_to_sql::transpile_expr("[a] + [b] * 2", {}, err);
|
|
check(err.empty() && e2.find("\"a\"") != std::string::npos &&
|
|
e2.find("\"b\"") != std::string::npos,
|
|
"phase11 lua: col refs + arith");
|
|
|
|
std::string e3 = tql_to_sql::transpile_expr("[a] .. \"_\" .. [b]", {}, err);
|
|
check(err.empty() && e3.find(" || ") != std::string::npos,
|
|
"phase11 lua: concat -> ||");
|
|
|
|
std::string e4 = tql_to_sql::transpile_expr(
|
|
"if [n] > 10 then \"big\" else \"small\" end", {}, err);
|
|
check(err.empty() && e4.find("CASE WHEN") != std::string::npos &&
|
|
e4.find("THEN") != std::string::npos && e4.find("ELSE") != std::string::npos,
|
|
"phase11 lua: if/then/else -> CASE");
|
|
|
|
std::string e5 = tql_to_sql::transpile_expr("math.floor([x] / 100)", {}, err);
|
|
check(err.empty() && e5.find("floor(") != std::string::npos,
|
|
"phase11 lua: math.floor");
|
|
|
|
std::string e6 = tql_to_sql::transpile_expr("string.upper([name])", {}, err);
|
|
check(err.empty() && e6.find("upper(") != std::string::npos,
|
|
"phase11 lua: string.upper");
|
|
|
|
std::string e7 = tql_to_sql::transpile_expr("string.sub([s], 1, 3)", {}, err);
|
|
check(err.empty() && e7.find("substring(") != std::string::npos,
|
|
"phase11 lua: string.sub 3-arg");
|
|
|
|
std::string e8 = tql_to_sql::transpile_expr("not ([x] == nil)", {}, err);
|
|
check(err.empty() && e8.find("NOT") != std::string::npos && e8.find("NULL") != std::string::npos,
|
|
"phase11 lua: not + nil");
|
|
|
|
std::string e9 = tql_to_sql::transpile_expr("tonumber([n])", {}, err);
|
|
check(err.empty() && e9.find("CAST(") != std::string::npos,
|
|
"phase11 lua: tonumber -> CAST DOUBLE");
|
|
|
|
// Fuera subset: 9 categorias rechazadas
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("function() return 1 end", {}, err).empty()
|
|
&& err.find("closures") != std::string::npos,
|
|
"phase11 lua: function closure rechazado");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("local x = 1", {}, err).empty()
|
|
&& err.find("local") != std::string::npos,
|
|
"phase11 lua: local rechazado");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("for i=1,10 do end", {}, err).empty()
|
|
&& err.find("loops") != std::string::npos,
|
|
"phase11 lua: for loop rechazado");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("while true do end", {}, err).empty()
|
|
&& err.find("loops") != std::string::npos,
|
|
"phase11 lua: while loop rechazado");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("{1,2,3}", {}, err).empty()
|
|
&& err.find("table") != std::string::npos,
|
|
"phase11 lua: table literal rechazado");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("io.read()", {}, err).empty()
|
|
&& err.find("io") != std::string::npos,
|
|
"phase11 lua: io.* rechazado");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("string.gsub([s], \"a\", \"b\")", {}, err).empty()
|
|
&& err.find("whitelist") != std::string::npos,
|
|
"phase11 lua: string.gsub no whitelisted");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("print([x])", {}, err).empty()
|
|
&& err.find("print") != std::string::npos,
|
|
"phase11 lua: print rechazado");
|
|
|
|
err.clear();
|
|
check(tql_to_sql::transpile_expr("[a]; [b]", {}, err).empty()
|
|
&& err.find("multi-statement") != std::string::npos,
|
|
"phase11 lua: ';' multi-stmt rechazado");
|
|
|
|
// is_transpilable wrapper
|
|
std::string werr;
|
|
check(tql_to_sql::is_transpilable("[a] + 1", werr), "phase11 lua: is_transpilable OK");
|
|
check(!tql_to_sql::is_transpilable("function() end", werr),
|
|
"phase11 lua: is_transpilable false para closure");
|
|
}
|
|
|
|
// === phase11: TQL State -> SQL DuckDB emit ===
|
|
{
|
|
// Setup: 1 tabla "users" con cols lang,n.
|
|
TableInput t;
|
|
t.name = "users";
|
|
t.headers = {"lang", "n"};
|
|
t.types = {ColumnType::String, ColumnType::Int};
|
|
// Cells no usado por emit (solo schema).
|
|
std::vector<TableInput> tables = {t};
|
|
|
|
// Caso 1: stage 0 simple (sin filters ni sort)
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 sql: empty pipeline -> no error");
|
|
check(e.sql.find("WITH t0") != std::string::npos &&
|
|
e.sql.find("FROM \"users\"") != std::string::npos &&
|
|
e.sql.find("SELECT * FROM t0") != std::string::npos,
|
|
"phase11 sql: stage0 SELECT * FROM users");
|
|
}
|
|
|
|
// Caso 2: stage 0 filter + sort
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
st.stages[0].filters.push_back({0, Op::Eq, "go"});
|
|
st.stages[0].filters.push_back({1, Op::Gt, "10"});
|
|
st.stages[0].sorts.push_back({"n", true});
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 sql: filter+sort OK");
|
|
check(e.sql.find("WHERE") != std::string::npos &&
|
|
e.sql.find("\"lang\" = ?") != std::string::npos &&
|
|
e.sql.find("\"n\" > ?") != std::string::npos,
|
|
"phase11 sql: filter clauses");
|
|
check(e.params.size() == 2 && e.params[0] == "go" && e.params[1] == "10",
|
|
"phase11 sql: params bound");
|
|
check(e.sql.find("ORDER BY \"n\" DESC") != std::string::npos,
|
|
"phase11 sql: ORDER BY desc");
|
|
}
|
|
|
|
// Caso 3: stage 1 group + count
|
|
{
|
|
State st;
|
|
st.stages.resize(2);
|
|
st.stages[1].breakouts.push_back("lang");
|
|
st.stages[1].aggregations.push_back({AggFn::Count});
|
|
st.active_stage = 1;
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 sql: group ok");
|
|
check(e.sql.find("t1 AS") != std::string::npos &&
|
|
e.sql.find("COUNT(*)") != std::string::npos &&
|
|
e.sql.find("GROUP BY") != std::string::npos &&
|
|
e.sql.find("SELECT * FROM t1") != std::string::npos,
|
|
"phase11 sql: stage1 CTE + COUNT + GROUP BY");
|
|
}
|
|
|
|
// Caso 4: granularity :month -> date_trunc
|
|
{
|
|
State st;
|
|
st.stages.resize(2);
|
|
st.stages[1].breakouts.push_back("ts:month");
|
|
st.stages[1].aggregations.push_back({AggFn::Sum, "n"});
|
|
st.active_stage = 1;
|
|
TableInput ts_t;
|
|
ts_t.name = "events";
|
|
ts_t.headers = {"ts", "n"};
|
|
ts_t.types = {ColumnType::Date, ColumnType::Int};
|
|
std::vector<TableInput> tt = {ts_t};
|
|
auto e = tql_to_sql::emit_sql(st, tt);
|
|
check(e.error.empty(), "phase11 sql: granularity ok");
|
|
check(e.sql.find("date_trunc('month'") != std::string::npos &&
|
|
e.sql.find("SUM(\"n\")") != std::string::npos,
|
|
"phase11 sql: date_trunc + SUM");
|
|
}
|
|
|
|
// Caso 5: aggregations p25/median/p99
|
|
{
|
|
State st;
|
|
st.stages.resize(2);
|
|
st.stages[1].breakouts.push_back("lang");
|
|
st.stages[1].aggregations.push_back({AggFn::Median, "n"});
|
|
st.stages[1].aggregations.push_back({AggFn::P25, "n"});
|
|
st.stages[1].aggregations.push_back({AggFn::P99, "n"});
|
|
st.active_stage = 1;
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 sql: percentiles ok");
|
|
check(e.sql.find("quantile_cont(\"n\", 0.5)") != std::string::npos &&
|
|
e.sql.find("quantile_cont(\"n\", 0.25)") != std::string::npos &&
|
|
e.sql.find("quantile_cont(\"n\", 0.99)") != std::string::npos,
|
|
"phase11 sql: quantile_cont calls");
|
|
}
|
|
|
|
// Caso 6: joins 4 strategies
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
Join jn;
|
|
jn.alias = "o";
|
|
jn.source = "orders";
|
|
jn.on.push_back({"user_id", "user_id"});
|
|
jn.strategy = JoinStrategy::Left;
|
|
st.joins.push_back(jn);
|
|
TableInput u, o;
|
|
u.name = "users";
|
|
u.headers = {"user_id", "name"};
|
|
u.types = {ColumnType::String, ColumnType::String};
|
|
o.name = "orders";
|
|
o.headers = {"user_id", "amount"};
|
|
o.types = {ColumnType::String, ColumnType::Int};
|
|
std::vector<TableInput> tt = {u, o};
|
|
auto e = tql_to_sql::emit_sql(st, tt);
|
|
check(e.error.empty(), "phase11 sql: join ok");
|
|
check(e.sql.find("LEFT JOIN \"orders\" AS \"o\"") != std::string::npos &&
|
|
e.sql.find("ON \"users\".\"user_id\" = \"o\".\"user_id\"") != std::string::npos,
|
|
"phase11 sql: LEFT JOIN ON syntax");
|
|
|
|
// Inner
|
|
st.joins[0].strategy = JoinStrategy::Inner;
|
|
auto e2 = tql_to_sql::emit_sql(st, tt);
|
|
check(e2.sql.find("INNER JOIN") != std::string::npos, "phase11 sql: INNER JOIN");
|
|
|
|
// Right
|
|
st.joins[0].strategy = JoinStrategy::Right;
|
|
auto e3 = tql_to_sql::emit_sql(st, tt);
|
|
check(e3.sql.find("RIGHT JOIN") != std::string::npos, "phase11 sql: RIGHT JOIN");
|
|
|
|
// Full
|
|
st.joins[0].strategy = JoinStrategy::Full;
|
|
auto e4 = tql_to_sql::emit_sql(st, tt);
|
|
check(e4.sql.find("FULL OUTER JOIN") != std::string::npos, "phase11 sql: FULL OUTER JOIN");
|
|
}
|
|
|
|
// Caso 7: derived col subset -> SQL expression
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
DerivedColumn d;
|
|
d.name = "size_kb";
|
|
d.source_col = -1;
|
|
d.formula = "[n] / 1024.0";
|
|
d.type = ColumnType::Float;
|
|
st.stages[0].derived.push_back(d);
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 sql: derived subset ok");
|
|
check(e.sql.find("\"n\" / 1024") != std::string::npos &&
|
|
e.sql.find("AS \"size_kb\"") != std::string::npos,
|
|
"phase11 sql: derived expression + alias");
|
|
}
|
|
|
|
// Caso 8: derived col FUERA subset -> warning + skip
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
DerivedColumn d;
|
|
d.name = "bad";
|
|
d.source_col = -1;
|
|
d.formula = "string.gsub([n], \"a\", \"b\")";
|
|
d.type = ColumnType::String;
|
|
st.stages[0].derived.push_back(d);
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 sql: derived fuera subset NO bloquea emit");
|
|
check(!e.warnings.empty() &&
|
|
e.warnings[0].find("out of SQL subset") != std::string::npos,
|
|
"phase11 sql: warning derived fuera subset");
|
|
check(e.sql.find("\"bad\"") == std::string::npos,
|
|
"phase11 sql: derived skip cuando fuera subset");
|
|
}
|
|
|
|
// Caso 9: empty tables -> error
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
std::vector<TableInput> empty;
|
|
auto e = tql_to_sql::emit_sql(st, empty);
|
|
check(!e.error.empty() && e.error.find("no input tables") != std::string::npos,
|
|
"phase11 sql: empty tables -> error");
|
|
}
|
|
|
|
// Caso 10: stage 0 con LIKE (Contains)
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
st.stages[0].filters.push_back({0, Op::Contains, "go"});
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 sql: LIKE Contains ok");
|
|
check(e.sql.find("LIKE ?") != std::string::npos &&
|
|
e.params.size() == 1 && e.params[0] == "%go%",
|
|
"phase11 sql: Contains -> LIKE %go%");
|
|
}
|
|
}
|
|
|
|
// === phase11: LLM client (mock, no red) ===
|
|
{
|
|
llm_anthropic::AskInput in;
|
|
in.question = "show top 10 langs";
|
|
in.tql_current = "return { stages = {} }";
|
|
in.col_names = {"lang", "n"};
|
|
in.col_types = {ColumnType::String, ColumnType::Int};
|
|
in.mode = llm_anthropic::OutputMode::TQL;
|
|
std::string body = llm_anthropic::build_request_body(in);
|
|
check(body.find("\"model\":\"claude-sonnet-4-6\"") != std::string::npos,
|
|
"phase11 llm: default model");
|
|
check(body.find("\"max_tokens\":8192") != std::string::npos,
|
|
"phase11 llm: max_tokens");
|
|
check(body.find("\\\"system\\\"") == std::string::npos /* not double-escaped */,
|
|
"phase11 llm: system not double-escaped");
|
|
check(body.find("Available columns") != std::string::npos,
|
|
"phase11 llm: schema block present");
|
|
check(body.find("show top 10 langs") != std::string::npos,
|
|
"phase11 llm: question present");
|
|
check(body.find("TQL") != std::string::npos,
|
|
"phase11 llm: system mentions TQL");
|
|
|
|
in.mode = llm_anthropic::OutputMode::SQL;
|
|
std::string body_sql = llm_anthropic::build_request_body(in);
|
|
check(body_sql.find("DuckDB") != std::string::npos,
|
|
"phase11 llm: SQL mode mentions DuckDB");
|
|
}
|
|
|
|
{
|
|
// extract_code_block
|
|
std::string raw1 = "Here you go:\n```lua\nreturn { x = 1 }\n```\nDone!";
|
|
std::string code = llm_anthropic::extract_code_block(raw1, "lua");
|
|
check(code == "return { x = 1 }", "phase11 llm: extract ```lua block");
|
|
|
|
std::string raw2 = "Sure:\n```\nplain code\n```";
|
|
std::string code2 = llm_anthropic::extract_code_block(raw2, "lua");
|
|
check(code2 == "plain code", "phase11 llm: extract bare ```");
|
|
|
|
std::string raw3 = "no fences here";
|
|
std::string code3 = llm_anthropic::extract_code_block(raw3, "lua");
|
|
check(code3 == "no fences here", "phase11 llm: no fence -> stripped");
|
|
|
|
std::string raw4 = "```sql\nSELECT 1;\n```";
|
|
std::string code4 = llm_anthropic::extract_code_block(raw4, "sql");
|
|
check(code4 == "SELECT 1;", "phase11 llm: extract ```sql");
|
|
}
|
|
|
|
{
|
|
// parse_response_text from JSON
|
|
std::string j = "{\"id\":\"x\",\"content\":[{\"type\":\"text\",\"text\":\"hello\\nworld\"}],\"role\":\"assistant\"}";
|
|
std::string t = llm_anthropic::parse_response_text(j);
|
|
check(t == "hello\nworld", "phase11 llm: parse text content");
|
|
|
|
std::string j2 = "{\"content\":[{\"type\":\"text\",\"text\":\"\\\"quoted\\\"\"}]}";
|
|
std::string t2 = llm_anthropic::parse_response_text(j2);
|
|
check(t2 == "\"quoted\"", "phase11 llm: parse quoted escape");
|
|
|
|
std::string j3 = "{\"error\":\"foo\"}";
|
|
std::string t3 = llm_anthropic::parse_response_text(j3);
|
|
check(t3.empty(), "phase11 llm: no text -> empty");
|
|
}
|
|
|
|
{
|
|
// Mock end-to-end via FN_LLM_MOCK_RESPONSE (portable Linux/Mingw via putenv).
|
|
const char* mock_kv =
|
|
"FN_LLM_MOCK_RESPONSE={\"content\":[{\"type\":\"text\",\"text\":\"```lua\\nreturn { mock = true }\\n```\"}]}";
|
|
putenv((char*)mock_kv);
|
|
llm_anthropic::AskInput in;
|
|
in.question = "q";
|
|
in.col_names = {"a"};
|
|
in.col_types = {ColumnType::String};
|
|
auto r = llm_anthropic::ask(in);
|
|
check(r.error.empty(), "phase11 llm mock: no error");
|
|
check(r.code == "return { mock = true }", "phase11 llm mock: code extracted");
|
|
// Unset: putenv con "VAR=" deja vacio (suficiente para nuestro check `*mock`).
|
|
putenv((char*)"FN_LLM_MOCK_RESPONSE=");
|
|
}
|
|
|
|
#ifdef FN_TQL_DUCKDB
|
|
// === phase11 round-trip TQL emit -> DuckDB execute -> match compute_stage ===
|
|
{
|
|
// Setup tabla "users" con 5 rows.
|
|
const char* cells[] = {
|
|
"go", "10",
|
|
"go", "20",
|
|
"py", "30",
|
|
"py", "40",
|
|
"cpp", "50",
|
|
};
|
|
TableInput t;
|
|
t.name = "users";
|
|
t.headers = {"lang", "n"};
|
|
t.types = {ColumnType::String, ColumnType::Int};
|
|
t.cells = cells;
|
|
t.rows = 5;
|
|
t.cols = 2;
|
|
std::vector<TableInput> tables = {t};
|
|
|
|
// Caso A: stage 0 simple SELECT
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 rt: stage0 emit OK");
|
|
auto r = tql_duckdb::execute(e.sql, e.params, tables);
|
|
check(r.error.empty(), "phase11 rt: stage0 execute OK");
|
|
check(r.out.rows == 5, "phase11 rt: stage0 5 rows");
|
|
check(r.out.cols == 2, "phase11 rt: stage0 2 cols");
|
|
}
|
|
|
|
// Caso B: stage 1 group by lang + count
|
|
{
|
|
State st;
|
|
st.stages.resize(2);
|
|
st.stages[1].breakouts.push_back("lang");
|
|
st.stages[1].aggregations.push_back({AggFn::Count});
|
|
st.active_stage = 1;
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 rt: group emit OK");
|
|
auto r = tql_duckdb::execute(e.sql, e.params, tables);
|
|
check(r.error.empty(), "phase11 rt: group execute OK");
|
|
check(r.out.rows == 3, "phase11 rt: 3 grupos (go/py/cpp)");
|
|
check(r.out.cols == 2, "phase11 rt: cols = lang + count");
|
|
}
|
|
|
|
// Caso C: filter Op::Eq
|
|
{
|
|
State st;
|
|
st.stages.resize(1);
|
|
st.stages[0].filters.push_back({0, Op::Eq, "go"});
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 rt: filter emit OK");
|
|
auto r = tql_duckdb::execute(e.sql, e.params, tables);
|
|
check(r.error.empty(), "phase11 rt: filter execute OK");
|
|
check(r.out.rows == 2, "phase11 rt: filter -> 2 rows go");
|
|
}
|
|
|
|
// Caso D: aggregation sum
|
|
{
|
|
State st;
|
|
st.stages.resize(2);
|
|
st.stages[1].breakouts.push_back("lang");
|
|
st.stages[1].aggregations.push_back({AggFn::Sum, "n"});
|
|
st.active_stage = 1;
|
|
auto e = tql_to_sql::emit_sql(st, tables);
|
|
check(e.error.empty(), "phase11 rt: sum emit OK");
|
|
auto r = tql_duckdb::execute(e.sql, e.params, tables);
|
|
check(r.error.empty(), "phase11 rt: sum execute OK");
|
|
check(r.out.rows == 3, "phase11 rt: sum 3 grupos");
|
|
// Verifica que sum_n para "go" es 30 (10+20)
|
|
bool found_go_30 = false;
|
|
for (int rr = 0; rr < r.out.rows; ++rr) {
|
|
std::string lang = r.out.cells[rr * 2 + 0];
|
|
std::string sum = r.out.cells[rr * 2 + 1];
|
|
if (lang == "go" && (sum == "30" || sum == "30.0")) found_go_30 = true;
|
|
}
|
|
check(found_go_30, "phase11 rt: sum_n(go) = 30");
|
|
}
|
|
}
|
|
#endif
|
|
|
|
std::printf("\n=== %d passed, %d failed ===\n", passed, failed);
|
|
return failed == 0 ? 0 : 1;
|
|
}
|