// 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" #include #include #include #include #include #include #include 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 "@" 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 [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 col_order = {0, 1, 2}; std::vector col_vis = {true, true, true}; std::vector 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 col_order = {0, 1, 2}; std::vector col_vis = {true, false, true}; // letter oculto std::vector 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 col_order = {1, 0}; // letter primero std::vector col_vis = {true, true}; std::vector 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 col_order = {0, 1}; std::vector col_vis = {true, true}; std::vector 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 col_order = {0, 1}; std::vector col_vis = {true, true}; std::vector 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 hn = {"name", "qty"}; std::unordered_map 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 hn2 = {"name", "qty", "size", "flag", "dt"}; std::unordered_map 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 derived; std::unordered_map 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 hn_utf = {"año", "qty"}; std::unordered_map n2c_utf = {{"año", 0}, {"qty", 1}}; const char* cells_utf[] = {"2025", "10", "2026", "20"}; ColumnType types_utf[] = {ColumnType::Int, ColumnType::Int}; std::vector empty_d; std::unordered_map 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. 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 hdrs = {"lang", "size_kb", "name"}; std::vector 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 hdrs = {"lang", "n"}; std::vector 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 hdrs = {"lang", "n"}; std::vector 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 hdrs = {"lang", "domain", "n"}; std::vector 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 hdrs = {"k", "n"}; std::vector 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 hdrs = {"lang", "name"}; std::vector 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 hdrs = {"lang", "domain", "n"}; std::vector 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 hdrs = {"lang", "n", "name"}; // Empty state -> minimal std::vector 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 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{}, 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 hdrs = {"a"}; std::string err; bool ok = tql::apply("return {{{ not valid lua", st, hdrs, std::vector{}, 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 hdrs = {"a"}; std::string err; bool ok = tql::apply("return 42", st, hdrs, std::vector{}, 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 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 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 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{}, 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 hdrs = {"lang", "n"}; std::vector 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 hdrs = {"lang", "n"}; std::vector 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 hdrs = {"size_kb"}; std::vector 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 hdrs = {"lang", "domain", "n"}; std::vector 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 out_hn = out1.headers; std::unordered_map n2c; for (size_t i = 0; i < out_hn.size(); ++i) n2c[out_hn[i]] = (int)i; std::vector 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 hdrs = {"lang", "name"}; std::vector 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 out_hn = out1.headers; std::unordered_map n2c; for (size_t i = 0; i < out_hn.size(); ++i) n2c[out_hn[i]] = (int)i; std::vector der; der.push_back({-1, ColumnType::Int, "A", "[count] + 100", idA, ""}); std::unordered_map 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 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 hdrs = {"lang", "domain", "n"}; std::vector 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 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{}, 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 hdrs = {"lang", "domain", "n"}; std::vector 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 hdrs = {"lang", "n"}; std::vector 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 hdrs = {"k", "n"}; std::vector 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 hdrs = {"a"}; std::string err; bool ok = tql::apply("return { display=\"table\", stages={}, columns={} }", st, hdrs, std::vector{}, 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 hdrs = {"a"}; std::string err; bool ok = tql::apply("return { version=999, stages={}, columns={} }", st, hdrs, std::vector{}, 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 hdrs = {"a"}; std::string err; bool ok = tql::apply("return { version=\"x\", stages={}, columns={} }", st, hdrs, std::vector{}, nullptr, 0, 1, &err); check(!ok, "phase5: version no-number rechaza"); } { // unknown filter col -> warning State st; std::vector 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{}, 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 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{}, 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 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{}, 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 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{}, 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 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{}, 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 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{}, cells_t, 2, 2, &err); check(ok && err.empty(), "phase5: TQL valido sin warnings"); } { // emit() incluye cheatsheet header State st; std::vector hdrs = {"a"}; std::vector 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 hdrs = {"a"}; std::vector 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 hdrs = {"a"}; std::string err; std::string text = "return { version=1, display=\"weird\", stages={}, columns={} }"; bool ok = tql::apply(text, st, hdrs, std::vector{}, 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 hdrs = {"country", "sales", "profit"}; std::vector 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 lh = {"id", "name"}; std::vector 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 lh = {"y", "m", "v"}; std::vector 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 hdrs = {"a"}; std::vector 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 hdrs = {"user_id","region","name"}; std::vector 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 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 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 hdrs = {"ts", "amount"}; std::vector 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 hdrs = {"ts", "amount"}; std::vector 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 hdrs = {"lang", "n"}; std::vector 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 back_stack; std::vector 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 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 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 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 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 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="); } std::printf("\n=== %d passed, %d failed ===\n", passed, failed); return failed == 0 ? 0 : 1; }