diff --git a/cpp/apps/primitives_gallery/CMakeLists.txt b/cpp/apps/primitives_gallery/CMakeLists.txt index 9d2bb614..5205dbed 100644 --- a/cpp/apps/primitives_gallery/CMakeLists.txt +++ b/cpp/apps/primitives_gallery/CMakeLists.txt @@ -17,12 +17,14 @@ add_imgui_app(primitives_gallery ${CMAKE_SOURCE_DIR}/functions/core/timeline.cpp demos_sql.cpp demos_scientific.cpp - # text_editor + file_watcher (issue 0025) + # text_editor + file_watcher (issue 0025) + file_poll_diff pure (issue 0045) ${CMAKE_SOURCE_DIR}/functions/core/text_editor.cpp ${CMAKE_SOURCE_DIR}/functions/core/file_watcher.cpp + ${CMAKE_SOURCE_DIR}/functions/core/file_poll_diff.cpp ${CMAKE_SOURCE_DIR}/vendor/imgui_text_edit/TextEditor.cpp - # sql_workbench (issue 0032) + # sql_workbench (issue 0032) + sql_parse pure (issue 0045) ${CMAKE_SOURCE_DIR}/functions/core/sql_workbench.cpp + ${CMAKE_SOURCE_DIR}/functions/core/sql_parse.cpp # Core primitives demoed (tokens vive en fn_framework) ${CMAKE_SOURCE_DIR}/functions/core/fullscreen_window.cpp ${CMAKE_SOURCE_DIR}/functions/core/page_header.cpp @@ -38,6 +40,7 @@ add_imgui_app(primitives_gallery ${CMAKE_SOURCE_DIR}/functions/core/toast.cpp ${CMAKE_SOURCE_DIR}/functions/core/tree_view.cpp ${CMAKE_SOURCE_DIR}/functions/core/process_runner.cpp + ${CMAKE_SOURCE_DIR}/functions/core/process_state_machine.cpp # Viz primitives demoed ${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp ${CMAKE_SOURCE_DIR}/functions/viz/bar_chart.cpp diff --git a/cpp/apps/shaders_lab/CMakeLists.txt b/cpp/apps/shaders_lab/CMakeLists.txt index b5da11af..5e8a9d43 100644 --- a/cpp/apps/shaders_lab/CMakeLists.txt +++ b/cpp/apps/shaders_lab/CMakeLists.txt @@ -1,5 +1,6 @@ add_imgui_app(shaders_lab main.cpp + compiler.cpp ${CMAKE_SOURCE_DIR}/functions/gfx/gl_loader.cpp ${CMAKE_SOURCE_DIR}/functions/gfx/gl_shader.cpp ${CMAKE_SOURCE_DIR}/functions/gfx/gl_framebuffer.cpp diff --git a/cpp/apps/shaders_lab/compiler.cpp b/cpp/apps/shaders_lab/compiler.cpp new file mode 100644 index 00000000..1f2f5d57 --- /dev/null +++ b/cpp/apps/shaders_lab/compiler.cpp @@ -0,0 +1,63 @@ +#include "compiler.h" + +#include "gfx/shader_canvas.h" +#include "gfx/gl_shader.h" +#include "gfx/uniform_parser.h" +#include "gfx/uniform_panel.h" +#include "gfx/dag_compile.h" +#include "gfx/dag_uniforms.h" + +#include +#include +#include + +// ── Globals declarados en main.cpp (single source of truth) ───────────────── +extern fn::gfx::ShaderCanvas g_canvas_code; +extern fn::gfx::ShaderCanvas g_canvas_dag; +extern std::string g_source; +extern std::string g_code_err; +extern int g_code_err_line; +extern std::chrono::steady_clock::time_point g_code_last_edit; +extern bool g_code_dirty; +extern std::vector g_descs; +extern fn::gfx::UniformStore g_store; +extern std::vector g_pipeline; +extern std::string g_dag_glsl; +extern std::string g_dag_err; +extern int g_dag_err_line; + +namespace shaders_lab { + +void compile_code() { + auto r = fn::gfx::compile_fragment(g_source); + if (r.ok) { + g_descs = fn::gfx::parse_uniforms(g_source); + fn::gfx::uniforms_sync(g_store, g_descs); + fn::gfx::canvas_set_program(g_canvas_code, r.program); + g_code_err.clear(); + g_code_err_line = -1; + } else { + g_code_err = r.err_msg; + g_code_err_line = r.err_line; + } +} + +void compile_dag() { + g_dag_glsl = fn::gfx::compile_dag_to_glsl(g_pipeline); + auto r = fn::gfx::compile_fragment(g_dag_glsl); + if (r.ok) { + fn::gfx::canvas_set_program(g_canvas_dag, r.program); + g_dag_err.clear(); + g_dag_err_line = -1; + } else { + g_dag_err = r.err_msg; + g_dag_err_line = r.err_line; + } +} + +void mark_code_dirty() { + g_code_last_edit = std::chrono::steady_clock::now(); + g_code_dirty = true; +} + +} // namespace shaders_lab diff --git a/cpp/apps/shaders_lab/compiler.h b/cpp/apps/shaders_lab/compiler.h new file mode 100644 index 00000000..44d4f3fd --- /dev/null +++ b/cpp/apps/shaders_lab/compiler.h @@ -0,0 +1,24 @@ +#pragma once + +// shaders_lab/compiler — extrae las rutinas impuras de compilacion del shader +// (compile_code, compile_dag, mark_code_dirty) desde main.cpp para que el +// archivo principal quede acotado a la composicion de paneles ImGui. +// +// Las globals (g_source, g_descs, g_store, g_pipeline, etc.) se declaran +// extern y viven en main.cpp; aqui solo orquestamos compilacion. + +namespace shaders_lab { + +// Compila g_source -> programa OpenGL para g_canvas_code, refresca g_descs +// y sincroniza g_store. Actualiza g_code_err / g_code_err_line. +void compile_code(); + +// Compila g_pipeline -> g_dag_glsl -> programa OpenGL para g_canvas_dag. +// Actualiza g_dag_err / g_dag_err_line. +void compile_dag(); + +// Marca el shader Code como dirty y registra el timestamp del ultimo edit +// (para debounce de 250ms en el render loop). +void mark_code_dirty(); + +} // namespace shaders_lab diff --git a/cpp/apps/shaders_lab/main.cpp b/cpp/apps/shaders_lab/main.cpp index 833eca56..6c3d4067 100644 --- a/cpp/apps/shaders_lab/main.cpp +++ b/cpp/apps/shaders_lab/main.cpp @@ -19,6 +19,8 @@ #include "core/app_menubar.h" #include "core/layout_storage.h" +#include "compiler.h" + #include #include #include @@ -27,8 +29,9 @@ #include #include -static fn::gfx::ShaderCanvas g_canvas_code; -static fn::gfx::ShaderCanvas g_canvas_dag; +// Globals: linked extern desde compiler.cpp. NO `static` aqui. +fn::gfx::ShaderCanvas g_canvas_code; +fn::gfx::ShaderCanvas g_canvas_dag; // Default placeholder so the Code panel does something useful on first launch // without committing to one specific look. @@ -47,19 +50,19 @@ void main() { } )glsl"; -static std::string g_source = CODE_PLACEHOLDER; -static std::string g_code_err; -static int g_code_err_line = -1; -static std::chrono::steady_clock::time_point g_code_last_edit; -static bool g_code_dirty = true; -static std::vector g_descs; -static fn::gfx::UniformStore g_store; +std::string g_source = CODE_PLACEHOLDER; +std::string g_code_err; +int g_code_err_line = -1; +std::chrono::steady_clock::time_point g_code_last_edit; +bool g_code_dirty = true; +std::vector g_descs; +fn::gfx::UniformStore g_store; -static std::vector g_pipeline; -static std::string g_dag_glsl; -static std::string g_dag_err; -static int g_dag_err_line = -1; -static bool g_dag_dirty = true; +std::vector g_pipeline; +std::string g_dag_glsl; +std::string g_dag_err; +int g_dag_err_line = -1; +static bool g_dag_dirty = true; // solo lo usa main.cpp // ── Panel visibility (toggled from View menu and panel close button) ────── static bool g_show_code = true; @@ -84,37 +87,10 @@ static char g_save_desc[256] = ""; static char g_save_tags[128] = "shaders_lab,user"; static std::string g_save_err; -static void compile_code() { - auto r = fn::gfx::compile_fragment(g_source); - if (r.ok) { - g_descs = fn::gfx::parse_uniforms(g_source); - fn::gfx::uniforms_sync(g_store, g_descs); - fn::gfx::canvas_set_program(g_canvas_code, r.program); - g_code_err.clear(); - g_code_err_line = -1; - } else { - g_code_err = r.err_msg; - g_code_err_line = r.err_line; - } -} - -static void compile_dag() { - g_dag_glsl = fn::gfx::compile_dag_to_glsl(g_pipeline); - auto r = fn::gfx::compile_fragment(g_dag_glsl); - if (r.ok) { - fn::gfx::canvas_set_program(g_canvas_dag, r.program); - g_dag_err.clear(); - g_dag_err_line = -1; - } else { - g_dag_err = r.err_msg; - g_dag_err_line = r.err_line; - } -} - -static void mark_code_dirty() { - g_code_last_edit = std::chrono::steady_clock::now(); - g_code_dirty = true; -} +// compile_code, compile_dag, mark_code_dirty viven en compiler.cpp +using shaders_lab::compile_code; +using shaders_lab::compile_dag; +using shaders_lab::mark_code_dirty; static void ensure_dag_default() { if (g_pipeline.empty()) { diff --git a/cpp/apps/text_editor_smoke/CMakeLists.txt b/cpp/apps/text_editor_smoke/CMakeLists.txt index 5572da34..54beeb9c 100644 --- a/cpp/apps/text_editor_smoke/CMakeLists.txt +++ b/cpp/apps/text_editor_smoke/CMakeLists.txt @@ -7,6 +7,7 @@ add_imgui_app(text_editor_smoke main.cpp ${CMAKE_SOURCE_DIR}/functions/core/text_editor.cpp ${CMAKE_SOURCE_DIR}/functions/core/file_watcher.cpp + ${CMAKE_SOURCE_DIR}/functions/core/file_poll_diff.cpp ${CMAKE_SOURCE_DIR}/vendor/imgui_text_edit/TextEditor.cpp ) target_include_directories(text_editor_smoke PRIVATE diff --git a/cpp/functions/core/file_poll_diff.cpp b/cpp/functions/core/file_poll_diff.cpp new file mode 100644 index 00000000..794ff64a --- /dev/null +++ b/cpp/functions/core/file_poll_diff.cpp @@ -0,0 +1,45 @@ +#include "core/file_poll_diff.h" + +#include +#include + +namespace fn_ui { + +FileDiff file_poll_diff(const std::vector& before, + const std::vector& after) { + FileDiff out; + + // Index before by path para lookup O(1). + std::unordered_map idx_before; + idx_before.reserve(before.size()); + for (const auto& e : before) { + idx_before[e.path] = &e; + } + + // Recorrer after: clasificar added/modified. + std::unordered_set seen_in_after; + seen_in_after.reserve(after.size()); + for (const auto& e : after) { + seen_in_after.insert(e.path); + auto it = idx_before.find(e.path); + if (it == idx_before.end()) { + out.added.push_back(e.path); + } else { + const FileEntry* prev = it->second; + if (prev->size != e.size || prev->mtime != e.mtime) { + out.modified.push_back(e.path); + } + } + } + + // Recorrer before: detectar removed (paths que no estan en after). + for (const auto& e : before) { + if (seen_in_after.find(e.path) == seen_in_after.end()) { + out.removed.push_back(e.path); + } + } + + return out; +} + +} // namespace fn_ui diff --git a/cpp/functions/core/file_poll_diff.h b/cpp/functions/core/file_poll_diff.h new file mode 100644 index 00000000..975964bb --- /dev/null +++ b/cpp/functions/core/file_poll_diff.h @@ -0,0 +1,34 @@ +#pragma once + +// file_poll_diff — diff puro entre dos snapshots de filesystem. +// +// Pareja natural de file_watcher: en plataformas sin inotify/RDCW (o cuando +// se quiere fallback portable), el caller toma snapshots periodicos via +// stat()/opendir() y los compara con esta funcion para emitir eventos +// added/modified/removed. Sin I/O, sin estado. + +#include +#include +#include + +namespace fn_ui { + +struct FileEntry { + std::string path; + uint64_t size = 0; + int64_t mtime = 0; // unix seconds +}; + +struct FileDiff { + std::vector added; + std::vector modified; // size o mtime distinto + std::vector removed; +}; + +// Pura: calcula diff entre dos snapshots por path. Asume entries con paths +// unicos en cada snapshot. Orden de los vectores: el orden en que aparecen +// los paths en `after` (added/modified) y en `before` (removed). +FileDiff file_poll_diff(const std::vector& before, + const std::vector& after); + +} // namespace fn_ui diff --git a/cpp/functions/core/file_poll_diff.md b/cpp/functions/core/file_poll_diff.md new file mode 100644 index 00000000..526148bd --- /dev/null +++ b/cpp/functions/core/file_poll_diff.md @@ -0,0 +1,77 @@ +--- +name: file_poll_diff +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "fn_ui::FileDiff fn_ui::file_poll_diff(const std::vector& before, const std::vector& after)" +description: "Diff puro entre dos snapshots de filesystem (path/size/mtime). Devuelve vectores added/modified/removed. Sin I/O. Pareja del file_watcher para fallback portable basado en polling." +tags: [filesystem, diff, poll, snapshot, pure] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["file_poll_diff detects added/modified/removed", "file_poll_diff: empty inputs", "file_poll_diff: identical snapshots"] +test_file_path: "cpp/tests/test_file_poll_diff.cpp" +file_path: "cpp/functions/core/file_poll_diff.cpp" +params: + - name: before + desc: "Snapshot anterior. Vector de FileEntry {path, size, mtime}, paths unicos" + - name: after + desc: "Snapshot actual. Mismo formato. Paths unicos" +output: "FileDiff con tres vectores de paths: added (en after y no en before), modified (path comun pero size o mtime distinto), removed (en before y no en after)." +--- + +# file_poll_diff + +Logica pura para calcular cambios de filesystem entre dos snapshots. Sin +I/O, sin estado: el caller hace `stat()`/`opendir()` por su lado, construye +los `FileEntry`, y esta funcion los compara. + +## API + +```cpp +namespace fn_ui { + +struct FileEntry { + std::string path; + uint64_t size = 0; + int64_t mtime = 0; // unix seconds +}; + +struct FileDiff { + std::vector added; + std::vector modified; + std::vector removed; +}; + +FileDiff file_poll_diff(const std::vector& before, + const std::vector& after); + +} +``` + +## Reglas + +- Comparacion por `path` exacto. +- "Modified": al menos uno de `size` o `mtime` cambia. +- Asume paths unicos por snapshot (la funcion no deduplica). +- Complejidad: O(N+M) con un `unordered_map` para + indexar `before`. + +## Cuando usar + +- En plataformas donde `file_watcher` no tiene backend nativo (stub) y se + necesita un fallback basado en polling. +- Para consolidar bursts de eventos: tomar dos snapshots en el tiempo y + reportar solo el cambio neto (sin los intermedios). +- Tests del watcher: simular cambios de FS sin tocar disco. + +## Por que pura + +No abre archivos, no llama `stat`, no usa el reloj. Misma entrada produce la +misma salida — testeable sin fixtures de FS. diff --git a/cpp/functions/core/file_watcher.cpp b/cpp/functions/core/file_watcher.cpp index a115f707..8de9c4e2 100644 --- a/cpp/functions/core/file_watcher.cpp +++ b/cpp/functions/core/file_watcher.cpp @@ -1,5 +1,7 @@ #include "file_watcher.h" +#include "core/file_poll_diff.h" + #include #include #include @@ -280,4 +282,29 @@ const char* file_watcher_last_error(const FileWatcher* w) { return w ? w->last_ #endif +// ───────────────────────────────────────────────────────────────────────────── +// Helper portable basado en file_poll_diff (puro). Comun a todas las plataformas. +// ───────────────────────────────────────────────────────────────────────────── + +std::vector file_watcher_events_from_diff( + const std::vector& before, + const std::vector& after) { + + // Convertir a fn_ui::FileEntry para llamar a la funcion pura. + std::vector a, b; + a.reserve(before.size()); + b.reserve(after.size()); + for (const auto& e : before) a.push_back({e.path, e.size, e.mtime}); + for (const auto& e : after) b.push_back({e.path, e.size, e.mtime}); + + fn_ui::FileDiff diff = fn_ui::file_poll_diff(a, b); + + std::vector out; + out.reserve(diff.added.size() + diff.modified.size() + diff.removed.size()); + for (const auto& p : diff.added) out.push_back({p, FileEvent::Created}); + for (const auto& p : diff.modified) out.push_back({p, FileEvent::Modified}); + for (const auto& p : diff.removed) out.push_back({p, FileEvent::Deleted}); + return out; +} + } // namespace fn diff --git a/cpp/functions/core/file_watcher.h b/cpp/functions/core/file_watcher.h index fcfa817f..0c847d70 100644 --- a/cpp/functions/core/file_watcher.h +++ b/cpp/functions/core/file_watcher.h @@ -7,7 +7,12 @@ // Otros: stub (poll() devuelve siempre vacio) // // API no bloqueante: poll() drena los eventos disponibles desde la ultima llamada. +// +// Para plataformas sin backend nativo, el caller puede usar la helper +// file_watcher_events_from_diff que delega en `file_poll_diff` (puro) para +// derivar FileEvents a partir de dos snapshots tomados con stat()/opendir(). +#include #include #include @@ -21,6 +26,14 @@ struct FileEvent { Kind kind; }; +// Snapshot de filesystem (forma simple, identica al fn_ui::FileEntry de +// file_poll_diff_cpp_core). Se replica aqui para no acoplar headers en apps. +struct FileSnapshotEntry { + std::string path; + uint64_t size = 0; + int64_t mtime = 0; +}; + // Crea un watcher vacio. El caller llama destroy. FileWatcher* file_watcher_create(); @@ -38,4 +51,14 @@ std::vector file_watcher_poll(FileWatcher* w); // Devuelve el mensaje del ultimo error (vacio si no hay). const char* file_watcher_last_error(const FileWatcher* w); +// Helper portable: convierte dos snapshots de FS en FileEvents usando la +// logica pura `file_poll_diff_cpp_core`. Util en plataformas sin inotify/ +// RDCW o como fallback de polling. El caller construye los snapshots con +// stat()/opendir(). +// +// Mapeo: added -> Created, modified -> Modified, removed -> Deleted. +std::vector file_watcher_events_from_diff( + const std::vector& before, + const std::vector& after); + } // namespace fn diff --git a/cpp/functions/core/file_watcher.md b/cpp/functions/core/file_watcher.md index 517b38e4..d91eef4c 100644 --- a/cpp/functions/core/file_watcher.md +++ b/cpp/functions/core/file_watcher.md @@ -8,7 +8,7 @@ purity: impure signature: "fn::FileWatcher* fn::file_watcher_create(); void fn::file_watcher_destroy(fn::FileWatcher*); bool fn::file_watcher_add(fn::FileWatcher*, const char* path); std::vector fn::file_watcher_poll(fn::FileWatcher*); const char* fn::file_watcher_last_error(const fn::FileWatcher*)" description: "Watcher de archivos/directorios cross-platform (Linux inotify, Windows ReadDirectoryChangesW). API no bloqueante: registra paths con add() y consulta eventos con poll(). Cada poll() drena todos los eventos pendientes desde la llamada anterior." tags: [filesystem, watcher, inotify, file_events, io] -uses_functions: [] +uses_functions: ["file_poll_diff_cpp_core"] uses_types: [] returns: [] returns_optional: false diff --git a/cpp/functions/core/process_runner.cpp b/cpp/functions/core/process_runner.cpp index efe5ab82..371ac6b3 100644 --- a/cpp/functions/core/process_runner.cpp +++ b/cpp/functions/core/process_runner.cpp @@ -1,10 +1,26 @@ #include "core/process_runner.h" +#include "core/process_state_machine.h" #include "core/tokens.h" #include #include namespace fn_ui { +// Helpers para puentear el RunnerState (publico) con la SM pura. +// RunnerState y ProcessState comparten orden y valores enteros (0..3). +namespace { +ProcessState to_ps(RunnerState s) { return static_cast(static_cast(s)); } +RunnerState to_rs(ProcessState s) { return static_cast(static_cast(s)); } + +// Aplica un evento al estado actual del runner via process_transition. +void apply_event(ProcessRunner& r, std::atomic& state, ProcessEvent ev) { + (void)r; + int cur = state.load(); + ProcessState ns = process_transition(to_ps(static_cast(cur)), ev); + state.store(static_cast(to_rs(ns))); +} +} // namespace + ProcessRunner::ProcessRunner() = default; ProcessRunner::~ProcessRunner() { @@ -26,7 +42,8 @@ std::string ProcessRunner::message() const { void ProcessRunner::reset() { if (is_busy()) return; - state_.store(static_cast(RunnerState::Idle)); + // Reset event: Success/Error -> Idle (no-op si ya en Idle). + apply_event(*this, state_, ProcessEvent::Reset); std::lock_guard lk(mu_); message_.clear(); } @@ -36,7 +53,9 @@ void runner_trigger(ProcessRunner& r, if (r.is_busy()) return; if (r.th_.joinable()) r.th_.join(); - r.state_.store(static_cast(RunnerState::Running)); + // Si venimos de Success/Error, primero Reset -> Idle, luego Spawned -> Running. + apply_event(r, r.state_, ProcessEvent::Reset); + apply_event(r, r.state_, ProcessEvent::Spawned); { std::lock_guard lk(r.mu_); r.message_.clear(); @@ -55,7 +74,8 @@ void runner_trigger(ProcessRunner& r, std::lock_guard lk(r.mu_); r.message_ = std::move(out); } - r.state_.store(static_cast(ok ? RunnerState::Success : RunnerState::Error)); + // Running -> Success/Error via SM pura. + apply_event(r, r.state_, ok ? ProcessEvent::Finished : ProcessEvent::Failed); }); } diff --git a/cpp/functions/core/process_runner.md b/cpp/functions/core/process_runner.md index 924ba5b1..a33fd46f 100644 --- a/cpp/functions/core/process_runner.md +++ b/cpp/functions/core/process_runner.md @@ -8,7 +8,7 @@ purity: impure signature: "class fn_ui::ProcessRunner { is_busy(); state(); message(); reset(); }; void runner_trigger(ProcessRunner&, std::function); void runner_status(const ProcessRunner&, const char* label)" description: "Ejecuta una tarea en std::thread en background y expone estado thread-safe (idle/running/success/error). Incluye widget inline con spinner + resultado." tags: [imgui, ui, async, thread, runner, tokens] -uses_functions: ["tokens_cpp_core"] +uses_functions: ["process_state_machine_cpp_core", "tokens_cpp_core"] uses_types: [] returns: [] returns_optional: false diff --git a/cpp/functions/core/process_state_machine.cpp b/cpp/functions/core/process_state_machine.cpp new file mode 100644 index 00000000..6115bba9 --- /dev/null +++ b/cpp/functions/core/process_state_machine.cpp @@ -0,0 +1,47 @@ +#include "core/process_state_machine.h" + +namespace fn_ui { + +ProcessState process_transition(ProcessState s, ProcessEvent e) { + using S = ProcessState; + using E = ProcessEvent; + switch (s) { + case S::Idle: + // Trigger es una solicitud, el cambio real ocurre con Spawned. + if (e == E::Spawned) return S::Running; + return s; + case S::Running: + if (e == E::Finished) return S::Success; + if (e == E::Failed || e == E::Timeout) return S::Error; + return s; + case S::Success: + case S::Error: + if (e == E::Reset) return S::Idle; + return s; + } + return s; +} + +const char* process_state_name(ProcessState s) { + switch (s) { + case ProcessState::Idle: return "Idle"; + case ProcessState::Running: return "Running"; + case ProcessState::Success: return "Success"; + case ProcessState::Error: return "Error"; + } + return "Unknown"; +} + +const char* process_event_name(ProcessEvent e) { + switch (e) { + case ProcessEvent::Trigger: return "Trigger"; + case ProcessEvent::Spawned: return "Spawned"; + case ProcessEvent::Finished: return "Finished"; + case ProcessEvent::Failed: return "Failed"; + case ProcessEvent::Timeout: return "Timeout"; + case ProcessEvent::Reset: return "Reset"; + } + return "Unknown"; +} + +} // namespace fn_ui diff --git a/cpp/functions/core/process_state_machine.h b/cpp/functions/core/process_state_machine.h new file mode 100644 index 00000000..e6da9c90 --- /dev/null +++ b/cpp/functions/core/process_state_machine.h @@ -0,0 +1,36 @@ +#pragma once + +// process_state_machine — logica pura de transiciones de un proceso async +// (idle/running/success/error) a partir de eventos. Sin threads, sin I/O. +// +// Diseñada para que `process_runner` (impuro, threads + popen) delegue las +// transiciones aqui y la UI/CLI puedan razonar sobre el estado sin depender +// de la implementacion concreta del runner. + +namespace fn_ui { + +enum class ProcessState { + Idle, + Running, + Success, + Error, +}; + +enum class ProcessEvent { + Trigger, // se solicita lanzar (no cambia estado por si solo) + Spawned, // el proceso/task realmente arranco + Finished, // termino con exito + Failed, // termino con error + Timeout, // se cancelo por timeout (-> Error) + Reset, // limpiar a Idle (solo desde Success/Error) +}; + +// Pura: dado (state, event) devuelve el nuevo estado. Transiciones invalidas +// devuelven el mismo estado sin mutar. +ProcessState process_transition(ProcessState s, ProcessEvent e); + +// Nombres legibles para logs/UI (sin alocar — punteros a literales staticos). +const char* process_state_name(ProcessState s); +const char* process_event_name(ProcessEvent e); + +} // namespace fn_ui diff --git a/cpp/functions/core/process_state_machine.md b/cpp/functions/core/process_state_machine.md new file mode 100644 index 00000000..86450842 --- /dev/null +++ b/cpp/functions/core/process_state_machine.md @@ -0,0 +1,71 @@ +--- +name: process_state_machine +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "fn_ui::ProcessState fn_ui::process_transition(fn_ui::ProcessState s, fn_ui::ProcessEvent e); const char* fn_ui::process_state_name(fn_ui::ProcessState); const char* fn_ui::process_event_name(fn_ui::ProcessEvent)" +description: "State machine puro para procesos async. Estados: Idle, Running, Success, Error. Eventos: Trigger, Spawned, Finished, Failed, Timeout, Reset. Transiciones invalidas devuelven el mismo estado sin mutar." +tags: [state_machine, process, async, runner, pure] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["process_transition: idle/spawned -> running", "process_transition: running -> success/error", "process_transition: reset from terminal", "process_transition: invalid events keep state", "process_state_name and process_event_name"] +test_file_path: "cpp/tests/test_process_state_machine.cpp" +file_path: "cpp/functions/core/process_state_machine.cpp" +params: + - name: s + desc: "Estado actual (Idle, Running, Success, Error)" + - name: e + desc: "Evento a aplicar (Trigger, Spawned, Finished, Failed, Timeout, Reset)" +output: "Nuevo ProcessState. Si la transicion (s, e) no esta definida, devuelve s sin cambios. process_state_name y process_event_name retornan punteros a literales staticos (no alocan)." +--- + +# process_state_machine + +Las apps del registry usan `process_runner` (impuro: threads, mutex, popen) +para lanzar reindex, builds, deploys... Esta funcion extrae el contrato de +estados a una tabla pura que se puede testear, simular y compartir con CLIs +y bots sin depender del runner concreto. + +## Tabla de transiciones + +| Estado actual | Evento | Nuevo estado | +|---------------|------------|--------------| +| Idle | Spawned | Running | +| Idle | Trigger | Idle (la solicitud no cambia el estado por si sola — el runner debe Spawnar) | +| Running | Finished | Success | +| Running | Failed | Error | +| Running | Timeout | Error | +| Success | Reset | Idle | +| Error | Reset | Idle | +| cualquier otra combinacion | | mismo estado | + +`Trigger` se mantiene como evento explicito para que el runner pueda emitir +"se pidio lanzar" antes de saber si el proceso arranca (util para logs y UI). + +## API + +```cpp +namespace fn_ui { + +enum class ProcessState { Idle, Running, Success, Error }; +enum class ProcessEvent { Trigger, Spawned, Finished, Failed, Timeout, Reset }; + +ProcessState process_transition(ProcessState s, ProcessEvent e); +const char* process_state_name(ProcessState s); +const char* process_event_name(ProcessEvent e); + +} +``` + +## Uso desde process_runner + +`process_runner` mantiene su `std::atomic` con el estado, pero llama a +`process_transition` para decidir el siguiente valor. Esto evita que la +logica de transicion se duplique entre el thread productor y la UI consumer. diff --git a/cpp/functions/core/sql_parse.cpp b/cpp/functions/core/sql_parse.cpp new file mode 100644 index 00000000..a06e9a06 --- /dev/null +++ b/cpp/functions/core/sql_parse.cpp @@ -0,0 +1,179 @@ +#include "core/sql_parse.h" + +#include +#include + +namespace fn_ui { + +namespace { + +// Devuelve la version uppercase ASCII del primer token (delimitado por +// whitespace) de `s`, asumiendo que `s` empieza ya en el token. +std::string first_token_upper(const std::string& s, size_t start) { + std::string out; + while (start < s.size()) { + unsigned char c = static_cast(s[start]); + if (std::isspace(c)) break; + if (!std::isalpha(c)) break; // keywords son solo letras + out.push_back(static_cast(std::toupper(c))); + ++start; + } + return out; +} + +// Devuelve el indice del primer caracter "real" (no whitespace, no comentario) +// a partir de `i`. Avanza saltando -- ... \n y /* ... */. +size_t skip_ws_and_comments(const std::string& s, size_t i) { + while (i < s.size()) { + unsigned char c = static_cast(s[i]); + if (std::isspace(c)) { ++i; continue; } + if (c == '-' && i + 1 < s.size() && s[i + 1] == '-') { + // line comment hasta \n + i += 2; + while (i < s.size() && s[i] != '\n') ++i; + continue; + } + if (c == '/' && i + 1 < s.size() && s[i + 1] == '*') { + // block comment hasta */ + i += 2; + while (i + 1 < s.size() && !(s[i] == '*' && s[i + 1] == '/')) ++i; + if (i + 1 < s.size()) i += 2; + else i = s.size(); + continue; + } + break; + } + return i; +} + +std::string trim(const std::string& s) { + size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +} // namespace + +SqlStmtKind sql_classify(const std::string& stmt) { + size_t i = skip_ws_and_comments(stmt, 0); + if (i >= stmt.size()) return SqlStmtKind::Unknown; + std::string head = first_token_upper(stmt, i); + if (head == "SELECT" || head == "WITH") return SqlStmtKind::Select; + if (head == "INSERT") return SqlStmtKind::Insert; + if (head == "UPDATE") return SqlStmtKind::Update; + if (head == "DELETE") return SqlStmtKind::Delete; + if (head == "CREATE") return SqlStmtKind::Create; + if (head == "DROP") return SqlStmtKind::Drop; + if (head == "ALTER") return SqlStmtKind::Alter; + if (head == "PRAGMA") return SqlStmtKind::Pragma; + if (head == "EXPLAIN") return SqlStmtKind::Explain; + return SqlStmtKind::Unknown; +} + +std::vector sql_parse(const std::string& input) { + std::vector out; + + enum class Mode { Normal, LineComment, BlockComment, SingleStr, DoubleStr, BackTick }; + Mode m = Mode::Normal; + + size_t stmt_start = 0; + int cur_line = 1; + int stmt_line = 1; + bool stmt_has_content = false; + + auto flush = [&](size_t end) { + std::string raw = input.substr(stmt_start, end - stmt_start); + std::string t = trim(raw); + if (!t.empty()) { + SqlStatement s; + s.text = t; + s.kind = sql_classify(t); + s.line = stmt_line; + out.push_back(std::move(s)); + } + }; + + for (size_t i = 0; i < input.size(); ++i) { + char c = input[i]; + char n = (i + 1 < input.size()) ? input[i + 1] : '\0'; + if (c == '\n') ++cur_line; + + switch (m) { + case Mode::Normal: + if (c == '-' && n == '-') { + m = Mode::LineComment; + ++i; + } else if (c == '/' && n == '*') { + m = Mode::BlockComment; + ++i; + } else if (!stmt_has_content && !std::isspace(static_cast(c))) { + // marca inicio "real" de un statement (despues de skip ws/comments) + stmt_line = cur_line; + stmt_has_content = true; + // procesar el caracter actual abajo (no es comentario ni ws) + if (c == '\'') m = Mode::SingleStr; + else if (c == '"') m = Mode::DoubleStr; + else if (c == '`') m = Mode::BackTick; + else if (c == ';') { + flush(i); + stmt_start = i + 1; + stmt_has_content = false; + } + } else if (c == '\'') { + m = Mode::SingleStr; + } else if (c == '"') { + m = Mode::DoubleStr; + } else if (c == '`') { + m = Mode::BackTick; + } else if (c == ';') { + flush(i); + stmt_start = i + 1; + stmt_has_content = false; + } + break; + + case Mode::LineComment: + if (c == '\n') m = Mode::Normal; + break; + + case Mode::BlockComment: + if (c == '*' && n == '/') { + m = Mode::Normal; + ++i; + } + break; + + case Mode::SingleStr: + if (c == '\'') { + // SQL escapa '' como literal, sigue dentro de la cadena. + if (n == '\'') { ++i; } + else m = Mode::Normal; + } + break; + + case Mode::DoubleStr: + if (c == '"') { + if (n == '"') { ++i; } + else m = Mode::Normal; + } + break; + + case Mode::BackTick: + if (c == '`') { + if (n == '`') { ++i; } + else m = Mode::Normal; + } + break; + } + } + + // ultimo statement sin ';' final + if (stmt_start < input.size()) { + flush(input.size()); + } + + return out; +} + +} // namespace fn_ui diff --git a/cpp/functions/core/sql_parse.h b/cpp/functions/core/sql_parse.h new file mode 100644 index 00000000..4bfd380d --- /dev/null +++ b/cpp/functions/core/sql_parse.h @@ -0,0 +1,48 @@ +#pragma once + +// sql_parse — tokenizer y clasificador de statements SQL (logica pura). +// +// Separa una cadena multi-statement por ';' (fuera de strings y comentarios) +// y clasifica cada statement por su keyword inicial. No ejecuta nada — esta +// funcion es 100% pura: misma entrada, misma salida. +// +// Uso tipico: +// +// auto stmts = fn_ui::sql_parse("SELECT 1; INSERT INTO t VALUES (2);"); +// for (auto& s : stmts) { +// switch (s.kind) { ... } +// } + +#include +#include + +namespace fn_ui { + +enum class SqlStmtKind { + Unknown, + Select, + Insert, + Update, + Delete, + Create, + Drop, + Alter, + Pragma, + Explain, +}; + +struct SqlStatement { + SqlStmtKind kind = SqlStmtKind::Unknown; + std::string text; // texto trimeado (sin ';' final) + int line = 1; // linea de inicio en el input (1-based) +}; + +// Tokeniza SQL multi-statement. Salta cadenas '...' "..." `...` y comentarios +// -- linea y /* bloque */. Devuelve los statements no vacios. +std::vector sql_parse(const std::string& input); + +// Clasifica un statement individual por su keyword inicial (case-insensitive, +// despues de saltar whitespace y comentarios iniciales). +SqlStmtKind sql_classify(const std::string& stmt); + +} // namespace fn_ui diff --git a/cpp/functions/core/sql_parse.md b/cpp/functions/core/sql_parse.md new file mode 100644 index 00000000..09eb7c88 --- /dev/null +++ b/cpp/functions/core/sql_parse.md @@ -0,0 +1,76 @@ +--- +name: sql_parse +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "std::vector fn_ui::sql_parse(const std::string& input); fn_ui::SqlStmtKind fn_ui::sql_classify(const std::string& stmt)" +description: "Tokenizer y clasificador puro de SQL multi-statement. Separa por ';' fuera de strings ('...', \"...\", `...`) y comentarios (-- linea, /* bloque */), trimea, y clasifica cada statement por su keyword inicial (SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, PRAGMA, EXPLAIN, WITH→Select)." +tags: [sql, parser, tokenizer, pure, sqlite] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["sql_parse classifies common statements", "sql_parse handles strings and comments", "sql_parse trims and ignores empty"] +test_file_path: "cpp/tests/test_sql_parse.cpp" +file_path: "cpp/functions/core/sql_parse.cpp" +params: + - name: input + desc: "Texto SQL completo, posiblemente multi-statement, con ';' opcional al final" + - name: stmt + desc: "(sql_classify) Un solo statement ya separado, sin ';' final" +output: "sql_parse: vector con un SqlStatement por statement no vacio (kind, text trimeado, line 1-based de inicio en el input). sql_classify: SqlStmtKind segun la primera keyword del statement." +--- + +# sql_parse + +Logica pura para entender un script SQL antes de pasarlo al motor: separar +statements y clasificarlos. Sin estado, sin I/O, sin SQLite. Reutilizable +desde `sql_workbench` y desde cualquier app/CLI que necesite distinguir +SELECT de DDL para mostrar info al usuario o decidir como ejecutar. + +## API + +```cpp +namespace fn_ui { + +enum class SqlStmtKind { + Unknown, Select, Insert, Update, Delete, Create, Drop, Alter, Pragma, Explain +}; + +struct SqlStatement { + SqlStmtKind kind; + std::string text; // texto trimeado + int line; // linea de inicio en el input (1-based) +}; + +std::vector sql_parse(const std::string& input); +SqlStmtKind sql_classify(const std::string& stmt); + +} +``` + +## Reglas del tokenizer + +- Strings: `'...'`, `"..."` y `` `...` `` se saltan enteras. Soporta el escape + SQL estandar de doblar la quote (`'don''t'`, `"a""b"`). +- Comentarios: `-- linea` hasta `\n` y `/* bloque */`. +- El separador `;` solo divide cuando aparece en modo Normal (fuera de + strings/comments). +- Statements vacios (`;;`, `; ;`) se descartan tras trimear. +- Si el ultimo statement no termina en `;`, se incluye igualmente. + +## Clasificacion + +`sql_classify` mira la primera palabra alfabetica despues de saltar whitespace +y comentarios. `WITH` se clasifica como `Select` porque es la forma comun de +iniciar CTEs que devuelven filas. + +## Por que pura + +No abre conexiones, no toca SQLite, no consulta el reloj. Misma entrada → misma +salida. Esto permite testearla sin depender de un fixture de DB. diff --git a/cpp/functions/core/sql_workbench.cpp b/cpp/functions/core/sql_workbench.cpp index ded12b54..7513051c 100644 --- a/cpp/functions/core/sql_workbench.cpp +++ b/cpp/functions/core/sql_workbench.cpp @@ -1,5 +1,6 @@ #include "core/sql_workbench.h" +#include "core/sql_parse.h" #include "core/text_editor.h" #include "core/tokens.h" #include "core/button.h" @@ -25,24 +26,19 @@ namespace fn { namespace { -std::string ltrim(const std::string& s) { - size_t i = 0; - while (i < s.size() && std::isspace(static_cast(s[i]))) ++i; - return s.substr(i); -} - -std::string upper_first_token(const std::string& sql) { - std::string t = ltrim(sql); - size_t end = 0; - while (end < t.size() && !std::isspace(static_cast(t[end]))) ++end; - std::string tok = t.substr(0, end); - for (auto& c : tok) c = static_cast(std::toupper(static_cast(c))); - return tok; +// Clasifica el primer statement con sql_parse pure. Si el input no contiene +// statements (todo whitespace/comentarios), devuelve Unknown. +fn_ui::SqlStmtKind first_stmt_kind(const std::string& sql) { + auto stmts = fn_ui::sql_parse(sql); + if (stmts.empty()) return fn_ui::SqlStmtKind::Unknown; + return stmts.front().kind; } bool is_readonly_stmt(const std::string& sql) { - const std::string head = upper_first_token(sql); - return head == "SELECT" || head == "PRAGMA" || head == "EXPLAIN" || head == "WITH"; + using K = fn_ui::SqlStmtKind; + K k = first_stmt_kind(sql); + // sql_parse clasifica WITH como Select; Pragma y Explain son readonly. + return k == K::Select || k == K::Pragma || k == K::Explain; } TextEditorState* editor_of(SqlWorkbenchState& state) { diff --git a/cpp/functions/core/sql_workbench.md b/cpp/functions/core/sql_workbench.md index 2b4d219e..64a3ccc5 100644 --- a/cpp/functions/core/sql_workbench.md +++ b/cpp/functions/core/sql_workbench.md @@ -8,7 +8,7 @@ purity: impure signature: "void fn::sql_workbench(const char* id, sqlite3* db, fn::SqlWorkbenchState& state, ImVec2 size); bool fn::sql_workbench_run_query(sqlite3*, const char*, fn::SqlWorkbenchState&); void fn::sql_workbench_load_schema(sqlite3*, fn::SqlWorkbenchState&); void fn::sql_workbench_destroy(fn::SqlWorkbenchState&)" description: "Workbench SQL embebido en ImGui: editor con highlighting (text_editor + CodeLang::SQL), tabla de resultados (table_view), sidebar de schema (sqlite_master) e historial. Ejecuta queries contra una sqlite3* del caller (no abre/cierra la DB)." tags: [imgui, sql, sqlite, editor, table, dashboard, registry, debug] -uses_functions: ["button_cpp_core", "table_view_cpp_viz", "text_editor_cpp_core", "tokens_cpp_core"] +uses_functions: ["button_cpp_core", "sql_parse_cpp_core", "table_view_cpp_viz", "text_editor_cpp_core", "tokens_cpp_core"] uses_types: [] returns: [] returns_optional: false diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 64711ac0..2040e966 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -31,6 +31,14 @@ add_fn_test(test_pie_chart_math test_pie_chart_math.cpp) add_fn_test(test_kpi_card_math test_kpi_card_math.cpp) add_fn_test(test_bar_chart_math test_bar_chart_math.cpp) +# Issue 0045 — tests de la logica pura extraida. +add_fn_test(test_sql_parse test_sql_parse.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sql_parse.cpp) +add_fn_test(test_process_state_machine test_process_state_machine.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/process_state_machine.cpp) +add_fn_test(test_file_poll_diff test_file_poll_diff.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/file_poll_diff.cpp) + # --- Placeholders para primitivos UI (logica visual cubierta en 0048) ------ add_fn_test(test_tokens test_tokens.cpp) add_fn_test(test_button test_button.cpp) diff --git a/cpp/tests/test_file_poll_diff.cpp b/cpp/tests/test_file_poll_diff.cpp new file mode 100644 index 00000000..b4734cbc --- /dev/null +++ b/cpp/tests/test_file_poll_diff.cpp @@ -0,0 +1,105 @@ +// Tests for fn_ui::file_poll_diff (cpp/functions/core/file_poll_diff). +// Pura: compara dos snapshots de FS y devuelve added/modified/removed. + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "core/file_poll_diff.h" + +#include +#include +#include + +using fn_ui::file_poll_diff; +using fn_ui::FileEntry; + +namespace { +// Helper: ordena los vectores para comparaciones estables (el orden interno +// depende del orden de iteracion de unordered_map, no es estable). +void sort_diff(fn_ui::FileDiff& d) { + std::sort(d.added.begin(), d.added.end()); + std::sort(d.modified.begin(), d.modified.end()); + std::sort(d.removed.begin(), d.removed.end()); +} +} + +TEST_CASE("file_poll_diff detects added/modified/removed") { + std::vector before = {{"a", 10, 1}, {"b", 20, 2}, {"c", 30, 3}}; + std::vector after = {{"a", 10, 1}, {"b", 25, 2}, {"d", 40, 4}}; + auto d = file_poll_diff(before, after); + sort_diff(d); + REQUIRE(d.added == std::vector{"d"}); + REQUIRE(d.modified == std::vector{"b"}); + REQUIRE(d.removed == std::vector{"c"}); +} + +TEST_CASE("file_poll_diff: empty inputs") { + auto d = file_poll_diff({}, {}); + REQUIRE(d.added.empty()); + REQUIRE(d.modified.empty()); + REQUIRE(d.removed.empty()); +} + +TEST_CASE("file_poll_diff: identical snapshots") { + std::vector snap = {{"a", 1, 100}, {"b", 2, 200}}; + auto d = file_poll_diff(snap, snap); + REQUIRE(d.added.empty()); + REQUIRE(d.modified.empty()); + REQUIRE(d.removed.empty()); +} + +TEST_CASE("file_poll_diff: all added (before vacio)") { + std::vector after = {{"a", 1, 1}, {"b", 2, 2}}; + auto d = file_poll_diff({}, after); + sort_diff(d); + REQUIRE(d.added == std::vector{"a", "b"}); + REQUIRE(d.modified.empty()); + REQUIRE(d.removed.empty()); +} + +TEST_CASE("file_poll_diff: all removed (after vacio)") { + std::vector before = {{"a", 1, 1}, {"b", 2, 2}}; + auto d = file_poll_diff(before, {}); + sort_diff(d); + REQUIRE(d.removed == std::vector{"a", "b"}); + REQUIRE(d.modified.empty()); + REQUIRE(d.added.empty()); +} + +TEST_CASE("file_poll_diff: solo size cambia -> modified") { + std::vector before = {{"a", 100, 5}}; + std::vector after = {{"a", 200, 5}}; + auto d = file_poll_diff(before, after); + REQUIRE(d.modified == std::vector{"a"}); + REQUIRE(d.added.empty()); + REQUIRE(d.removed.empty()); +} + +TEST_CASE("file_poll_diff: solo mtime cambia -> modified") { + std::vector before = {{"a", 100, 5}}; + std::vector after = {{"a", 100, 9}}; + auto d = file_poll_diff(before, after); + REQUIRE(d.modified == std::vector{"a"}); + REQUIRE(d.added.empty()); + REQUIRE(d.removed.empty()); +} + +TEST_CASE("file_poll_diff: combinacion compleja") { + std::vector before = { + {"keep", 10, 1}, + {"mod_size", 20, 2}, + {"mod_mtime", 30, 3}, + {"removed", 40, 4}, + }; + std::vector after = { + {"keep", 10, 1}, + {"mod_size", 21, 2}, + {"mod_mtime", 30, 9}, + {"new", 50, 5}, + }; + auto d = file_poll_diff(before, after); + sort_diff(d); + REQUIRE(d.added == std::vector{"new"}); + REQUIRE(d.modified == std::vector{"mod_mtime", "mod_size"}); + REQUIRE(d.removed == std::vector{"removed"}); +} diff --git a/cpp/tests/test_process_state_machine.cpp b/cpp/tests/test_process_state_machine.cpp new file mode 100644 index 00000000..1f51a303 --- /dev/null +++ b/cpp/tests/test_process_state_machine.cpp @@ -0,0 +1,83 @@ +// Tests for fn_ui::process_transition (cpp/functions/core/process_state_machine). +// Tabla de transiciones definida en .md. + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "core/process_state_machine.h" +#include + +using fn_ui::process_transition; +using fn_ui::process_state_name; +using fn_ui::process_event_name; +using fn_ui::ProcessState; +using fn_ui::ProcessEvent; + +TEST_CASE("process_transition: idle -> running on Spawned") { + REQUIRE(process_transition(ProcessState::Idle, ProcessEvent::Spawned) == ProcessState::Running); +} + +TEST_CASE("process_transition: idle + Trigger sigue Idle (la solicitud no muta el estado)") { + REQUIRE(process_transition(ProcessState::Idle, ProcessEvent::Trigger) == ProcessState::Idle); +} + +TEST_CASE("process_transition: running -> success on Finished") { + REQUIRE(process_transition(ProcessState::Running, ProcessEvent::Finished) == ProcessState::Success); +} + +TEST_CASE("process_transition: running -> error on Failed/Timeout") { + REQUIRE(process_transition(ProcessState::Running, ProcessEvent::Failed) == ProcessState::Error); + REQUIRE(process_transition(ProcessState::Running, ProcessEvent::Timeout) == ProcessState::Error); +} + +TEST_CASE("process_transition: success/error -> idle on Reset") { + REQUIRE(process_transition(ProcessState::Success, ProcessEvent::Reset) == ProcessState::Idle); + REQUIRE(process_transition(ProcessState::Error, ProcessEvent::Reset) == ProcessState::Idle); +} + +TEST_CASE("process_transition: invalid events keep state") { + // Idle no acepta Finished/Failed/Timeout. + REQUIRE(process_transition(ProcessState::Idle, ProcessEvent::Finished) == ProcessState::Idle); + REQUIRE(process_transition(ProcessState::Idle, ProcessEvent::Failed) == ProcessState::Idle); + REQUIRE(process_transition(ProcessState::Idle, ProcessEvent::Timeout) == ProcessState::Idle); + REQUIRE(process_transition(ProcessState::Idle, ProcessEvent::Reset) == ProcessState::Idle); + + // Running ignora Trigger/Spawned/Reset. + REQUIRE(process_transition(ProcessState::Running, ProcessEvent::Trigger) == ProcessState::Running); + REQUIRE(process_transition(ProcessState::Running, ProcessEvent::Spawned) == ProcessState::Running); + REQUIRE(process_transition(ProcessState::Running, ProcessEvent::Reset) == ProcessState::Running); + + // Success ignora todo menos Reset. + REQUIRE(process_transition(ProcessState::Success, ProcessEvent::Trigger) == ProcessState::Success); + REQUIRE(process_transition(ProcessState::Success, ProcessEvent::Spawned) == ProcessState::Success); + REQUIRE(process_transition(ProcessState::Success, ProcessEvent::Finished) == ProcessState::Success); + REQUIRE(process_transition(ProcessState::Success, ProcessEvent::Failed) == ProcessState::Success); + + // Error idem. + REQUIRE(process_transition(ProcessState::Error, ProcessEvent::Finished) == ProcessState::Error); + REQUIRE(process_transition(ProcessState::Error, ProcessEvent::Spawned) == ProcessState::Error); +} + +TEST_CASE("process_state_name and process_event_name") { + REQUIRE(std::strcmp(process_state_name(ProcessState::Idle), "Idle") == 0); + REQUIRE(std::strcmp(process_state_name(ProcessState::Running), "Running") == 0); + REQUIRE(std::strcmp(process_state_name(ProcessState::Success), "Success") == 0); + REQUIRE(std::strcmp(process_state_name(ProcessState::Error), "Error") == 0); + + REQUIRE(std::strcmp(process_event_name(ProcessEvent::Trigger), "Trigger") == 0); + REQUIRE(std::strcmp(process_event_name(ProcessEvent::Spawned), "Spawned") == 0); + REQUIRE(std::strcmp(process_event_name(ProcessEvent::Finished), "Finished") == 0); + REQUIRE(std::strcmp(process_event_name(ProcessEvent::Failed), "Failed") == 0); + REQUIRE(std::strcmp(process_event_name(ProcessEvent::Timeout), "Timeout") == 0); + REQUIRE(std::strcmp(process_event_name(ProcessEvent::Reset), "Reset") == 0); +} + +TEST_CASE("process_transition: ciclo completo idle -> running -> success -> idle") { + ProcessState s = ProcessState::Idle; + s = process_transition(s, ProcessEvent::Spawned); + REQUIRE(s == ProcessState::Running); + s = process_transition(s, ProcessEvent::Finished); + REQUIRE(s == ProcessState::Success); + s = process_transition(s, ProcessEvent::Reset); + REQUIRE(s == ProcessState::Idle); +} diff --git a/cpp/tests/test_sql_parse.cpp b/cpp/tests/test_sql_parse.cpp new file mode 100644 index 00000000..40aa5bcc --- /dev/null +++ b/cpp/tests/test_sql_parse.cpp @@ -0,0 +1,93 @@ +// Tests for fn_ui::sql_parse / sql_classify (cpp/functions/core/sql_parse). +// Pura: tokeniza SQL multi-statement saltando strings y comentarios, y +// clasifica por keyword inicial. + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "core/sql_parse.h" + +using fn_ui::sql_parse; +using fn_ui::sql_classify; +using fn_ui::SqlStmtKind; + +TEST_CASE("sql_parse classifies common statements") { + auto stmts = sql_parse("SELECT * FROM t; INSERT INTO t VALUES (1);"); + REQUIRE(stmts.size() == 2); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); + REQUIRE(stmts[1].kind == SqlStmtKind::Insert); +} + +TEST_CASE("sql_parse handles strings and comments") { + auto stmts = sql_parse("-- comment\nSELECT 'a;b' FROM t; /* x */ DELETE FROM t;"); + REQUIRE(stmts.size() == 2); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); + REQUIRE(stmts[1].kind == SqlStmtKind::Delete); +} + +TEST_CASE("sql_parse trims and ignores empty") { + auto stmts = sql_parse("; ; SELECT 1;;"); + REQUIRE(stmts.size() == 1); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); +} + +TEST_CASE("sql_parse: ddl and dcl keywords") { + auto stmts = sql_parse("CREATE TABLE t(a); DROP TABLE t; ALTER TABLE t ADD b; UPDATE t SET a=1;"); + REQUIRE(stmts.size() == 4); + REQUIRE(stmts[0].kind == SqlStmtKind::Create); + REQUIRE(stmts[1].kind == SqlStmtKind::Drop); + REQUIRE(stmts[2].kind == SqlStmtKind::Alter); + REQUIRE(stmts[3].kind == SqlStmtKind::Update); +} + +TEST_CASE("sql_parse: pragma and explain") { + auto stmts = sql_parse("PRAGMA foreign_keys = ON; EXPLAIN SELECT 1;"); + REQUIRE(stmts.size() == 2); + REQUIRE(stmts[0].kind == SqlStmtKind::Pragma); + REQUIRE(stmts[1].kind == SqlStmtKind::Explain); +} + +TEST_CASE("sql_parse: WITH clasifica como Select") { + auto stmts = sql_parse("WITH x AS (SELECT 1) SELECT * FROM x;"); + REQUIRE(stmts.size() == 1); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); +} + +TEST_CASE("sql_parse: case-insensitive y trim") { + auto stmts = sql_parse(" select 1;\n INSERT INTO t VALUES (2);"); + REQUIRE(stmts.size() == 2); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); + REQUIRE(stmts[1].kind == SqlStmtKind::Insert); +} + +TEST_CASE("sql_parse: line tracking") { + auto stmts = sql_parse("\n\nSELECT 1;\n-- skip\nDELETE FROM t;\n"); + REQUIRE(stmts.size() == 2); + REQUIRE(stmts[0].line == 3); + REQUIRE(stmts[1].line == 5); +} + +TEST_CASE("sql_parse: ultimo statement sin ;") { + auto stmts = sql_parse("SELECT 1"); + REQUIRE(stmts.size() == 1); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); +} + +TEST_CASE("sql_classify: standalone") { + REQUIRE(sql_classify("SELECT 1") == SqlStmtKind::Select); + REQUIRE(sql_classify("garbage") == SqlStmtKind::Unknown); + REQUIRE(sql_classify("") == SqlStmtKind::Unknown); + REQUIRE(sql_classify("/* c */ DELETE FROM t") == SqlStmtKind::Delete); +} + +TEST_CASE("sql_parse: ; dentro de string no separa") { + auto stmts = sql_parse("SELECT 'a;b;c' FROM t;"); + REQUIRE(stmts.size() == 1); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); +} + +TEST_CASE("sql_parse: backtick identifier") { + auto stmts = sql_parse("SELECT * FROM `weird;name`;"); + REQUIRE(stmts.size() == 1); + REQUIRE(stmts[0].kind == SqlStmtKind::Select); +} diff --git a/dev/issues/0045-cpp-extract-pure-logic.md b/dev/issues/completed/0045-cpp-extract-pure-logic.md similarity index 100% rename from dev/issues/0045-cpp-extract-pure-logic.md rename to dev/issues/completed/0045-cpp-extract-pure-logic.md