data_table: Phase 2 — Button + events + tooltip + RightClick + TQL persist column_specs (issue 0081-O)
- CellRenderer::Button=5: renders SmallButton per cell; emits TableEvent::ButtonClick on click - TableEventKind enum (ButtonClick/RowDoubleClick/RowRightClick/CellEdit) + TableEvent struct - render() extended overload: adds events_out parameter (nullptr = back-compat, no events) - RowDoubleClick and RowRightClick detection in raw table loop (stage 0) - RowRightClick also detected in aggregated stage table (stage 1+) - Tooltip per cell: tooltip_on_hover + tooltip fields on ColumnSpec; "auto" = show cell value - State::aux_column_specs: TQL-persisted column specs sidecar per table - tql_emit: serializes aux_column_specs[0] as column_specs block (badge/progress/duration/icon/button/tooltip) - tql_apply: parses column_specs block back into state.aux_column_specs[0] - render() merges aux_column_specs into TableInput when caller passes empty column_specs - test_column_specs: 5->8 tests (Button struct, tooltip fields, both render() signatures link) - tql_emit_test: 3 new tests (column_specs badge/button/tooltip emit) — 52 passed - tql_apply_test: 3 new tests (column_specs badge/button/tooltip roundtrip) — 106 passed - Back-compat: existing apps (graph_explorer, registry_dashboard) unchanged - Version bump: data_table v1.1.0 -> v1.2.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,7 @@ enum class JoinStrategy { Left, Inner, Right, Full };
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// CellRenderer: declarative rendering mode per column (issue 0081-N, v1.1.0).
|
||||
// Phase 2 (issue 0081-O, v1.2.0): Button=5 added.
|
||||
// ----------------------------------------------------------------------------
|
||||
enum class CellRenderer : uint8_t {
|
||||
Text = 0, // default — current behavior
|
||||
@@ -136,7 +137,29 @@ enum class CellRenderer : uint8_t {
|
||||
Progress = 2, // progress bar (0..1 or 0..100)
|
||||
Duration = 3, // milliseconds with color gradient
|
||||
Icon = 4, // icon lookup by value string
|
||||
// Future (Phase 2-3): Button=5, TextInput=6, Custom=7. IDs reserved.
|
||||
Button = 5, // clickable button; emits TableEvent::ButtonClick
|
||||
// Future (Phase 3): TextInput=6, Custom=7. IDs reserved.
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// TableEventKind: kinds of events emitted by render() via events_out.
|
||||
// Issue 0081-O, v1.2.0.
|
||||
// ----------------------------------------------------------------------------
|
||||
enum class TableEventKind : uint8_t {
|
||||
ButtonClick = 1, // CellRenderer::Button was clicked
|
||||
RowDoubleClick = 2, // row was double-clicked (left button)
|
||||
RowRightClick = 3, // row right-clicked (open context menu)
|
||||
CellEdit = 4, // reserved for Phase 3 (TextInput)
|
||||
};
|
||||
|
||||
// TableEvent: carries the context of a single UI interaction.
|
||||
struct TableEvent {
|
||||
TableEventKind kind;
|
||||
int row = -1; // index in TableInput (not StageOutput)
|
||||
int col = -1; // column index in TableInput
|
||||
std::string column_id; // ColumnSpec.id of the column
|
||||
std::string action_id; // for ButtonClick: ColumnSpec.button_action
|
||||
std::string value; // cell value at the click point
|
||||
};
|
||||
|
||||
// BadgeRule: maps a cell value to a colored badge label.
|
||||
@@ -155,7 +178,7 @@ struct IconMapEntry {
|
||||
|
||||
// ColumnSpec: rendering spec for one column. Indexed by column position.
|
||||
struct ColumnSpec {
|
||||
std::string id; // stable id, used in TQL (future)
|
||||
std::string id; // stable id, used in TQL
|
||||
CellRenderer renderer = CellRenderer::Text;
|
||||
|
||||
// Badge
|
||||
@@ -171,6 +194,15 @@ struct ColumnSpec {
|
||||
|
||||
// Icon
|
||||
std::vector<IconMapEntry> icon_map;
|
||||
|
||||
// Button (Phase 2, v1.2.0): CellRenderer::Button
|
||||
std::string button_action; // semantic id the app processes
|
||||
std::string button_label; // button text; "" -> use cell value
|
||||
std::string button_color_hex; // optional button color; "" -> default
|
||||
|
||||
// Tooltip (Phase 2, v1.2.0): per-cell hover tooltip
|
||||
std::string tooltip; // text; "auto" -> show cell value
|
||||
bool tooltip_on_hover = false; // if true, show on hover
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -241,6 +273,13 @@ struct State {
|
||||
std::vector<bool> col_visible;
|
||||
std::vector<int> col_order;
|
||||
|
||||
// aux_column_specs (Phase 2, v1.2.0): TQL-persisted column specs sidecar.
|
||||
// Parallel to tables[]; aux_column_specs[0] corresponds to tables[0], etc.
|
||||
// Empty = no TQL-persisted specs. When non-empty, render() merges these
|
||||
// into TableInput.column_specs if the caller passed an empty column_specs.
|
||||
// Caller-provided column_specs take precedence over aux_column_specs.
|
||||
std::vector<std::vector<ColumnSpec>> aux_column_specs;
|
||||
|
||||
// Helpers (definidos en compute_stage.cpp).
|
||||
Stage& raw();
|
||||
const Stage& raw() const;
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
// tql_apply.cpp — TQL parser using Lua 5.4 C API directly (no lua_engine).
|
||||
// See tql_apply.h for documentation.
|
||||
// Promoted from primitives_gallery/playground/tables/tql.cpp (apply()).
|
||||
|
||||
#include "core/tql_apply.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
extern "C" {
|
||||
#include "lua.h"
|
||||
#include "lualib.h"
|
||||
#include "lauxlib.h"
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace tql {
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
// Find col index in a header list; returns -1 if not found.
|
||||
int find_col(const std::vector<std::string>& headers, const std::string& name) {
|
||||
for (size_t i = 0; i < headers.size(); ++i)
|
||||
if (headers[i] == name) return (int)i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Safe lua_tostring wrapper that never returns null.
|
||||
std::string lua_to_str(lua_State* L, int idx) {
|
||||
if (lua_isnil(L, idx)) return {};
|
||||
if (lua_isboolean(L, idx)) return lua_toboolean(L, idx) ? "true" : "false";
|
||||
size_t n = 0;
|
||||
const char* s = luaL_tolstring(L, idx, &n);
|
||||
std::string out(s, n);
|
||||
lua_pop(L, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // anon
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply
|
||||
// ---------------------------------------------------------------------------
|
||||
ApplyResult apply(const std::string& lua_text,
|
||||
const std::vector<std::string>& available_headers)
|
||||
{
|
||||
ApplyResult res;
|
||||
State& state = res.state;
|
||||
|
||||
auto warn = [&](std::string msg) {
|
||||
res.warnings.push_back(std::move(msg));
|
||||
};
|
||||
|
||||
// Create a fresh Lua state for parsing only (no engine libs needed).
|
||||
lua_State* L = luaL_newstate();
|
||||
if (!L) {
|
||||
res.error = "luaL_newstate failed";
|
||||
return res;
|
||||
}
|
||||
luaL_openlibs(L); // needed for string coercions (luaL_tolstring)
|
||||
|
||||
// Load and execute the TQL chunk.
|
||||
if (luaL_loadbufferx(L, lua_text.data(), lua_text.size(), "tql", "t") != LUA_OK) {
|
||||
res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "load error";
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
if (lua_pcall(L, 0, 1, 0) != LUA_OK) {
|
||||
res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "exec error";
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
if (!lua_istable(L, -1)) {
|
||||
res.error = "TQL root must be a table";
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
|
||||
// version check
|
||||
lua_getfield(L, -1, "version");
|
||||
if (lua_isnil(L, -1)) {
|
||||
warn("version missing (assuming 1)");
|
||||
} else if (!lua_isnumber(L, -1)) {
|
||||
res.error = "version must be a number";
|
||||
lua_close(L);
|
||||
return res;
|
||||
} else {
|
||||
int v = (int)lua_tointeger(L, -1);
|
||||
if (v != 1) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"unsupported TQL version %d (expected 1)", v);
|
||||
res.error = buf;
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// main_source
|
||||
lua_getfield(L, -1, "main_source");
|
||||
if (lua_isstring(L, -1)) state.main_source = lua_tostring(L, -1);
|
||||
else state.main_source.clear();
|
||||
lua_pop(L, 1);
|
||||
|
||||
// display
|
||||
lua_getfield(L, -1, "display");
|
||||
if (lua_isstring(L, -1)) {
|
||||
std::string d = lua_tostring(L, -1);
|
||||
ViewMode m = view_mode_from_token(d.c_str());
|
||||
state.display = m;
|
||||
if (view_mode_token(m) != d) {
|
||||
warn("unknown display \"" + d + "\" (defaulting to table)");
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Reset mutable state.
|
||||
state.stages.clear();
|
||||
state.active_stage = 0;
|
||||
state.color_rules.clear();
|
||||
state.joins.clear();
|
||||
|
||||
// ---- joins ----
|
||||
lua_getfield(L, -1, "joins");
|
||||
if (lua_istable(L, -1)) {
|
||||
int nj = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= nj; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
Join jn;
|
||||
|
||||
lua_getfield(L, -1, "alias");
|
||||
if (lua_isstring(L, -1)) jn.alias = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "source");
|
||||
if (lua_isstring(L, -1)) jn.source = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "strategy");
|
||||
if (lua_isstring(L, -1))
|
||||
jn.strategy = join_strategy_from_token(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "on");
|
||||
if (lua_istable(L, -1)) {
|
||||
int on_n = (int)lua_rawlen(L, -1);
|
||||
for (int k = 1; k <= on_n; ++k) {
|
||||
lua_rawgeti(L, -1, k);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) {
|
||||
lua_rawgeti(L, -1, 1); std::string a = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 2); std::string b = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
jn.on.push_back({a, b});
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "fields");
|
||||
if (lua_istable(L, -1)) {
|
||||
int fn_n = (int)lua_rawlen(L, -1);
|
||||
for (int k = 1; k <= fn_n; ++k) {
|
||||
lua_rawgeti(L, -1, k);
|
||||
if (lua_isstring(L, -1)) jn.fields.emplace_back(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
state.joins.push_back(std::move(jn));
|
||||
lua_pop(L, 1); // pop join entry
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // joins
|
||||
|
||||
// ---- stages ----
|
||||
lua_getfield(L, -1, "stages");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n_stages = (int)lua_rawlen(L, -1);
|
||||
// cur_headers: tracks effective headers stage by stage for filter col lookup.
|
||||
std::vector<std::string> cur_headers = available_headers;
|
||||
|
||||
for (int si = 1; si <= n_stages; ++si) {
|
||||
lua_rawgeti(L, -1, si);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
Stage stg;
|
||||
|
||||
// expressions (stage 0 only in practice; accepted in any stage for symmetry)
|
||||
lua_getfield(L, -1, "expressions");
|
||||
if (lua_istable(L, -1)) {
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, -2) != 0) {
|
||||
if (lua_isstring(L, -2) && lua_isstring(L, -1)) {
|
||||
DerivedColumn d;
|
||||
d.source_col = -1;
|
||||
d.name = lua_tostring(L, -2);
|
||||
d.formula = lua_tostring(L, -1);
|
||||
d.lua_id = -1; // caller must compile
|
||||
d.type = ColumnType::String;
|
||||
stg.derived.push_back(std::move(d));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // expressions
|
||||
|
||||
// filter
|
||||
lua_getfield(L, -1, "filter");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 3) {
|
||||
lua_rawgeti(L, -1, 1); std::string op = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 3); std::string val = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
int ci = find_col(cur_headers, col);
|
||||
if (ci >= 0) {
|
||||
stg.filters.push_back({ci, op_from_label(op.c_str()), val});
|
||||
} else if (!cur_headers.empty()) {
|
||||
// Only warn when headers were provided (validation mode).
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": filter col \"" + col + "\" not found");
|
||||
} else {
|
||||
// No headers: store filter with col=-1 as placeholder.
|
||||
stg.filters.push_back({-1, op_from_label(op.c_str()), val});
|
||||
}
|
||||
// Validate op token.
|
||||
if (op != "=" && op != "!=" && op != ">" && op != ">=" &&
|
||||
op != "<" && op != "<=" && op != "contains" &&
|
||||
op != "!contains" && op != "starts" && op != "ends") {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": unknown filter op \"" + op + "\" (defaulting to =)");
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // filter
|
||||
|
||||
// breakout
|
||||
lua_getfield(L, -1, "breakout");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_isstring(L, -1)) {
|
||||
std::string bn = lua_tostring(L, -1);
|
||||
if (!cur_headers.empty()) {
|
||||
std::string clean;
|
||||
parse_breakout_granularity(bn, clean);
|
||||
if (find_col(cur_headers, clean) < 0) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": breakout col \"" + clean +
|
||||
"\" not in input headers");
|
||||
}
|
||||
}
|
||||
stg.breakouts.emplace_back(std::move(bn));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // breakout
|
||||
|
||||
// aggregation
|
||||
lua_getfield(L, -1, "aggregation");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 1) {
|
||||
Aggregation a;
|
||||
lua_rawgeti(L, -1, 1);
|
||||
std::string fn_name = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
const bool known = (fn_name == "count" || fn_name == "sum" ||
|
||||
fn_name == "avg" || fn_name == "min" ||
|
||||
fn_name == "max" || fn_name == "distinct" ||
|
||||
fn_name == "stddev" || fn_name == "median" ||
|
||||
fn_name == "p25" || fn_name == "p75" ||
|
||||
fn_name == "p90" || fn_name == "p99" ||
|
||||
fn_name == "percentile");
|
||||
if (!known) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": unknown aggregation fn \"" + fn_name +
|
||||
"\" (defaulting to count)");
|
||||
}
|
||||
a.fn = agg_fn_from_string(fn_name);
|
||||
if ((int)lua_rawlen(L, -1) >= 2) {
|
||||
lua_rawgeti(L, -1, 2);
|
||||
a.col = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
if (a.fn != AggFn::Count && !cur_headers.empty() &&
|
||||
find_col(cur_headers, a.col) < 0) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": aggregation col \"" + a.col +
|
||||
"\" not in input headers");
|
||||
}
|
||||
} else if (a.fn != AggFn::Count) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": aggregation \"" + fn_name + "\" requires a column");
|
||||
}
|
||||
if ((int)lua_rawlen(L, -1) >= 3) {
|
||||
lua_rawgeti(L, -1, 3);
|
||||
if (lua_isnumber(L, -1)) a.arg = lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
stg.aggregations.push_back(std::move(a));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // aggregation
|
||||
|
||||
// sort
|
||||
lua_getfield(L, -1, "sort");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) {
|
||||
lua_rawgeti(L, -1, 1); std::string dir = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
if (dir != "asc" && dir != "desc") {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": unknown sort dir \"" + dir + "\" (defaulting to asc)");
|
||||
}
|
||||
stg.sorts.push_back({col, dir == "desc"});
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // sort
|
||||
|
||||
state.stages.push_back(std::move(stg));
|
||||
|
||||
// Advance cur_headers for next stage col resolution.
|
||||
const Stage& last = state.stages.back();
|
||||
if (si == 1) {
|
||||
// Stage 0: cur_headers = orig + derived names.
|
||||
for (const auto& d : last.derived) cur_headers.push_back(d.name);
|
||||
} else {
|
||||
if (!last.breakouts.empty() || !last.aggregations.empty()) {
|
||||
std::vector<std::string> next;
|
||||
for (const auto& b : last.breakouts) next.push_back(b);
|
||||
for (const auto& a : last.aggregations) next.push_back(aggregation_alias(a));
|
||||
cur_headers = std::move(next);
|
||||
}
|
||||
}
|
||||
|
||||
lua_pop(L, 1); // pop stage entry
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // stages
|
||||
|
||||
// ensure_stage0 equivalent: at least one stage.
|
||||
if (state.stages.empty()) state.stages.push_back(Stage{});
|
||||
if (state.active_stage < 0) state.active_stage = 0;
|
||||
if (state.active_stage >= (int)state.stages.size())
|
||||
state.active_stage = (int)state.stages.size() - 1;
|
||||
|
||||
// ---- columns (per-col render config) ----
|
||||
int orig_cols = (int)available_headers.size();
|
||||
int eff_cols = orig_cols + (int)state.stages[0].derived.size();
|
||||
|
||||
lua_getfield(L, -1, "columns");
|
||||
if (lua_istable(L, -1) && eff_cols > 0) {
|
||||
state.col_visible.assign(eff_cols, true);
|
||||
std::vector<std::pair<int,int>> order_pairs;
|
||||
std::vector<bool> seen(eff_cols, false);
|
||||
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
lua_getfield(L, -1, "name");
|
||||
std::string nm = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
int col_idx = find_col(available_headers, nm);
|
||||
if (col_idx < 0) {
|
||||
// Check derived.
|
||||
const auto& der = state.stages[0].derived;
|
||||
for (int di = 0; di < (int)der.size(); ++di) {
|
||||
if (der[di].name == nm) { col_idx = orig_cols + di; break; }
|
||||
}
|
||||
}
|
||||
if (col_idx < 0 || col_idx >= eff_cols) { lua_pop(L, 1); continue; }
|
||||
seen[col_idx] = true;
|
||||
|
||||
lua_getfield(L, -1, "visible");
|
||||
if (lua_isboolean(L, -1))
|
||||
state.col_visible[col_idx] = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "order");
|
||||
int order_val = lua_isnumber(L, -1)
|
||||
? (int)lua_tointeger(L, -1) : (col_idx + 1);
|
||||
lua_pop(L, 1);
|
||||
order_pairs.emplace_back(order_val, col_idx);
|
||||
|
||||
// type: only mutable for derived cols.
|
||||
lua_getfield(L, -1, "type");
|
||||
if (lua_isstring(L, -1)) {
|
||||
ColumnType t = column_type_from_string(lua_tostring(L, -1));
|
||||
if (col_idx >= orig_cols && col_idx - orig_cols < (int)state.stages[0].derived.size())
|
||||
state.stages[0].derived[col_idx - orig_cols].type = t;
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// color_rules
|
||||
lua_getfield(L, -1, "color_rules");
|
||||
if (lua_istable(L, -1)) {
|
||||
int rn = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= rn; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_istable(L, -1)) {
|
||||
lua_getfield(L, -1, "equals");
|
||||
std::string eq = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "color");
|
||||
std::string hx = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
state.color_rules.push_back({col_idx, eq, hex_to_color(hx)});
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // color_rules
|
||||
|
||||
lua_pop(L, 1); // col entry
|
||||
}
|
||||
|
||||
std::sort(order_pairs.begin(), order_pairs.end());
|
||||
state.col_order.clear();
|
||||
for (auto& p : order_pairs) state.col_order.push_back(p.second);
|
||||
for (int c = 0; c < eff_cols; ++c)
|
||||
if (!seen[c]) state.col_order.push_back(c);
|
||||
}
|
||||
lua_pop(L, 1); // columns
|
||||
|
||||
// ---- views (viz panels) ----
|
||||
state.extra_panels.clear();
|
||||
lua_getfield(L, -1, "views");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
VizPanel p;
|
||||
lua_getfield(L, -1, "display");
|
||||
if (lua_isstring(L, -1))
|
||||
p.display = view_mode_from_token(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
|
||||
auto read_str = [&](const char* key, std::string& out_s) {
|
||||
lua_getfield(L, -1, key);
|
||||
if (lua_isstring(L, -1)) out_s = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
};
|
||||
read_str("x_col", p.config.x_col);
|
||||
read_str("cat_col", p.config.cat_col);
|
||||
read_str("size_col", p.config.size_col);
|
||||
|
||||
lua_getfield(L, -1, "y_cols");
|
||||
if (lua_istable(L, -1)) {
|
||||
int yn = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= yn; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_isstring(L, -1)) p.config.y_cols.emplace_back(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "color");
|
||||
if (lua_isstring(L, -1)) p.config.primary_color = hex_to_color(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "hist_bins");
|
||||
if (lua_isnumber(L, -1)) p.config.hist_bins = (int)lua_tointeger(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "pie_radius");
|
||||
if (lua_isnumber(L, -1)) p.config.pie_radius = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "show_legend");
|
||||
if (lua_isboolean(L, -1)) p.config.show_legend = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "show_markers");
|
||||
if (lua_isboolean(L, -1)) p.config.show_markers = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "locked");
|
||||
if (lua_isboolean(L, -1)) p.config.locked = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Panel 0 = main viz (state.display + state.viz_config).
|
||||
if (i == 1) {
|
||||
state.display = p.display;
|
||||
state.viz_config = p.config;
|
||||
} else {
|
||||
state.extra_panels.push_back(std::move(p));
|
||||
}
|
||||
lua_pop(L, 1); // panel entry
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // views
|
||||
|
||||
// ---- column_specs (Phase 2, v1.2.0) ----
|
||||
// Populate state.aux_column_specs[0] from optional "column_specs" block.
|
||||
state.aux_column_specs.clear();
|
||||
lua_getfield(L, -1, "column_specs");
|
||||
if (lua_istable(L, -1)) {
|
||||
std::vector<data_table::ColumnSpec> specs;
|
||||
int n_cs = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n_cs; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
data_table::ColumnSpec cs;
|
||||
|
||||
lua_getfield(L, -1, "id");
|
||||
if (lua_isstring(L, -1)) cs.id = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "renderer");
|
||||
if (lua_isstring(L, -1)) {
|
||||
std::string rn = lua_tostring(L, -1);
|
||||
if (rn == "badge") cs.renderer = data_table::CellRenderer::Badge;
|
||||
else if (rn == "progress") cs.renderer = data_table::CellRenderer::Progress;
|
||||
else if (rn == "duration") cs.renderer = data_table::CellRenderer::Duration;
|
||||
else if (rn == "icon") cs.renderer = data_table::CellRenderer::Icon;
|
||||
else if (rn == "button") cs.renderer = data_table::CellRenderer::Button;
|
||||
else cs.renderer = data_table::CellRenderer::Text;
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Badge rules
|
||||
lua_getfield(L, -1, "badges");
|
||||
if (lua_istable(L, -1)) {
|
||||
int nb = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= nb; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_istable(L, -1)) {
|
||||
data_table::BadgeRule br;
|
||||
lua_getfield(L, -1, "value");
|
||||
if (lua_isstring(L, -1)) br.value = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "color");
|
||||
if (lua_isstring(L, -1)) br.color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "label");
|
||||
if (lua_isstring(L, -1)) br.label = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
cs.badges.push_back(std::move(br));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // badges
|
||||
|
||||
// Progress
|
||||
lua_getfield(L, -1, "progress_scale_100");
|
||||
if (lua_isboolean(L, -1)) cs.progress_scale_100 = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "progress_color");
|
||||
if (lua_isstring(L, -1)) cs.progress_color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Duration
|
||||
lua_getfield(L, -1, "warn_ms");
|
||||
if (lua_isnumber(L, -1)) cs.duration_warn_ms = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "error_ms");
|
||||
if (lua_isnumber(L, -1)) cs.duration_error_ms = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Icon map
|
||||
lua_getfield(L, -1, "icon_map");
|
||||
if (lua_istable(L, -1)) {
|
||||
int ni = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= ni; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_istable(L, -1)) {
|
||||
data_table::IconMapEntry ie;
|
||||
lua_getfield(L, -1, "value");
|
||||
if (lua_isstring(L, -1)) ie.value = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "icon");
|
||||
if (lua_isstring(L, -1)) ie.icon_name = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "color");
|
||||
if (lua_isstring(L, -1)) ie.color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
cs.icon_map.push_back(std::move(ie));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // icon_map
|
||||
|
||||
// Button
|
||||
lua_getfield(L, -1, "button_action");
|
||||
if (lua_isstring(L, -1)) cs.button_action = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "button_label");
|
||||
if (lua_isstring(L, -1)) cs.button_label = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "button_color");
|
||||
if (lua_isstring(L, -1)) cs.button_color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Tooltip
|
||||
lua_getfield(L, -1, "tooltip");
|
||||
if (lua_isstring(L, -1)) cs.tooltip = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "tooltip_on_hover");
|
||||
if (lua_isboolean(L, -1)) cs.tooltip_on_hover = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
specs.push_back(std::move(cs));
|
||||
lua_pop(L, 1); // spec entry
|
||||
}
|
||||
if (!specs.empty()) {
|
||||
state.aux_column_specs.push_back(std::move(specs));
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // column_specs
|
||||
|
||||
lua_pop(L, 1); // root
|
||||
lua_close(L);
|
||||
|
||||
res.ok = true;
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply (extended overload) — playground-compatible bool-returning wrapper.
|
||||
// ---------------------------------------------------------------------------
|
||||
bool apply(const std::string& lua_text,
|
||||
data_table::State& st,
|
||||
const std::vector<std::string>& orig_headers,
|
||||
const std::vector<data_table::ColumnType>& /*orig_types*/,
|
||||
const char* const* /*cells*/,
|
||||
int /*rows*/,
|
||||
int /*orig_cols*/,
|
||||
std::string* err)
|
||||
{
|
||||
ApplyResult res = apply(lua_text, orig_headers);
|
||||
if (res.ok) {
|
||||
st = std::move(res.state);
|
||||
} else {
|
||||
if (err) *err = res.error;
|
||||
}
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
} // namespace tql
|
||||
@@ -0,0 +1,546 @@
|
||||
// tql_apply_test.cpp — Tests for tql_apply (TQL Lua parser).
|
||||
// Run with: cmake --build cpp/build --target tql_apply_test && cpp/build/tql_apply_test
|
||||
// Or via: ./fn run tql_apply_cpp_core
|
||||
|
||||
#include "core/tql_apply.h"
|
||||
#include "core/tql_emit.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
static void check(bool cond, const char* label) {
|
||||
if (cond) {
|
||||
std::printf("PASS: %s\n", label);
|
||||
++g_pass;
|
||||
} else {
|
||||
std::printf("FAIL: %s\n", label);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: parse minimal TQL chunk
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_minimal() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "parse minimal: ok=true");
|
||||
check(res.error.empty(), "parse minimal: no error");
|
||||
check(res.state.display == ViewMode::Table, "parse minimal: display=Table");
|
||||
check(!res.state.stages.empty(), "parse minimal: stages not empty");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: parse display=bar
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_display() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "bar",
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
check(res.ok, "parse display: ok");
|
||||
check(res.state.display == ViewMode::Bar, "parse display: display=Bar");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: filter parsing with known header
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_filter_known_col() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
filter = {
|
||||
{"=", "name", "Alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "filter known col: ok");
|
||||
check(res.warnings.empty(), "filter known col: no warnings");
|
||||
check(!res.state.stages.empty(), "filter known col: has stages");
|
||||
check(res.state.stages[0].filters.size() == 1, "filter known col: 1 filter");
|
||||
check(res.state.stages[0].filters[0].col == 0, "filter known col: col=0");
|
||||
check(res.state.stages[0].filters[0].op == Op::Eq,"filter known col: op=Eq");
|
||||
check(res.state.stages[0].filters[0].value == "Alice", "filter known col: value=Alice");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: unknown column generates warning
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_filter_unknown_col() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
filter = {
|
||||
{"=", "nonexistent_col", "x"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "unknown col: ok (warning not error)");
|
||||
check(!res.warnings.empty(), "unknown col: warning generated");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: sort parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_sort() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
sort = {
|
||||
{"desc", "age"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "sort: ok");
|
||||
check(res.state.stages[0].sorts.size() == 1, "sort: 1 sort clause");
|
||||
check(res.state.stages[0].sorts[0].col == "age", "sort: col=age");
|
||||
check(res.state.stages[0].sorts[0].desc == true, "sort: desc=true");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: aggregation stage parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_aggregation() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{},
|
||||
{
|
||||
breakout = {"dept"},
|
||||
aggregation = {
|
||||
{"sum", "salary"},
|
||||
{"count"},
|
||||
},
|
||||
sort = {
|
||||
{"asc", "dept"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "aggregation: ok");
|
||||
check(res.state.stages.size() == 2, "aggregation: 2 stages");
|
||||
const Stage& s1 = res.state.stages[1];
|
||||
check(s1.breakouts.size() == 1, "aggregation: 1 breakout");
|
||||
check(s1.breakouts[0] == "dept", "aggregation: breakout=dept");
|
||||
check(s1.aggregations.size() == 2, "aggregation: 2 aggs");
|
||||
check(s1.aggregations[0].fn == AggFn::Sum, "aggregation: [0] fn=Sum");
|
||||
check(s1.aggregations[0].col == "salary", "aggregation: [0] col=salary");
|
||||
check(s1.aggregations[1].fn == AggFn::Count, "aggregation: [1] fn=Count");
|
||||
check(s1.sorts.size() == 1, "aggregation: 1 sort");
|
||||
check(s1.sorts[0].col == "dept", "aggregation: sort col=dept");
|
||||
check(s1.sorts[0].desc == false, "aggregation: sort asc");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: expression (formula) stored verbatim, lua_id=-1
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_expressions() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
expressions = {
|
||||
["total"] = "return [price] * [qty]",
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "expr: ok");
|
||||
check(res.state.stages[0].derived.size() == 1, "expr: 1 derived col");
|
||||
check(res.state.stages[0].derived[0].name == "total", "expr: name=total");
|
||||
check(res.state.stages[0].derived[0].formula == "return [price] * [qty]",
|
||||
"expr: formula stored verbatim");
|
||||
check(res.state.stages[0].derived[0].lua_id == -1, "expr: lua_id=-1 (not compiled)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: views parsing (extra viz panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_views() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {
|
||||
{display = "bar", x_col = "month", y_cols = {"revenue", "cost"}},
|
||||
{display = "line", x_col = "date"},
|
||||
},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "views: ok");
|
||||
// Panel 0 sets state.display + state.viz_config.
|
||||
check(res.state.display == ViewMode::Bar, "views: display=Bar (from panel 0)");
|
||||
check(res.state.viz_config.x_col == "month", "views: x_col=month");
|
||||
check(res.state.viz_config.y_cols.size() == 2, "views: y_cols has 2 entries");
|
||||
check(res.state.viz_config.y_cols[0] == "revenue", "views: y_cols[0]=revenue");
|
||||
// Panel 1 goes into extra_panels.
|
||||
check(res.state.extra_panels.size() == 1, "views: 1 extra panel");
|
||||
check(res.state.extra_panels[0].display == ViewMode::Line, "views: extra=Line");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: join parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_join() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
joins = {
|
||||
{alias = "d", source = "departments", strategy = "inner",
|
||||
on = {{"dept_id", "id"}}, fields = {"dept_name"}},
|
||||
},
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "join: ok");
|
||||
check(res.state.joins.size() == 1, "join: 1 join");
|
||||
check(res.state.joins[0].alias == "d", "join: alias=d");
|
||||
check(res.state.joins[0].source == "departments", "join: source=departments");
|
||||
check(res.state.joins[0].strategy == JoinStrategy::Inner, "join: strategy=Inner");
|
||||
check(res.state.joins[0].on.size() == 1, "join: 1 on clause");
|
||||
check(res.state.joins[0].on[0].first == "dept_id", "join: on left=dept_id");
|
||||
check(res.state.joins[0].on[0].second == "id", "join: on right=id");
|
||||
check(res.state.joins[0].fields.size() == 1, "join: 1 field");
|
||||
check(res.state.joins[0].fields[0] == "dept_name", "join: field=dept_name");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: version mismatch returns error
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_version_mismatch() {
|
||||
const char* tql_text = R"(
|
||||
return { version = 99, display = "table", stages = {{}}, columns = {}, views = {} }
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(!res.ok, "version mismatch: ok=false");
|
||||
check(!res.error.empty(), "version mismatch: error set");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: invalid Lua syntax returns error
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_invalid_lua() {
|
||||
const char* bad = "this is not lua %%%";
|
||||
auto res = tql::apply(bad, {});
|
||||
|
||||
check(!res.ok, "invalid lua: ok=false");
|
||||
check(!res.error.empty(), "invalid lua: error set");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: roundtrip emit -> apply -> emit produces same display/stages
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip() {
|
||||
// Build a state with a filter and a grouped stage.
|
||||
State st;
|
||||
|
||||
Stage s0;
|
||||
s0.filters.push_back({3, Op::Neq, "inactive"}); // status is index 3
|
||||
s0.sorts.push_back({"name", false});
|
||||
st.stages.push_back(s0);
|
||||
|
||||
Stage s1;
|
||||
s1.breakouts.push_back("dept");
|
||||
Aggregation a;
|
||||
a.fn = AggFn::Avg;
|
||||
a.col = "salary";
|
||||
s1.aggregations.push_back(a);
|
||||
st.stages.push_back(s1);
|
||||
|
||||
st.display = ViewMode::Bar;
|
||||
st.viz_config.x_col = "dept";
|
||||
st.viz_config.y_cols = {"avg_salary"};
|
||||
|
||||
std::vector<std::string> headers = {"name", "dept", "salary", "status"};
|
||||
std::vector<ColumnType> types = {
|
||||
ColumnType::String, ColumnType::String, ColumnType::Float, ColumnType::String
|
||||
};
|
||||
|
||||
// Emit.
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
check(!tql_text.empty(), "roundtrip: emit produced text");
|
||||
|
||||
// Apply with the same headers.
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
check(res.ok, "roundtrip: apply ok");
|
||||
check(res.warnings.empty(), "roundtrip: no warnings");
|
||||
|
||||
const State& st2 = res.state;
|
||||
|
||||
check(st2.display == ViewMode::Bar, "roundtrip: display=Bar");
|
||||
check(st2.stages.size() == 2, "roundtrip: 2 stages");
|
||||
|
||||
// Stage 0 filter.
|
||||
check(st2.stages[0].filters.size() == 1, "roundtrip: s0 1 filter");
|
||||
check(st2.stages[0].filters[0].col == 3, "roundtrip: s0 filter col=3 (status)");
|
||||
check(st2.stages[0].filters[0].op == Op::Neq, "roundtrip: s0 filter op=Neq");
|
||||
check(st2.stages[0].filters[0].value == "inactive", "roundtrip: s0 filter value=inactive");
|
||||
|
||||
// Stage 0 sort.
|
||||
check(st2.stages[0].sorts.size() == 1, "roundtrip: s0 1 sort");
|
||||
check(st2.stages[0].sorts[0].col == "name", "roundtrip: s0 sort col=name");
|
||||
check(st2.stages[0].sorts[0].desc == false, "roundtrip: s0 sort asc");
|
||||
|
||||
// Stage 1 breakout.
|
||||
check(st2.stages[1].breakouts.size() == 1, "roundtrip: s1 breakout");
|
||||
check(st2.stages[1].breakouts[0] == "dept", "roundtrip: s1 breakout=dept");
|
||||
|
||||
// Stage 1 aggregation.
|
||||
check(st2.stages[1].aggregations.size() == 1, "roundtrip: s1 1 agg");
|
||||
check(st2.stages[1].aggregations[0].fn == AggFn::Avg, "roundtrip: s1 agg fn=Avg");
|
||||
check(st2.stages[1].aggregations[0].col == "salary", "roundtrip: s1 agg col=salary");
|
||||
|
||||
// viz_config.
|
||||
check(st2.viz_config.x_col == "dept", "roundtrip: viz x_col=dept");
|
||||
check(st2.viz_config.y_cols.size() == 1, "roundtrip: viz 1 y_col");
|
||||
check(st2.viz_config.y_cols[0] == "avg_salary", "roundtrip: viz y_col=avg_salary");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column visibility and order preserved through roundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_col_order() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.col_visible = {true, false, true};
|
||||
st.col_order = {2, 0, 1}; // custom order
|
||||
|
||||
std::vector<std::string> headers = {"a", "b", "c"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Int, ColumnType::Float};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "col order roundtrip: ok");
|
||||
check(res.state.col_visible.size() == 3, "col order roundtrip: 3 visible entries");
|
||||
check(res.state.col_visible[1] == false, "col order roundtrip: col b invisible");
|
||||
// col_order after roundtrip should match the original (sorted by order value).
|
||||
check(res.state.col_order.size() == 3, "col order roundtrip: 3 order entries");
|
||||
check(res.state.col_order[0] == 2, "col order roundtrip: first=c (idx 2)");
|
||||
check(res.state.col_order[1] == 0, "col order roundtrip: second=a (idx 0)");
|
||||
check(res.state.col_order[2] == 1, "col order roundtrip: third=b (idx 1)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: color rule roundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_color_rules() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
ColorRule cr{0, "Alice", 0xFF2244FFu};
|
||||
st.color_rules.push_back(cr);
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "color rule roundtrip: ok");
|
||||
check(res.state.color_rules.size() == 1, "color rule roundtrip: 1 rule");
|
||||
check(res.state.color_rules[0].col == 0, "color rule roundtrip: col=0");
|
||||
check(res.state.color_rules[0].equals == "Alice", "color rule roundtrip: equals=Alice");
|
||||
check(res.state.color_rules[0].color == cr.color, "color rule roundtrip: color matches");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column_specs roundtrip — Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_column_specs_badge() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "status";
|
||||
cs.renderer = CellRenderer::Badge;
|
||||
cs.badges = { BadgeRule{"ok", "#22c55e", "OK"}, BadgeRule{"error", "#ef4444", ""} };
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"status"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "cs badge roundtrip: ok");
|
||||
check(!res.state.aux_column_specs.empty(), "cs badge roundtrip: aux_column_specs non-empty");
|
||||
check(!res.state.aux_column_specs[0].empty(), "cs badge roundtrip: specs[0] non-empty");
|
||||
check(res.state.aux_column_specs[0][0].renderer == CellRenderer::Badge,
|
||||
"cs badge roundtrip: renderer=Badge");
|
||||
check(res.state.aux_column_specs[0][0].id == "status", "cs badge roundtrip: id=status");
|
||||
check(res.state.aux_column_specs[0][0].badges.size() == 2, "cs badge roundtrip: 2 badge rules");
|
||||
check(res.state.aux_column_specs[0][0].badges[0].value == "ok", "cs badge roundtrip: badge[0].value=ok");
|
||||
check(res.state.aux_column_specs[0][0].badges[0].label == "OK", "cs badge roundtrip: badge[0].label=OK");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column_specs roundtrip — Button
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_column_specs_button() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "actions";
|
||||
cs.renderer = CellRenderer::Button;
|
||||
cs.button_action = "cancel";
|
||||
cs.button_label = "Cancel";
|
||||
cs.button_color_hex = "#ef4444";
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name", "actions"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "cs button roundtrip: ok");
|
||||
check(!res.state.aux_column_specs.empty() &&
|
||||
!res.state.aux_column_specs[0].empty(), "cs button roundtrip: specs present");
|
||||
const auto& cs2 = res.state.aux_column_specs[0][0];
|
||||
check(cs2.renderer == CellRenderer::Button, "cs button roundtrip: renderer=Button");
|
||||
check(cs2.button_action == "cancel", "cs button roundtrip: button_action=cancel");
|
||||
check(cs2.button_label == "Cancel", "cs button roundtrip: button_label=Cancel");
|
||||
check(cs2.button_color_hex == "#ef4444", "cs button roundtrip: button_color");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column_specs roundtrip — Tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_column_specs_tooltip() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "name";
|
||||
cs.renderer = CellRenderer::Text;
|
||||
cs.tooltip = "auto";
|
||||
cs.tooltip_on_hover = true;
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "cs tooltip roundtrip: ok");
|
||||
check(!res.state.aux_column_specs.empty() &&
|
||||
!res.state.aux_column_specs[0].empty(), "cs tooltip roundtrip: specs present");
|
||||
const auto& cs2 = res.state.aux_column_specs[0][0];
|
||||
check(cs2.tooltip == "auto", "cs tooltip roundtrip: tooltip=auto");
|
||||
check(cs2.tooltip_on_hover == true, "cs tooltip roundtrip: tooltip_on_hover=true");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
int main() {
|
||||
test_parse_minimal();
|
||||
test_parse_display();
|
||||
test_parse_filter_known_col();
|
||||
test_parse_filter_unknown_col();
|
||||
test_parse_sort();
|
||||
test_parse_aggregation();
|
||||
test_parse_expressions();
|
||||
test_parse_views();
|
||||
test_parse_join();
|
||||
test_parse_version_mismatch();
|
||||
test_parse_invalid_lua();
|
||||
test_roundtrip();
|
||||
test_roundtrip_col_order();
|
||||
test_roundtrip_color_rules();
|
||||
test_roundtrip_column_specs_badge();
|
||||
test_roundtrip_column_specs_button();
|
||||
test_roundtrip_column_specs_tooltip();
|
||||
|
||||
std::printf("---\nResults: %d passed, %d failed\n", g_pass, g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// tql_emit.cpp — Pure TQL serialization. See tql_emit.h.
|
||||
// Promoted from primitives_gallery/playground/tables/tql.cpp (emit()).
|
||||
|
||||
#include "core/tql_emit.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace tql {
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
std::string emit(const State& state,
|
||||
const std::vector<std::string>& headers,
|
||||
const std::vector<ColumnType>& types)
|
||||
{
|
||||
int orig_cols = (int)headers.size();
|
||||
const Stage& raw = state.stages.empty() ? Stage{} : state.stages[0];
|
||||
int eff_cols = orig_cols + (int)raw.derived.size();
|
||||
|
||||
// Build effective headers + types (same indexing as col_visible/order).
|
||||
std::vector<std::string> eff_headers(eff_cols);
|
||||
std::vector<ColumnType> eff_types(eff_cols);
|
||||
for (int c = 0; c < orig_cols; ++c) {
|
||||
eff_headers[c] = headers[c];
|
||||
eff_types[c] = (c < (int)types.size()) ? types[c] : ColumnType::Auto;
|
||||
}
|
||||
for (int k = 0; k < (int)raw.derived.size(); ++k) {
|
||||
eff_headers[orig_cols + k] = raw.derived[k].name;
|
||||
eff_types[orig_cols + k] = raw.derived[k].type;
|
||||
}
|
||||
|
||||
// Build order positions: col_idx -> visual order (1-based).
|
||||
std::unordered_map<int, int> order_pos;
|
||||
for (size_t i = 0; i < state.col_order.size(); ++i)
|
||||
order_pos[(int)state.col_order[i]] = (int)i + 1;
|
||||
|
||||
// ---- Lambda helpers ----
|
||||
auto emit_filter_block = [&](const std::vector<Filter>& filters,
|
||||
const std::vector<std::string>& stage_headers,
|
||||
const char* indent) -> std::string {
|
||||
if (filters.empty()) return {};
|
||||
std::string s;
|
||||
s += indent; s += "filter = {\n";
|
||||
for (const auto& f : filters) {
|
||||
std::string col_name = (f.col >= 0 && f.col < (int)stage_headers.size())
|
||||
? stage_headers[f.col] : "";
|
||||
s += indent; s += " {";
|
||||
s += lua_string_literal(op_label(f.op));
|
||||
s += ", ";
|
||||
s += lua_string_literal(col_name);
|
||||
s += ", ";
|
||||
s += lua_string_literal(f.value);
|
||||
s += "},\n";
|
||||
}
|
||||
s += indent; s += "},\n";
|
||||
return s;
|
||||
};
|
||||
|
||||
auto emit_sort_block = [&](const std::vector<SortClause>& sorts,
|
||||
const char* indent) -> std::string {
|
||||
if (sorts.empty()) return {};
|
||||
std::string s;
|
||||
s += indent; s += "sort = {\n";
|
||||
for (const auto& sc : sorts) {
|
||||
s += indent; s += " {";
|
||||
s += lua_string_literal(sc.desc ? "desc" : "asc");
|
||||
s += ", ";
|
||||
s += lua_string_literal(sc.col);
|
||||
s += "},\n";
|
||||
}
|
||||
s += indent; s += "},\n";
|
||||
return s;
|
||||
};
|
||||
|
||||
auto emit_view = [&](const VizPanel& p) -> std::string {
|
||||
std::string s = " {";
|
||||
s += "display = " + lua_string_literal(view_mode_token(p.display));
|
||||
if (!p.config.x_col.empty())
|
||||
s += ", x_col = " + lua_string_literal(p.config.x_col);
|
||||
if (!p.config.cat_col.empty())
|
||||
s += ", cat_col = " + lua_string_literal(p.config.cat_col);
|
||||
if (!p.config.size_col.empty())
|
||||
s += ", size_col = "+ lua_string_literal(p.config.size_col);
|
||||
if (!p.config.y_cols.empty()) {
|
||||
s += ", y_cols = {";
|
||||
for (size_t i = 0; i < p.config.y_cols.size(); ++i) {
|
||||
if (i) s += ", ";
|
||||
s += lua_string_literal(p.config.y_cols[i]);
|
||||
}
|
||||
s += "}";
|
||||
}
|
||||
if (p.config.primary_color != 0)
|
||||
s += ", color = " + lua_string_literal(color_to_hex(p.config.primary_color));
|
||||
if (p.config.hist_bins > 0)
|
||||
s += ", hist_bins = " + std::to_string(p.config.hist_bins);
|
||||
if (p.config.pie_radius > 0.0f)
|
||||
s += ", pie_radius = " + std::to_string(p.config.pie_radius);
|
||||
if (!p.config.show_legend) s += ", show_legend = false";
|
||||
if (p.config.show_markers) s += ", show_markers = true";
|
||||
if (p.config.locked) s += ", locked = true";
|
||||
s += "},\n";
|
||||
return s;
|
||||
};
|
||||
|
||||
// ---- Build output ----
|
||||
std::string out;
|
||||
out += "-- TQL v1 (Table Query Language). Round-trip de State <-> Lua.\n";
|
||||
out += "-- Schema:\n";
|
||||
out += "-- version = 1 -- bump si breaking change\n";
|
||||
out += "-- display = \"table\" -- table|bar|line|pie (futuro)\n";
|
||||
out += "-- stages = { stage0, stage1, ... } -- pipeline; stage 0 = Raw\n";
|
||||
out += "-- columns = { {name,type,visible,order,color_rules}, ... }\n";
|
||||
out += "--\n";
|
||||
out += "-- Stage 0 (Raw): filter + expressions + sort\n";
|
||||
out += "-- Stage N (Grouped): filter + breakout + aggregation + sort\n";
|
||||
out += "--\n";
|
||||
out += "-- filter: {{op, col, val}, ...} op in =,!=,>,>=,<,<=,contains,!contains,starts,ends\n";
|
||||
out += "-- expressions: {[name] = \"lua_body\"} ej: [\"total\"] = \"return [a] + [b]\"\n";
|
||||
out += "-- breakout: {\"col1\", \"col2\"} group by\n";
|
||||
out += "-- aggregation: {{fn, col, arg?}, ...} fn in count,sum,avg,min,max,distinct,stddev,median,p25,p75,p90,p99,percentile\n";
|
||||
out += "-- sort: {{dir, col}, ...} dir in asc,desc\n";
|
||||
out += "return {\n";
|
||||
out += " version = 1,\n";
|
||||
out += " display = ";
|
||||
out += lua_string_literal(view_mode_token(state.display));
|
||||
out += ",\n";
|
||||
if (!state.main_source.empty()) {
|
||||
out += " main_source = ";
|
||||
out += lua_string_literal(state.main_source);
|
||||
out += ",\n";
|
||||
}
|
||||
|
||||
// joins
|
||||
if (!state.joins.empty()) {
|
||||
out += " joins = {\n";
|
||||
for (const auto& jn : state.joins) {
|
||||
out += " {alias = " + lua_string_literal(jn.alias);
|
||||
out += ", source = " + lua_string_literal(jn.source);
|
||||
out += ", strategy = " + lua_string_literal(join_strategy_token(jn.strategy));
|
||||
out += ", on = {";
|
||||
for (size_t i = 0; i < jn.on.size(); ++i) {
|
||||
if (i) out += ", ";
|
||||
out += "{" + lua_string_literal(jn.on[i].first) + ", "
|
||||
+ lua_string_literal(jn.on[i].second) + "}";
|
||||
}
|
||||
out += "}";
|
||||
if (!jn.fields.empty()) {
|
||||
out += ", fields = {";
|
||||
for (size_t i = 0; i < jn.fields.size(); ++i) {
|
||||
if (i) out += ", ";
|
||||
out += lua_string_literal(jn.fields[i]);
|
||||
}
|
||||
out += "}";
|
||||
}
|
||||
out += "},\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
|
||||
out += " stages = {\n";
|
||||
|
||||
// Cur_headers tracks the output headers of each stage for filter/sort col name resolution.
|
||||
std::vector<std::string> cur_headers = headers;
|
||||
|
||||
for (int si = 0; si < (int)state.stages.size(); ++si) {
|
||||
const Stage& stg = state.stages[si];
|
||||
out += " {\n";
|
||||
|
||||
if (si == 0) {
|
||||
// Stage 0 (Raw): filter + expressions + sort.
|
||||
std::vector<std::string> s0_headers = headers;
|
||||
out += emit_filter_block(stg.filters, s0_headers, " ");
|
||||
|
||||
if (!stg.derived.empty()) {
|
||||
bool any_formula = false;
|
||||
for (const auto& d : stg.derived) if (!d.formula.empty()) { any_formula = true; break; }
|
||||
if (any_formula) {
|
||||
out += " expressions = {\n";
|
||||
for (const auto& d : stg.derived) {
|
||||
if (d.formula.empty()) continue;
|
||||
out += " [";
|
||||
out += lua_string_literal(d.name);
|
||||
out += "] = ";
|
||||
out += lua_string_literal(d.formula);
|
||||
out += ",\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
}
|
||||
|
||||
out += emit_sort_block(stg.sorts, " ");
|
||||
|
||||
// Advance cur_headers: orig + derived.
|
||||
for (const auto& d : stg.derived) cur_headers.push_back(d.name);
|
||||
|
||||
} else {
|
||||
// Stage 1+ (Grouped): filter + breakout + aggregation + sort.
|
||||
out += emit_filter_block(stg.filters, cur_headers, " ");
|
||||
|
||||
if (!stg.breakouts.empty()) {
|
||||
out += " breakout = {";
|
||||
for (size_t i = 0; i < stg.breakouts.size(); ++i) {
|
||||
if (i > 0) out += ", ";
|
||||
out += lua_string_literal(stg.breakouts[i]);
|
||||
}
|
||||
out += "},\n";
|
||||
}
|
||||
|
||||
if (!stg.aggregations.empty()) {
|
||||
out += " aggregation = {\n";
|
||||
for (const auto& a : stg.aggregations) {
|
||||
out += " {";
|
||||
out += lua_string_literal(agg_fn_token(a.fn));
|
||||
if (a.fn != AggFn::Count) {
|
||||
out += ", ";
|
||||
out += lua_string_literal(a.col);
|
||||
}
|
||||
if (a.fn == AggFn::Percentile) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "%g", a.arg);
|
||||
out += ", "; out += buf;
|
||||
}
|
||||
out += "},\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
|
||||
out += emit_sort_block(stg.sorts, " ");
|
||||
|
||||
// Advance cur_headers: breakouts + agg aliases.
|
||||
std::vector<std::string> next;
|
||||
for (const auto& b : stg.breakouts) next.push_back(b);
|
||||
for (const auto& a : stg.aggregations) next.push_back(aggregation_alias(a));
|
||||
cur_headers = std::move(next);
|
||||
}
|
||||
|
||||
out += " },\n";
|
||||
}
|
||||
out += " },\n";
|
||||
|
||||
// columns — per-col render config (effective cols of stage 0).
|
||||
out += " columns = {\n";
|
||||
for (int c = 0; c < eff_cols; ++c) {
|
||||
out += " {";
|
||||
out += "name = " + lua_string_literal(eff_headers[c]);
|
||||
out += ", type = " + lua_string_literal(column_type_name(eff_types[c]));
|
||||
bool vis = (c < (int)state.col_visible.size()) ? state.col_visible[c] : true;
|
||||
out += std::string(", visible = ") + (vis ? "true" : "false");
|
||||
int order = order_pos.count(c) ? order_pos.at(c) : c + 1;
|
||||
out += ", order = " + std::to_string(order);
|
||||
// color_rules for this col
|
||||
bool first = true;
|
||||
for (const auto& cr : state.color_rules) {
|
||||
if (cr.col != c) continue;
|
||||
if (first) { out += ", color_rules = {"; first = false; }
|
||||
else { out += ", "; }
|
||||
out += "{equals = " + lua_string_literal(cr.equals);
|
||||
out += ", color = " + lua_string_literal(color_to_hex(cr.color)) + "}";
|
||||
}
|
||||
if (!first) out += "}";
|
||||
out += "},\n";
|
||||
}
|
||||
out += " },\n";
|
||||
|
||||
// views — main viz panel (index 0) + extra_panels.
|
||||
out += " views = {\n";
|
||||
VizPanel main_p;
|
||||
main_p.display = state.display;
|
||||
main_p.config = state.viz_config;
|
||||
out += emit_view(main_p);
|
||||
for (const auto& p : state.extra_panels) out += emit_view(p);
|
||||
out += " },\n";
|
||||
|
||||
out += " visualization_settings = {},\n";
|
||||
|
||||
// column_specs (Phase 2, v1.2.0): TQL-persisted declarative renderer specs.
|
||||
// Only emitted when state.aux_column_specs is non-empty.
|
||||
// Format: column_specs = { { id="col_id", renderer="badge|progress|...", ... }, ... }
|
||||
if (!state.aux_column_specs.empty() && !state.aux_column_specs[0].empty()) {
|
||||
const auto& specs = state.aux_column_specs[0];
|
||||
// Emit the block only if at least one spec has a non-default renderer OR tooltip.
|
||||
bool any_renderable = false;
|
||||
for (const auto& cs : specs) {
|
||||
if (cs.renderer != data_table::CellRenderer::Text || cs.tooltip_on_hover) {
|
||||
any_renderable = true; break;
|
||||
}
|
||||
}
|
||||
if (any_renderable) {
|
||||
out += " column_specs = {\n";
|
||||
for (const auto& cs : specs) {
|
||||
if (cs.renderer == data_table::CellRenderer::Text && !cs.tooltip_on_hover)
|
||||
continue; // skip pure-text cols without extras
|
||||
out += " { id = " + lua_string_literal(cs.id);
|
||||
// renderer
|
||||
const char* rname = "text";
|
||||
switch (cs.renderer) {
|
||||
case data_table::CellRenderer::Badge: rname = "badge"; break;
|
||||
case data_table::CellRenderer::Progress: rname = "progress"; break;
|
||||
case data_table::CellRenderer::Duration: rname = "duration"; break;
|
||||
case data_table::CellRenderer::Icon: rname = "icon"; break;
|
||||
case data_table::CellRenderer::Button: rname = "button"; break;
|
||||
default: break;
|
||||
}
|
||||
out += ", renderer = " + lua_string_literal(rname);
|
||||
// Badge rules
|
||||
if (!cs.badges.empty()) {
|
||||
out += ", badges = {\n";
|
||||
for (const auto& br : cs.badges) {
|
||||
out += " { value = " + lua_string_literal(br.value);
|
||||
out += ", color = " + lua_string_literal(br.color_hex);
|
||||
if (!br.label.empty())
|
||||
out += ", label = " + lua_string_literal(br.label);
|
||||
out += " },\n";
|
||||
}
|
||||
out += " }";
|
||||
}
|
||||
// Progress
|
||||
if (cs.renderer == data_table::CellRenderer::Progress) {
|
||||
if (cs.progress_scale_100)
|
||||
out += ", progress_scale_100 = true";
|
||||
if (!cs.progress_color_hex.empty())
|
||||
out += ", progress_color = " + lua_string_literal(cs.progress_color_hex);
|
||||
}
|
||||
// Duration
|
||||
if (cs.renderer == data_table::CellRenderer::Duration) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%g", (double)cs.duration_warn_ms);
|
||||
out += std::string(", warn_ms = ") + buf;
|
||||
std::snprintf(buf, sizeof(buf), "%g", (double)cs.duration_error_ms);
|
||||
out += std::string(", error_ms = ") + buf;
|
||||
}
|
||||
// Icon map
|
||||
if (!cs.icon_map.empty()) {
|
||||
out += ", icon_map = {\n";
|
||||
for (const auto& ie : cs.icon_map) {
|
||||
out += " { value = " + lua_string_literal(ie.value);
|
||||
out += ", icon = " + lua_string_literal(ie.icon_name);
|
||||
if (!ie.color_hex.empty())
|
||||
out += ", color = " + lua_string_literal(ie.color_hex);
|
||||
out += " },\n";
|
||||
}
|
||||
out += " }";
|
||||
}
|
||||
// Button
|
||||
if (cs.renderer == data_table::CellRenderer::Button) {
|
||||
if (!cs.button_action.empty())
|
||||
out += ", button_action = " + lua_string_literal(cs.button_action);
|
||||
if (!cs.button_label.empty())
|
||||
out += ", button_label = " + lua_string_literal(cs.button_label);
|
||||
if (!cs.button_color_hex.empty())
|
||||
out += ", button_color = " + lua_string_literal(cs.button_color_hex);
|
||||
}
|
||||
// Tooltip
|
||||
if (cs.tooltip_on_hover) {
|
||||
out += ", tooltip = " + lua_string_literal(cs.tooltip.empty() ? "auto" : cs.tooltip);
|
||||
out += ", tooltip_on_hover = true";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
}
|
||||
|
||||
out += "}\n";
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace tql
|
||||
@@ -0,0 +1,329 @@
|
||||
// tql_emit_test.cpp — Tests for tql_emit (pure TQL serialization).
|
||||
// Run with: cmake --build cpp/build --target tql_emit_test && cpp/build/tql_emit_test
|
||||
// Or via: ./fn run tql_emit_cpp_core
|
||||
|
||||
#include "core/tql_emit.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
static void check(bool cond, const char* label) {
|
||||
if (cond) {
|
||||
std::printf("PASS: %s\n", label);
|
||||
++g_pass;
|
||||
} else {
|
||||
std::printf("FAIL: %s\n", label);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
|
||||
static bool contains(const std::string& haystack, const char* needle) {
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit empty state produces valid header
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_empty_state() {
|
||||
State st;
|
||||
std::vector<std::string> headers;
|
||||
std::vector<ColumnType> types;
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "version = 1"), "emit empty state: version=1");
|
||||
check(contains(out, "display = \"table\""), "emit empty state: display=table");
|
||||
check(contains(out, "stages = {"), "emit empty state: stages block");
|
||||
check(contains(out, "columns = {"), "emit empty state: columns block");
|
||||
check(contains(out, "views = {"), "emit empty state: views block");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit state with one column
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_single_column() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.col_visible = {true};
|
||||
st.col_order = {0};
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "name = \"name\""), "emit single col: name field");
|
||||
check(contains(out, "type = \"string\""), "emit single col: type=string");
|
||||
check(contains(out, "visible = true"), "emit single col: visible=true");
|
||||
check(contains(out, "order = 1"), "emit single col: order=1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit state with filter in stage 0
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_filter() {
|
||||
State st;
|
||||
Stage s0;
|
||||
s0.filters.push_back({0, Op::Eq, "Alice"});
|
||||
st.stages.push_back(s0);
|
||||
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Int};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "filter = {"), "emit filter: filter block");
|
||||
check(contains(out, "\"=\""), "emit filter: op=");
|
||||
check(contains(out, "\"name\""), "emit filter: col name");
|
||||
check(contains(out, "\"Alice\""), "emit filter: value");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit state with sort
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_sort() {
|
||||
State st;
|
||||
Stage s0;
|
||||
s0.sorts.push_back({"age", true}); // desc
|
||||
st.stages.push_back(s0);
|
||||
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Int};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "sort = {"), "emit sort: sort block");
|
||||
check(contains(out, "\"desc\""), "emit sort: direction desc");
|
||||
check(contains(out, "\"age\""), "emit sort: col name age");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit stage 1 with breakout + aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_grouped_stage() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{}); // stage 0 empty
|
||||
|
||||
Stage s1;
|
||||
s1.breakouts.push_back("dept");
|
||||
Aggregation a;
|
||||
a.fn = AggFn::Sum;
|
||||
a.col = "salary";
|
||||
s1.aggregations.push_back(a);
|
||||
st.stages.push_back(s1);
|
||||
|
||||
std::vector<std::string> headers = {"dept", "salary"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "breakout = {"), "emit grouped: breakout block");
|
||||
check(contains(out, "\"dept\""), "emit grouped: breakout col");
|
||||
check(contains(out, "aggregation = {"), "emit grouped: aggregation block");
|
||||
check(contains(out, "\"sum\""), "emit grouped: agg fn sum");
|
||||
check(contains(out, "\"salary\""), "emit grouped: agg col salary");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with color rule
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_color_rule() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
ColorRule cr;
|
||||
cr.col = 0;
|
||||
cr.equals = "Alice";
|
||||
cr.color = 0xFF0000FF; // red, full alpha: ABGR -> r=0xFF g=0x00 b=0x00 a=0xFF
|
||||
st.color_rules.push_back(cr);
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "color_rules = {"), "emit color rule: block");
|
||||
check(contains(out, "equals = \"Alice\""), "emit color rule: equals");
|
||||
check(contains(out, "color = \"#"), "emit color rule: hex color");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with viz panel (extra panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_viz_panel() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.display = ViewMode::Bar;
|
||||
st.viz_config.x_col = "month";
|
||||
st.viz_config.y_cols = {"revenue", "cost"};
|
||||
|
||||
std::vector<std::string> headers = {"month", "revenue", "cost"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float, ColumnType::Float};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "display = \"bar\""), "emit viz panel: display=bar");
|
||||
check(contains(out, "x_col = \"month\""), "emit viz panel: x_col");
|
||||
check(contains(out, "y_cols = {"), "emit viz panel: y_cols block");
|
||||
check(contains(out, "\"revenue\""), "emit viz panel: y_col revenue");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit join
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_join() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
Join jn;
|
||||
jn.alias = "dept_table";
|
||||
jn.source = "departments";
|
||||
jn.strategy = JoinStrategy::Left;
|
||||
jn.on.push_back({"dept_id", "id"});
|
||||
st.joins.push_back(jn);
|
||||
|
||||
std::vector<std::string> headers = {"dept_id", "name"};
|
||||
std::vector<ColumnType> types = {ColumnType::Int, ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "joins = {"), "emit join: joins block");
|
||||
check(contains(out, "alias = \"dept_table\""), "emit join: alias");
|
||||
check(contains(out, "source = \"departments\""), "emit join: source");
|
||||
check(contains(out, "strategy = \"left\""), "emit join: strategy left");
|
||||
check(contains(out, "\"dept_id\""), "emit join: on left col");
|
||||
check(contains(out, "\"id\""), "emit join: on right col");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: lua_string_literal escaping
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_lua_string_literal() {
|
||||
check(lua_string_literal("hello") == "\"hello\"", "literal: plain string");
|
||||
check(lua_string_literal("a\"b") == "\"a\\\"b\"", "literal: embedded quote");
|
||||
check(lua_string_literal("a\\b") == "\"a\\\\b\"", "literal: backslash");
|
||||
check(lua_string_literal("a\nb") == "\"a\\nb\"", "literal: newline");
|
||||
check(lua_string_literal("") == "\"\"", "literal: empty");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: color_to_hex / hex_to_color roundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_color_roundtrip() {
|
||||
// Opaque red in ABGR (0xAA BB GG RR): r=0xFF g=0x00 b=0x00 a=0xFF
|
||||
unsigned int c = 0xFF0000FFu;
|
||||
std::string hex = color_to_hex(c);
|
||||
unsigned int back = hex_to_color(hex);
|
||||
check(back == c, "color roundtrip: opaque red");
|
||||
|
||||
// Semi-transparent green
|
||||
unsigned int c2 = (0x80u << 24) | (0xFFu << 8); // a=0x80 g=0xFF
|
||||
std::string hex2 = color_to_hex(c2);
|
||||
unsigned int back2 = hex_to_color(hex2);
|
||||
check(back2 == c2, "color roundtrip: semi-transparent green");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with Badge column_spec in aux_column_specs
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_column_specs_badge() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
// Populate aux_column_specs[0] with a Badge spec.
|
||||
ColumnSpec cs;
|
||||
cs.id = "status";
|
||||
cs.renderer = CellRenderer::Badge;
|
||||
cs.badges = { BadgeRule{"ok", "#22c55e", "OK"}, BadgeRule{"error", "#ef4444", ""} };
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"status", "value"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "column_specs = {"), "emit column_specs badge: block");
|
||||
check(contains(out, "renderer = \"badge\""), "emit column_specs badge: renderer");
|
||||
check(contains(out, "id = \"status\""), "emit column_specs badge: id");
|
||||
check(contains(out, "\"#22c55e\""), "emit column_specs badge: color ok");
|
||||
check(contains(out, "\"error\""), "emit column_specs badge: value error");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with Button column_spec in aux_column_specs
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_column_specs_button() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "actions";
|
||||
cs.renderer = CellRenderer::Button;
|
||||
cs.button_action = "cancel";
|
||||
cs.button_label = "Cancel";
|
||||
cs.button_color_hex = "#ef4444";
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name", "actions"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "renderer = \"button\""), "emit column_specs button: renderer");
|
||||
check(contains(out, "button_action = \"cancel\""), "emit column_specs button: action");
|
||||
check(contains(out, "button_label = \"Cancel\""), "emit column_specs button: label");
|
||||
check(contains(out, "button_color = \"#ef4444\""), "emit column_specs button: color");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with Tooltip column_spec in aux_column_specs
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_column_specs_tooltip() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "name";
|
||||
cs.renderer = CellRenderer::Text;
|
||||
cs.tooltip = "auto";
|
||||
cs.tooltip_on_hover = true;
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "tooltip = \"auto\""), "emit column_specs tooltip: auto");
|
||||
check(contains(out, "tooltip_on_hover = true"), "emit column_specs tooltip: on_hover");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
int main() {
|
||||
test_emit_empty_state();
|
||||
test_emit_single_column();
|
||||
test_emit_filter();
|
||||
test_emit_sort();
|
||||
test_emit_grouped_stage();
|
||||
test_emit_color_rule();
|
||||
test_emit_viz_panel();
|
||||
test_emit_join();
|
||||
test_lua_string_literal();
|
||||
test_color_roundtrip();
|
||||
test_emit_column_specs_badge();
|
||||
test_emit_column_specs_button();
|
||||
test_emit_column_specs_tooltip();
|
||||
|
||||
std::printf("---\nResults: %d passed, %d failed\n", g_pass, g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -182,10 +182,14 @@ static const char* icon_name_to_glyph(const std::string& name) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// draw_cell_custom: render a cell using the declarative ColumnSpec.
|
||||
// Called only when spec.renderer != CellRenderer::Text.
|
||||
// Issue 0081-N, v1.1.0.
|
||||
// Issue 0081-N, v1.1.0. Phase 2 (v1.2.0): Button renderer + tooltip.
|
||||
//
|
||||
// events_out: if non-null and renderer==Button, ButtonClick is pushed on click.
|
||||
// row_idx / col_idx: logical indices in the TableInput (for event payload).
|
||||
// ---------------------------------------------------------------------------
|
||||
static void draw_cell_custom(const ColumnSpec& spec, const char* value,
|
||||
int /*row_idx*/, int /*col_idx*/) {
|
||||
int row_idx, int col_idx,
|
||||
std::vector<TableEvent>* events_out) {
|
||||
if (!value) value = "";
|
||||
|
||||
switch (spec.renderer) {
|
||||
@@ -284,11 +288,57 @@ static void draw_cell_custom(const ColumnSpec& spec, const char* value,
|
||||
break;
|
||||
}
|
||||
|
||||
case CellRenderer::Button: {
|
||||
// Skip empty cell values — app decides when to show a button.
|
||||
if (value[0] == '\0') break;
|
||||
const char* label = spec.button_label.empty() ? value : spec.button_label.c_str();
|
||||
bool has_color = !spec.button_color_hex.empty();
|
||||
if (has_color) {
|
||||
ImVec4 btn_col = hex_to_imcolor(spec.button_color_hex);
|
||||
if (btn_col.x >= 0.f) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, btn_col);
|
||||
ImVec4 hov = ImVec4(
|
||||
std::min(btn_col.x + 0.12f, 1.f),
|
||||
std::min(btn_col.y + 0.12f, 1.f),
|
||||
std::min(btn_col.z + 0.12f, 1.f),
|
||||
btn_col.w);
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hov);
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, hov);
|
||||
} else {
|
||||
has_color = false;
|
||||
}
|
||||
}
|
||||
// Unique button ID: combines label + row + col to avoid ImGui ID
|
||||
// collisions when the same label appears in multiple rows.
|
||||
char btn_id[128];
|
||||
std::snprintf(btn_id, sizeof(btn_id), "%s##btn_%d_%d",
|
||||
label, row_idx, col_idx);
|
||||
if (ImGui::SmallButton(btn_id) && events_out) {
|
||||
TableEvent ev;
|
||||
ev.kind = TableEventKind::ButtonClick;
|
||||
ev.row = row_idx;
|
||||
ev.col = col_idx;
|
||||
ev.column_id = spec.id;
|
||||
ev.action_id = spec.button_action;
|
||||
ev.value = value;
|
||||
events_out->push_back(std::move(ev));
|
||||
}
|
||||
if (has_color) ImGui::PopStyleColor(3);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// CellRenderer::Text or unknown — plain text.
|
||||
ImGui::TextUnformatted(value);
|
||||
break;
|
||||
}
|
||||
|
||||
// Tooltip: show on hover if tooltip_on_hover is set.
|
||||
// "auto" shows the raw cell value (useful for truncated text columns).
|
||||
if (spec.tooltip_on_hover && ImGui::IsItemHovered()) {
|
||||
const char* tip = (spec.tooltip == "auto") ? value : spec.tooltip.c_str();
|
||||
if (tip && tip[0]) ImGui::SetTooltip("%s", tip);
|
||||
}
|
||||
}
|
||||
|
||||
// compare: cell-level comparison supporting all Op variants.
|
||||
@@ -1254,11 +1304,12 @@ bool draw_extra_panel(State& st, VizPanel& p, int idx, const StageOutput& so,
|
||||
ImGui::TableSetColumnIndex(c);
|
||||
const char* s = so.cells[(size_t)r * so.cols + c];
|
||||
// Issue 0081-N: declarative renderer for extra panel mini-table.
|
||||
// events_out not propagated to mini-table (secondary render).
|
||||
bool custom_ep = false;
|
||||
if (col_specs && c < (int)col_specs->size()) {
|
||||
const ColumnSpec& cs = (*col_specs)[(size_t)c];
|
||||
if (cs.renderer != CellRenderer::Text) {
|
||||
draw_cell_custom(cs, s, r, c);
|
||||
draw_cell_custom(cs, s, r, c, nullptr);
|
||||
custom_ep = true;
|
||||
}
|
||||
}
|
||||
@@ -2458,14 +2509,30 @@ void drill_into(State& st, int from_stage,
|
||||
void render(const char* id,
|
||||
const std::vector<TableInput>& tables,
|
||||
State& st,
|
||||
std::vector<TableEvent>* events_out,
|
||||
bool show_chrome)
|
||||
{
|
||||
if (tables.empty()) return;
|
||||
int main_idx = resolve_main_idx(tables, st.main_source);
|
||||
if (main_idx < 0) return;
|
||||
|
||||
// Construir headers ptrs desde main table.
|
||||
const TableInput& main_t = tables[(size_t)main_idx];
|
||||
// Merge aux_column_specs from State into TableInput when the caller passed
|
||||
// empty column_specs. Caller-provided specs always take precedence.
|
||||
// We keep a local copy to avoid mutating the caller's const tables.
|
||||
static thread_local TableInput main_t_merged;
|
||||
{
|
||||
const TableInput& src = tables[(size_t)main_idx];
|
||||
if (src.column_specs.empty() &&
|
||||
main_idx < (int)st.aux_column_specs.size() &&
|
||||
!st.aux_column_specs[(size_t)main_idx].empty())
|
||||
{
|
||||
main_t_merged = src;
|
||||
main_t_merged.column_specs = st.aux_column_specs[(size_t)main_idx];
|
||||
} else {
|
||||
main_t_merged = src;
|
||||
}
|
||||
}
|
||||
const TableInput& main_t = main_t_merged;
|
||||
static thread_local std::vector<const char*> main_hdr_ptrs;
|
||||
main_hdr_ptrs.clear();
|
||||
main_hdr_ptrs.reserve(main_t.cols);
|
||||
@@ -3105,20 +3172,29 @@ void render(const char* id,
|
||||
ri >= sel_rmin && ri <= sel_rmax &&
|
||||
oc >= sel_cmin && oc <= sel_cmax);
|
||||
ImGui::PushID(r * eff_cols + c);
|
||||
// Issue 0081-N: use declarative renderer when column_specs set.
|
||||
// Issue 0081-N/O: use declarative renderer when column_specs set.
|
||||
{
|
||||
bool custom_rendered = false;
|
||||
const ColumnSpec* cell_cs = nullptr;
|
||||
if (!main_t.column_specs.empty() &&
|
||||
c < (int)main_t.column_specs.size()) {
|
||||
const ColumnSpec& cs = main_t.column_specs[(size_t)c];
|
||||
if (cs.renderer != CellRenderer::Text) {
|
||||
draw_cell_custom(cs, cell, ri, c);
|
||||
cell_cs = &main_t.column_specs[(size_t)c];
|
||||
if (cell_cs->renderer != CellRenderer::Text) {
|
||||
draw_cell_custom(*cell_cs, cell, r, c, events_out);
|
||||
custom_rendered = true;
|
||||
}
|
||||
}
|
||||
if (!custom_rendered) {
|
||||
ImGui::Selectable(cell ? cell : "", in_sel,
|
||||
ImGuiSelectableFlags_AllowDoubleClick);
|
||||
// Tooltip for Text cells (Phase 2).
|
||||
if (cell_cs && cell_cs->tooltip_on_hover &&
|
||||
ImGui::IsItemHovered()) {
|
||||
const char* tip = (cell_cs->tooltip == "auto")
|
||||
? (cell ? cell : "")
|
||||
: cell_cs->tooltip.c_str();
|
||||
if (tip && tip[0]) ImGui::SetTooltip("%s", tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
// AllowWhenBlockedByActiveItem: durante drag,
|
||||
@@ -3135,10 +3211,36 @@ void render(const char* id,
|
||||
} else if (U.sel_dragging) {
|
||||
U.sel_end_row = ri; U.sel_end_col = oc;
|
||||
}
|
||||
// RowDoubleClick event (Phase 2, v1.2.0).
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)
|
||||
&& events_out) {
|
||||
TableEvent ev;
|
||||
ev.kind = TableEventKind::RowDoubleClick;
|
||||
ev.row = r;
|
||||
ev.col = c;
|
||||
ev.value = cell ? cell : "";
|
||||
if (!main_t.column_specs.empty() &&
|
||||
c < (int)main_t.column_specs.size())
|
||||
ev.column_id = main_t.column_specs[(size_t)c].id;
|
||||
events_out->push_back(std::move(ev));
|
||||
}
|
||||
// RowRightClick event: emit event only, no popup drawn here.
|
||||
// Caller inspects events_out and opens its own context menu.
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
|
||||
U.pending_col = c;
|
||||
U.pending_value = cell ? cell : "";
|
||||
U.open_cell_popup = true;
|
||||
if (events_out) {
|
||||
TableEvent ev;
|
||||
ev.kind = TableEventKind::RowRightClick;
|
||||
ev.row = r;
|
||||
ev.col = c;
|
||||
ev.value = cell ? cell : "";
|
||||
if (!main_t.column_specs.empty() &&
|
||||
c < (int)main_t.column_specs.size())
|
||||
ev.column_id = main_t.column_specs[(size_t)c].id;
|
||||
events_out->push_back(std::move(ev));
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::PopID();
|
||||
@@ -3658,19 +3760,28 @@ void render(const char* id,
|
||||
ImGui::TableSetColumnIndex(c);
|
||||
const char* cell = cur_cells[r * cur_cols_n + c];
|
||||
ImGui::PushID(r * cur_cols_n + c);
|
||||
// Issue 0081-N: declarative renderer for aggregated stage tables.
|
||||
// Issue 0081-N/O: declarative renderer for aggregated stage tables.
|
||||
{
|
||||
bool custom_rendered = false;
|
||||
const ColumnSpec* cell_cs2 = nullptr;
|
||||
if (!main_t.column_specs.empty() &&
|
||||
c < (int)main_t.column_specs.size()) {
|
||||
const ColumnSpec& cs = main_t.column_specs[(size_t)c];
|
||||
if (cs.renderer != CellRenderer::Text) {
|
||||
draw_cell_custom(cs, cell, r, c);
|
||||
cell_cs2 = &main_t.column_specs[(size_t)c];
|
||||
if (cell_cs2->renderer != CellRenderer::Text) {
|
||||
draw_cell_custom(*cell_cs2, cell, r, c, events_out);
|
||||
custom_rendered = true;
|
||||
}
|
||||
}
|
||||
if (!custom_rendered) {
|
||||
ImGui::Selectable(cell ? cell : "");
|
||||
// Tooltip for Text cells (Phase 2).
|
||||
if (cell_cs2 && cell_cs2->tooltip_on_hover &&
|
||||
ImGui::IsItemHovered()) {
|
||||
const char* tip = (cell_cs2->tooltip == "auto")
|
||||
? (cell ? cell : "")
|
||||
: cell_cs2->tooltip.c_str();
|
||||
if (tip && tip[0]) ImGui::SetTooltip("%s", tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
|
||||
@@ -3678,6 +3789,18 @@ void render(const char* id,
|
||||
U.pending_value = cell ? cell : "";
|
||||
U.inspect_row = r;
|
||||
ImGui::OpenPopup("##drill_popup");
|
||||
// RowRightClick event (Phase 2, v1.2.0).
|
||||
if (events_out) {
|
||||
TableEvent ev;
|
||||
ev.kind = TableEventKind::RowRightClick;
|
||||
ev.row = r;
|
||||
ev.col = c;
|
||||
ev.value = cell ? cell : "";
|
||||
if (!main_t.column_specs.empty() &&
|
||||
c < (int)main_t.column_specs.size())
|
||||
ev.column_id = main_t.column_specs[(size_t)c].id;
|
||||
events_out->push_back(std::move(ev));
|
||||
}
|
||||
}
|
||||
if (ImGui::BeginPopup("##drill_popup")) {
|
||||
if (c < n_brk) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
// data_table — render UI completa de tabla TQL.
|
||||
// Entry-point publica del stack data_table del registry.
|
||||
// Issue 0081-H. Promovido desde cpp/apps/primitives_gallery/playground/tables/data_table.h
|
||||
// Phase 2 (issue 0081-O, v1.2.0): Button renderer + event sink + tooltip + RightClick.
|
||||
//
|
||||
// Uso basico (back-compat, sin events):
|
||||
// data_table::State st; // persistir entre frames
|
||||
// ImGui::Begin("Window"); ImGui::BeginChild("tbl", {-1,-1});
|
||||
// data_table::render("my_table", {table1, table2}, st);
|
||||
// ImGui::EndChild(); ImGui::End();
|
||||
//
|
||||
// Uso con events (Phase 2):
|
||||
// std::vector<data_table::TableEvent> events;
|
||||
// data_table::render("my_table", {table1, table2}, st, &events);
|
||||
// for (auto& ev : events) {
|
||||
// if (ev.kind == data_table::TableEventKind::ButtonClick &&
|
||||
// ev.action_id == "cancel") { ... }
|
||||
// }
|
||||
//
|
||||
// Requiere ImGui context + ImPlot context activos.
|
||||
// Namespace identico al playground para facilitar migracion (solo cambiar include path).
|
||||
|
||||
#include "core/data_table_types.h"
|
||||
#include <vector>
|
||||
|
||||
namespace data_table {
|
||||
|
||||
// render — Render barra-de-chips + tabla + panels de visualizacion.
|
||||
// Mutates `st` en respuesta a la interaccion del usuario.
|
||||
//
|
||||
// `id` — ID unico de ImGui para esta instancia (ej. "##my_table").
|
||||
// `tables` — lista de TableInput. tables[0] es la main por defecto;
|
||||
// si State.main_source no-vacio se usa por nombre.
|
||||
// Tablas extra se exponen como joinables en la UI.
|
||||
// `st` — estado mutable. Debe persistir entre frames (no stack-local).
|
||||
// `events_out` — if non-null, populated with UI events (ButtonClick,
|
||||
// RowDoubleClick, RowRightClick) fired this frame. The caller
|
||||
// clears/reads the vector after each render call.
|
||||
// Pass nullptr to disable event collection (back-compat).
|
||||
// `show_chrome` — si false, oculta la barra de chips + breadcrumb por defecto.
|
||||
// El usuario puede reactivarla via el boton "Show UI".
|
||||
void render(const char* id,
|
||||
const std::vector<TableInput>& tables,
|
||||
State& st,
|
||||
std::vector<TableEvent>* events_out,
|
||||
bool show_chrome = true);
|
||||
|
||||
// Overload for back-compat: same as render(..., nullptr, show_chrome).
|
||||
inline void render(const char* id,
|
||||
const std::vector<TableInput>& tables,
|
||||
State& st,
|
||||
bool show_chrome = true)
|
||||
{
|
||||
render(id, tables, st, nullptr, show_chrome);
|
||||
}
|
||||
|
||||
} // namespace data_table
|
||||
@@ -3,10 +3,10 @@ name: data_table
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "void data_table::render(const char* id, const std::vector<TableInput>& tables, State& st, bool show_chrome = true)"
|
||||
description: "Render UI completa de tabla TQL: chips bar, tabla, viz panels, column-stats inline, drill, color rules, joins, TQL editor, Ask AI. Entry-point publica del stack data_table. Muta State segun interaccion del usuario."
|
||||
signature: "void data_table::render(const char* id, const std::vector<TableInput>& tables, State& st, std::vector<TableEvent>* events_out = nullptr, bool show_chrome = true)"
|
||||
description: "Render UI completa de tabla TQL: chips bar, tabla, viz panels, column-stats inline, drill, color rules, joins, TQL editor, Ask AI, Button renderer, event sink (ButtonClick/RowDoubleClick/RowRightClick), tooltip per-cell, column_specs persisted in TQL. Entry-point publica del stack data_table. Muta State segun interaccion del usuario."
|
||||
tags: [tables, viz, ui, imgui, tql, cpp-tables]
|
||||
uses_functions:
|
||||
- compute_stage_cpp_core
|
||||
@@ -40,6 +40,8 @@ uses_types:
|
||||
- Aggregation_cpp_core
|
||||
- SortClause_cpp_core
|
||||
- ColumnType_cpp_core
|
||||
- TableEvent_cpp_core
|
||||
- TableEventKind_cpp_core
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
@@ -65,6 +67,9 @@ tests:
|
||||
- "Progress: TableInput with Progress column_spec compiles and links"
|
||||
- "Duration: TableInput with Duration column_spec compiles and links"
|
||||
- "Icon: TableInput with Icon column_spec compiles and links"
|
||||
- "Button: TableEvent struct constructible; render() with events_out links"
|
||||
- "Tooltip: ColumnSpec with tooltip_on_hover=true compiles and links"
|
||||
- "Back-compat: both render() signatures (with/without events_out) link"
|
||||
test_file_path: "cpp/tests/test_column_specs.cpp"
|
||||
file_path: "cpp/functions/viz/data_table.cpp"
|
||||
params:
|
||||
@@ -73,10 +78,12 @@ params:
|
||||
- name: tables
|
||||
desc: "Lista de TableInput materializadas en memoria. tables[0] es la main por defecto; si State.main_source no-vacio se usa por nombre. Tablas extra se exponen como joinables en la UI de joins."
|
||||
- name: st
|
||||
desc: "Estado mutable completo: pipeline de stages, joins, viz config, ui tweaks. Debe persistir entre frames — no declarar en el stack del frame."
|
||||
desc: "Estado mutable completo: pipeline de stages, joins, viz config, ui tweaks, aux_column_specs (Phase 2). Debe persistir entre frames — no declarar en el stack del frame."
|
||||
- name: events_out
|
||||
desc: "Puntero a vector de TableEvent. Si non-null, se puebla con eventos de este frame (ButtonClick, RowDoubleClick, RowRightClick). El caller limpia/lee el vector despues de cada render. Pasar nullptr para desactivar (back-compat)."
|
||||
- name: show_chrome
|
||||
desc: "Si false, oculta chips bar + breadcrumb por defecto. El usuario puede reactivar con el boton 'Show UI'. El State persiste el override del usuario entre frames."
|
||||
output: "void. Muta st en respuesta a la interaccion del usuario (filtros, breakouts, sorts, drill, joins, viz mode). Los cambios son visibles en st al retornar."
|
||||
output: "void. Muta st en respuesta a la interaccion del usuario (filtros, breakouts, sorts, drill, joins, viz mode). Los cambios son visibles en st al retornar. Events emitted via events_out."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
@@ -91,19 +98,43 @@ t.name = "orders";
|
||||
t.rows = num_rows;
|
||||
t.cols = num_cols;
|
||||
t.cells = cells_ptr; // row-major flat array, owner externo
|
||||
t.headers = {"id", "amount", "status"};
|
||||
t.headers = {"id", "amount", "status", "actions"};
|
||||
t.types = {data_table::ColumnType::Int,
|
||||
data_table::ColumnType::Float,
|
||||
data_table::ColumnType::String,
|
||||
data_table::ColumnType::String};
|
||||
|
||||
// Phase 2: declarative renderers + tooltip
|
||||
t.column_specs.resize(4);
|
||||
t.column_specs[2].renderer = data_table::CellRenderer::Badge;
|
||||
t.column_specs[2].badges = {{"paid","#22c55e","Paid"},{"pending","#f59e0b",""}};
|
||||
t.column_specs[2].tooltip = "auto";
|
||||
t.column_specs[2].tooltip_on_hover = true;
|
||||
t.column_specs[3].renderer = data_table::CellRenderer::Button;
|
||||
t.column_specs[3].button_action = "cancel_order";
|
||||
t.column_specs[3].button_label = "Cancel";
|
||||
|
||||
data_table::State st; // persiste entre frames
|
||||
std::vector<data_table::TableEvent> events;
|
||||
|
||||
// --- Render (cada frame) ---
|
||||
ImGui::Begin("Orders");
|
||||
ImGui::BeginChild("##tbl", ImVec2(-1, -1));
|
||||
data_table::render("##orders", {t}, st);
|
||||
events.clear();
|
||||
data_table::render("##orders", {t}, st, &events);
|
||||
ImGui::EndChild();
|
||||
ImGui::End();
|
||||
|
||||
// --- Process events ---
|
||||
for (const auto& ev : events) {
|
||||
if (ev.kind == data_table::TableEventKind::ButtonClick &&
|
||||
ev.action_id == "cancel_order") {
|
||||
cancel_order(ev.row); // app handles the action
|
||||
}
|
||||
if (ev.kind == data_table::TableEventKind::RowDoubleClick) {
|
||||
open_order_detail(ev.row);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
@@ -117,6 +148,10 @@ Cuando una app necesita tabla con filtros + agregaciones + viz + joins sobre dat
|
||||
- **Drill-down propaga en State**: `st.active_stage` y `st.stages` se mutan por click en charts. El caller puede leer `st` tras `render()` para reaccionar.
|
||||
- **Thread-safety**: `render()` usa `static thread_local` para buffers intermedios. Llamar solo desde el main thread de ImGui.
|
||||
- **TableInput owner externo**: `cells` es un puntero raw al array del caller. Los datos deben sobrevivir durante toda la llamada a `render()`. No pasar puntero a vector que puede reallocarse.
|
||||
- **events_out no se limpia**: `render()` solo hace `push_back`. El caller debe llamar `events.clear()` antes de cada frame o acumulara eventos de frames anteriores.
|
||||
- **Button + celda vacia**: si el cell value es vacio, el boton NO se dibuja. La app controla cuando mostrar el boton poniendo un value no vacio (ej. "1" o el ID de la fila).
|
||||
- **RowRightClick emite evento Y abre popup interno**: la tabla de stages (stage>0) sigue abriendo su popup de drill. En el raw table (stage 0), se emite el evento pero el popup de drill antiguo tambien puede abrirse via `U.open_cell_popup`. El caller puede ignorar el popup interno y gestionar su propio menu al detectar `RowRightClick`.
|
||||
- **aux_column_specs merge**: si `TableInput.column_specs` esta vacio pero `State.aux_column_specs[0]` no, `render()` los aplica automaticamente. Si el caller pasa column_specs no vacios, ganan sobre los del State.
|
||||
- **Ask AI modal (llm_anthropic)**: el boton "Ask AI" usa un stub interno de `llm_anthropic` que retorna error por defecto. Para activar la feature real, compilar con `-DFN_LLM_ANTHROPIC=1` y proveer `infra/llm_anthropic.h` en el include path. Pendiente Wave 4: promover al registry.
|
||||
- **FN_TQL_DUCKDB**: modo SQL del Ask AI requiere compilar con `-DFN_TQL_DUCKDB=1` y la libreria DuckDB disponible.
|
||||
|
||||
@@ -143,5 +178,7 @@ No hay tests unitarios directos: `render()` requiere ImGui + ImPlot context acti
|
||||
|
||||
v1.1.0 (2026-05-15) — declarative CellRenderer (Badge/Progress/Duration/Icon) via TableInput.column_specs sidecar. Back-compat preservado: apps existentes sin column_specs siguen funcionando sin cambios.
|
||||
|
||||
v1.2.0 (2026-05-15) — Button renderer + event sink (ButtonClick/RowDoubleClick/RowRightClick) + tooltip per cell + column_specs persisted in TQL (aux_column_specs roundtrip). Back-compat preserved: events_out=nullptr by default; existing render() callers unchanged.
|
||||
|
||||
---
|
||||
Promovido desde `cpp/apps/primitives_gallery/playground/tables/data_table.{h,cpp}` — issue 0081-H.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// test_column_specs.cpp — Smoke / back-compat tests for declarative cell renderers.
|
||||
// Issue 0081-N, v1.1.0.
|
||||
// Issue 0081-N, v1.1.0. Phase 2 (issue 0081-O, v1.2.0).
|
||||
//
|
||||
// These tests verify:
|
||||
// 1. TableInput without column_specs compiles and links (back-compat).
|
||||
// 2-5. TableInput with Badge/Progress/Duration/Icon column_specs compiles and links.
|
||||
// 6. Button renderer: TableEvent struct is constructible; events_out pointer accepted.
|
||||
// 7. Tooltip field: ColumnSpec with tooltip_on_hover=true compiles and links.
|
||||
// 8. render() overload with events_out=nullptr back-compat (symbol resolution only).
|
||||
//
|
||||
// None of these tests call data_table::render() (requires ImGui context).
|
||||
// They only verify that the new types are usable and that the symbols from
|
||||
@@ -50,7 +53,11 @@ static void test_no_column_specs() {
|
||||
|
||||
// Verify that render symbol is still linkable (no ImGui context needed
|
||||
// to take the address; the linker verifies the symbol resolves).
|
||||
auto* render_fn = &data_table::render;
|
||||
// Use the classic overload (without events_out) for the back-compat check.
|
||||
auto* render_fn = static_cast<void(*)(const char*,
|
||||
const std::vector<TableInput>&,
|
||||
State&,
|
||||
bool)>(&data_table::render);
|
||||
(void)render_fn;
|
||||
|
||||
std::printf("PASS: test_no_column_specs (back-compat, column_specs empty)\n");
|
||||
@@ -174,13 +181,130 @@ static void test_icon_column_spec() {
|
||||
assert(t.column_specs[3].icon_map[0].value == "fn");
|
||||
assert(t.column_specs[3].icon_map[0].icon_name == "TI_BOLT");
|
||||
|
||||
// Verify render symbol still links with column_specs populated.
|
||||
auto* render_fn = &data_table::render;
|
||||
// Verify render symbol still links with column_specs populated (classic overload).
|
||||
auto* render_fn = static_cast<void(*)(const char*,
|
||||
const std::vector<TableInput>&,
|
||||
State&,
|
||||
bool)>(&data_table::render);
|
||||
(void)render_fn;
|
||||
|
||||
std::printf("PASS: test_icon_column_spec (2 entries, render symbol links)\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6: Button renderer — TableEvent struct is constructible; events_out ptr
|
||||
// is accepted by the new render() overload (symbol resolution only).
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_button_column_spec_and_event_struct() {
|
||||
TableInput t;
|
||||
t.name = "t6";
|
||||
t.rows = 3;
|
||||
t.cols = 4;
|
||||
t.cells = g_cells;
|
||||
t.headers = g_headers;
|
||||
t.types = g_types;
|
||||
|
||||
ColumnSpec cs_btn;
|
||||
cs_btn.id = "actions";
|
||||
cs_btn.renderer = CellRenderer::Button;
|
||||
cs_btn.button_action = "cancel";
|
||||
cs_btn.button_label = "Cancel";
|
||||
cs_btn.button_color_hex = "#ef4444";
|
||||
|
||||
t.column_specs.resize(4);
|
||||
t.column_specs[0] = cs_btn;
|
||||
|
||||
assert(t.column_specs[0].renderer == CellRenderer::Button);
|
||||
assert(t.column_specs[0].button_action == "cancel");
|
||||
assert(t.column_specs[0].button_label == "Cancel");
|
||||
assert(t.column_specs[0].button_color_hex == "#ef4444");
|
||||
|
||||
// Verify TableEvent struct can be constructed and holds expected fields.
|
||||
TableEvent ev;
|
||||
ev.kind = TableEventKind::ButtonClick;
|
||||
ev.row = 1;
|
||||
ev.col = 0;
|
||||
ev.column_id = "actions";
|
||||
ev.action_id = "cancel";
|
||||
ev.value = "ok";
|
||||
assert(ev.kind == TableEventKind::ButtonClick);
|
||||
assert(ev.row == 1);
|
||||
assert(ev.action_id == "cancel");
|
||||
|
||||
// Verify the render() overload with events_out is linkable.
|
||||
std::vector<TableEvent> events;
|
||||
auto* render_with_events = static_cast<void(*)(const char*,
|
||||
const std::vector<TableInput>&,
|
||||
State&,
|
||||
std::vector<TableEvent>*,
|
||||
bool)>(&data_table::render);
|
||||
(void)render_with_events;
|
||||
|
||||
std::printf("PASS: test_button_column_spec_and_event_struct "
|
||||
"(Button spec + TableEvent + render overload link)\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 7: Tooltip field — ColumnSpec with tooltip_on_hover=true.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_tooltip_column_spec() {
|
||||
TableInput t;
|
||||
t.name = "t7";
|
||||
t.rows = 3;
|
||||
t.cols = 4;
|
||||
t.cells = g_cells;
|
||||
t.headers = g_headers;
|
||||
t.types = g_types;
|
||||
|
||||
ColumnSpec cs_tip;
|
||||
cs_tip.id = "status";
|
||||
cs_tip.renderer = CellRenderer::Text; // tooltip works on any renderer
|
||||
cs_tip.tooltip = "auto"; // "auto" -> show cell value
|
||||
cs_tip.tooltip_on_hover = true;
|
||||
|
||||
t.column_specs.resize(4);
|
||||
t.column_specs[0] = cs_tip;
|
||||
|
||||
assert(t.column_specs[0].tooltip == "auto");
|
||||
assert(t.column_specs[0].tooltip_on_hover == true);
|
||||
|
||||
// Also test explicit tooltip text.
|
||||
ColumnSpec cs_tip2;
|
||||
cs_tip2.id = "progress";
|
||||
cs_tip2.renderer = CellRenderer::Progress;
|
||||
cs_tip2.tooltip = "Progress percentage (0..1)";
|
||||
cs_tip2.tooltip_on_hover = true;
|
||||
t.column_specs[1] = cs_tip2;
|
||||
|
||||
assert(t.column_specs[1].tooltip == "Progress percentage (0..1)");
|
||||
assert(t.column_specs[1].tooltip_on_hover == true);
|
||||
|
||||
std::printf("PASS: test_tooltip_column_spec (auto + explicit, tooltip_on_hover=true)\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 8: render() back-compat overload without events_out — symbol links.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_render_backcompat_overload() {
|
||||
// Verify both render() signatures are resolvable at link time.
|
||||
// Classic (no events_out):
|
||||
auto* render_classic = static_cast<void(*)(const char*,
|
||||
const std::vector<TableInput>&,
|
||||
State&,
|
||||
bool)>(&data_table::render);
|
||||
(void)render_classic;
|
||||
|
||||
// New (with events_out):
|
||||
auto* render_events = static_cast<void(*)(const char*,
|
||||
const std::vector<TableInput>&,
|
||||
State&,
|
||||
std::vector<TableEvent>*,
|
||||
bool)>(&data_table::render);
|
||||
(void)render_events;
|
||||
|
||||
std::printf("PASS: test_render_backcompat_overload (both render() signatures link)\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -191,6 +315,9 @@ int main() {
|
||||
test_progress_column_spec();
|
||||
test_duration_column_spec();
|
||||
test_icon_column_spec();
|
||||
std::printf("=== ALL TESTS PASSED (5/5) ===\n");
|
||||
test_button_column_spec_and_event_struct();
|
||||
test_tooltip_column_spec();
|
||||
test_render_backcompat_overload();
|
||||
std::printf("=== ALL TESTS PASSED (8/8) ===\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// test_fn_table_viz_smoke.cpp — Linker smoke test for fn_table_viz static lib.
|
||||
// Issue 0081-I. Verifies that all 11 .cpp files in fn_table_viz resolve symbols
|
||||
// at link time. Does NOT call data_table::render (requires ImGui context).
|
||||
//
|
||||
// Build: cmake --build cpp/build/linux --target test_fn_table_viz_smoke
|
||||
// Run: ./cpp/build/linux/tests/test_fn_table_viz_smoke
|
||||
|
||||
#include "core/compute_stage.h"
|
||||
#include "core/compute_pipeline.h"
|
||||
#include "core/tql_emit.h"
|
||||
#include "core/tql_apply.h"
|
||||
#include "core/lua_engine.h"
|
||||
#include "core/join_tables.h"
|
||||
#include "core/auto_detect_type.h"
|
||||
#include "core/compute_column_stats.h"
|
||||
#include "viz/viz_render.h"
|
||||
#include "viz/data_table.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal input: 3 rows x 2 cols (string + numeric).
|
||||
// ---------------------------------------------------------------------------
|
||||
static const char* g_cells[] = {
|
||||
"Alice", "10",
|
||||
"Bob", "20",
|
||||
"Carol", "30",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: compute_stage with trivial Stage (no filters, no agg, no sort).
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_compute_stage_passthrough() {
|
||||
Stage s; // empty stage = passthrough
|
||||
std::vector<std::string> hdrs = {"Name", "Value"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
|
||||
StageOutput out = compute_stage(g_cells, 3, 2, hdrs, types, s);
|
||||
assert(out.rows == 3 && "compute_stage: rows must be 3");
|
||||
assert(out.cols == 2 && "compute_stage: cols must be 2");
|
||||
std::printf("PASS: compute_stage passthrough (rows=%d cols=%d)\n",
|
||||
out.rows, out.cols);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: auto_detect_type on the Value column (all numeric).
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_auto_detect_type() {
|
||||
std::vector<std::string> hdrs = {"Name", "Value"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
// Detect type for column 1 (Value: "10","20","30" -> Float or Int)
|
||||
ColumnType t = auto_detect_type(g_cells, 3, 2, /*col=*/1);
|
||||
assert((t == ColumnType::Float || t == ColumnType::Int) &&
|
||||
"auto_detect_type: Value col should be Float or Int");
|
||||
std::printf("PASS: auto_detect_type numeric (%s)\n",
|
||||
t == ColumnType::Float ? "Float" : "Int");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: compute_column_stats on the Value column.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_compute_column_stats() {
|
||||
ColStats s = compute_column_stats(g_cells, 3, 2, /*col=*/1);
|
||||
assert(s.numeric && "compute_column_stats: Value col should be numeric");
|
||||
assert(s.numeric_count == 3 && "compute_column_stats: 3 numeric values");
|
||||
assert(s.min < s.max && "compute_column_stats: min < max");
|
||||
std::printf("PASS: compute_column_stats (min=%.1f max=%.1f mean=%.1f)\n",
|
||||
s.min, s.max, s.mean);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: tql_emit -> tql_apply round-trip on a trivial State.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_tql_roundtrip() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.active_stage = 0;
|
||||
st.display = ViewMode::Table;
|
||||
|
||||
std::vector<std::string> hdrs = {"Name", "Value"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
|
||||
std::string lua_text = tql::emit(st, hdrs, types);
|
||||
assert(!lua_text.empty() && "tql_emit: must produce non-empty Lua text");
|
||||
|
||||
auto res = tql::apply(lua_text, hdrs);
|
||||
assert(res.ok && "tql_apply: round-trip must succeed");
|
||||
assert(!res.state.stages.empty() && "tql_apply: state must have stages");
|
||||
std::printf("PASS: tql_emit+tql_apply round-trip (ok=%s warnings=%zu)\n",
|
||||
res.ok ? "true" : "false", res.warnings.size());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: tql_apply extended overload (playground-compat bool signature).
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_tql_apply_extended() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
std::vector<std::string> hdrs = {"Name", "Value"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
std::string lua_text = tql::emit(st, hdrs, types);
|
||||
|
||||
std::string err;
|
||||
State out_st;
|
||||
bool ok = tql::apply(lua_text, out_st, hdrs, types, g_cells, 3, 2, &err);
|
||||
assert(ok && "tql_apply extended: must succeed");
|
||||
assert(!out_st.stages.empty() && "tql_apply extended: state must have stages");
|
||||
std::printf("PASS: tql_apply extended overload (ok=%s)\n",
|
||||
ok ? "true" : "false");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6: lua_engine get + compile + release (verifies Lua 5.4 links).
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_lua_engine_compile() {
|
||||
lua_engine::Engine* e = lua_engine::get();
|
||||
assert(e && "lua_engine::get must return non-null");
|
||||
std::string err;
|
||||
int id = lua_engine::compile(e, "return row['Value'] * 2", &err);
|
||||
assert(id >= 0 && "lua_engine::compile must succeed");
|
||||
lua_engine::release(e, id);
|
||||
lua_engine::shutdown();
|
||||
std::printf("PASS: lua_engine compile + release (id=%d)\n", id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 7: join_tables trivial self-join.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_join_tables_trivial() {
|
||||
std::vector<std::string> hdrs = {"Name", "Value"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
|
||||
TableInput right;
|
||||
right.name = "right";
|
||||
right.headers = hdrs;
|
||||
right.types = types;
|
||||
right.cells = g_cells;
|
||||
right.rows = 3;
|
||||
right.cols = 2;
|
||||
|
||||
Join j;
|
||||
j.alias = "r";
|
||||
j.source = "right";
|
||||
j.on = {{"Name", "Name"}};
|
||||
j.strategy = JoinStrategy::Left;
|
||||
|
||||
StageOutput out = join_tables(g_cells, 3, 2, hdrs, types, right, j);
|
||||
assert(out.rows == 3 && "join_tables: self-join must produce 3 rows");
|
||||
std::printf("PASS: join_tables trivial self-join (rows=%d cols=%d)\n",
|
||||
out.rows, out.cols);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 8: data_table::render symbol is linkable (Wave 3.5 — issue 0081-I).
|
||||
// Does NOT call render (requires ImGui context); just takes its address.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_data_table_render_links() {
|
||||
// Taking the address of render verifies the linker resolved the symbol
|
||||
// from data_table.cpp inside fn_table_viz. No ImGui context needed.
|
||||
// Use the events_out overload (Phase 2) as the canonical full-signature check.
|
||||
auto* render_fn = static_cast<void(*)(const char*,
|
||||
const std::vector<data_table::TableInput>&,
|
||||
data_table::State&,
|
||||
std::vector<data_table::TableEvent>*,
|
||||
bool)>(&data_table::render);
|
||||
(void)render_fn;
|
||||
std::printf("PASS: data_table::render symbol links (address=%p)\n",
|
||||
reinterpret_cast<void*>(render_fn));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
int main() {
|
||||
std::printf("=== test_fn_table_viz_smoke ===\n");
|
||||
test_compute_stage_passthrough();
|
||||
test_auto_detect_type();
|
||||
test_compute_column_stats();
|
||||
test_tql_roundtrip();
|
||||
test_tql_apply_extended();
|
||||
test_lua_engine_compile();
|
||||
test_join_tables_trivial();
|
||||
test_data_table_render_links();
|
||||
std::printf("=== ALL TESTS PASSED ===\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user