// 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 "lua_engine.h" #include "tql.h" #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"); } std::printf("\n=== %d passed, %d failed ===\n", passed, failed); return failed == 0 ? 0 : 1; }