Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ When the user requests a durable behavior change, record it here or in the relev
- [tests/AGENTS.md](tests/AGENTS.md) — Catch2 unit + benchmark tests
- [help/AGENTS.md](help/AGENTS.md) — Help window widget with data-driven quick reference content
- [layout/AGENTS.md](layout/AGENTS.md) — Exe-relative ImGui layout persistence (default + named presets)
- [tutorial/AGENTS.md](tutorial/AGENTS.md) — Guided first-run walkthrough with panel highlighting and exe-relative completion marker

<!-- OPENWIKI:START -->

Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ add_subdirectory("attenuator")
add_subdirectory("combiner")
add_subdirectory("help")
add_subdirectory("layout")
add_subdirectory("tutorial")
# =============================================================================
# Install targets
# =============================================================================
Expand Down
4 changes: 3 additions & 1 deletion app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ Application orchestrator layer containing `RfSimulatorApp`, `ComponentRegistry`,
## Local Contracts
- `RfSimulatorApp::saveProject()` / `loadProject()` / `newProject()` are thin wrappers that delegate to `ProjectSerializer::save()` / `load()` / `reset()`; all `.rfsim` JSON serialization lives in `ProjectSerializer`
- Dirty tracking propagated via `markDirty()` / `onParamChange` / `onLinkChanged` callbacks
- File menu bar in `draw_ui()` handles keyboard shortcuts (`Ctrl+N`, `Ctrl+O`, `Ctrl+S`, `Ctrl+Shift+S`) and unsaved-changes modal; Help menu provides F1-toggled help window
- File menu bar in `draw_ui()` handles keyboard shortcuts (`Ctrl+N`, `Ctrl+O`, `Ctrl+S`, `Ctrl+Shift+S`) and unsaved-changes modal; Help menu provides the F1-toggled help window and `Help > Tutorial`
- `Help > Tutorial` and the first-run "Welcome" modal both route through `requestTutorial()`, which runs the same `PendingAction`/unsaved-changes guard as New/Open/Exit (`PendingAction::Tutorial`) before `startTutorial()` resets to a seeded sandbox. Any new `PendingAction` value must be handled in both the Save and Discard branches of that modal.
- The first-run offer is driven by `m_show_tutorial_first_run_prompt`, set on construction from `TutorialState::completed()` (see `tutorial/AGENTS.md`); either answer marks it completed so the prompt never repeats
- View menu's `Layouts` submenu (Save As.../Load/Manage...) drives `LayoutManager` (see `layout/AGENTS.md`) for named window-layout presets; the exe-relative default layout is auto-managed by ImGui itself via `IniFilename`, set in `core/src/core.cpp`
- Library browser's "New Component..."/"Edit" flow writes schema-v2 JSON via `ComponentFormModel::buildDefinition()`; new entries save to a user-chosen root (`rf-sim-libraries/` project-local or `~/.rf-sim/libraries/` global); built-in `component_data/library/` entries are read-only (no Edit button) and never a save target; edits always overwrite the original `source_path` (no rename-on-identity-change)
- Extension discovery stays inside `app/` and scans built-in, global, and project-local roots; invalid manifests remain visible via `ExtensionManager::all()`
Expand Down
1 change: 1 addition & 0 deletions app/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ target_link_libraries(app
simulator::node_graph_widget
simulator::help_widget
simulator::layout
simulator::tutorial
common
)

Expand Down
16 changes: 15 additions & 1 deletion app/include/app.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@
#include "spectrum_analyzer_engine.h"
#include "spectrum_analyzer_widget.h"
#include "splitter_engine.h"
#include "tutorial_state.h"
#include "tutorial_widget.h"
#include "view_manager.h"
#include <memory>
#include <optional>
#include <string_view>
#include <vector>
enum class PendingAction { None, New, Open, Exit };
enum class PendingAction { None, New, Open, Exit, Tutorial };

class RfSimulatorApp {
public:
Expand All @@ -56,6 +58,11 @@ class RfSimulatorApp {
bool m_show_node_editor = true;
bool m_show_help = false;
HelpWidget m_help_widget;
bool m_show_tutorial = false;
// Set on construction when no completion marker exists. Public so the UI
// test harness can suppress the blocking modal (see test_engine/ui_tests.cpp).
bool m_show_tutorial_first_run_prompt = false;
TutorialWidget m_tutorial_widget;
LayoutManager m_layout_manager;
bool m_show_save_layout_dialog = false;
bool m_show_manage_layouts_dialog = false;
Expand All @@ -68,6 +75,7 @@ class RfSimulatorApp {
std::string m_extension_result_message;

SessionState m_state;
TutorialState m_tutorial_state;
~RfSimulatorApp();

// Project save/load
Expand All @@ -85,6 +93,7 @@ class RfSimulatorApp {
ComponentRegistry &testComponents() { return m_components; }
NodeGraphWidget &testGraphWidget() { return *m_graph_widget; }
LayoutManager &testLayoutManager() { return m_layout_manager; }
TutorialState &testTutorialState() { return m_tutorial_state; }
ExtensionManager &testExtensionManager() { return m_extension_manager; }
const std::string &testExtensionResultMessage() const { return m_extension_result_message; }

Expand All @@ -93,6 +102,11 @@ class RfSimulatorApp {

private:
void load_window_states();
// Runs the unsaved-changes guard, then starts the tutorial (directly if the
// project is clean, otherwise via PendingAction::Tutorial).
void requestTutorial();
// Resets to a fresh seeded sandbox and activates the walkthrough.
void startTutorial();
void rewireInputs();
void duplicateComponent(int graph_node_id);
void addComponent(const ComponentTypeDescriptor *desc, ImVec2 pos);
Expand Down
79 changes: 79 additions & 0 deletions app/src/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ RfSimulatorApp::RfSimulatorApp() : m_components(m_graph_engine, m_view_manager)
m_graph_widget->syncNodesFromEngine();

load_window_states();

// Offer the guided walkthrough once, on the first launch of a given build.
// Tutorial visibility itself is transient session state, so it is not
// persisted through SessionState — only the completion marker is durable.
m_show_tutorial_first_run_prompt = !m_tutorial_state.completed();
}

void RfSimulatorApp::addComponent(const ComponentTypeDescriptor *desc, ImVec2 pos) {
Expand Down Expand Up @@ -173,6 +178,36 @@ void RfSimulatorApp::newProject() {
m_dirty = false;
}

void RfSimulatorApp::requestTutorial() {
// Same guard New/Open/Exit use — startTutorial() discards the current
// project, so the user must get the chance to save first.
if (m_dirty) {
m_pending_action = PendingAction::Tutorial;
m_show_unsaved_dialog = true;
} else
startTutorial();
}

void RfSimulatorApp::startTutorial() {
// Reset to the same Generator + Amplifier pair the app seeds on first launch,
// so every step's instruction matches what the user is actually looking at.
newProject();
m_components.add<SignalGeneratorEngine>(m_next_component_id++, m_graph_engine)
.addTone(100e6, -20.0);
m_components.add<AmplifierEngine>(m_next_component_id++, m_graph_engine);
m_graph_widget->syncNodesFromEngine();

// Every panel a step highlights must be on screen for the highlight to
// resolve — the Component Library in particular is hidden by default.
m_show_node_editor = true;
m_show_properties = true;
m_show_spectrum = true;
m_show_library = true;

m_tutorial_state.start();
m_show_tutorial = true;
}

void RfSimulatorApp::testMakeDirty() { markDirty(); }

void RfSimulatorApp::markDirty() { m_dirty = true; }
Expand Down Expand Up @@ -645,6 +680,8 @@ void RfSimulatorApp::draw_ui() {
if (ImGui::BeginMenu("Help")) {
if (ImGui::MenuItem("How to Use", "F1"))
m_show_help = !m_show_help;
if (ImGui::MenuItem("Tutorial"))
requestTutorial();
ImGui::EndMenu();
}
// Title / project name on the right
Expand Down Expand Up @@ -693,6 +730,35 @@ void RfSimulatorApp::draw_ui() {
m_show_help = !m_show_help;
}

// First-run tutorial offer. Must stay ahead of the Unsaved Changes block so
// that "Start Tutorial" can raise that dialog within the same frame.
if (m_show_tutorial_first_run_prompt) {
ImGui::OpenPopup("Welcome to Tiny RF Simulator");
}
if (ImGui::BeginPopupModal("Welcome to Tiny RF Simulator", nullptr,
ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("New here? A short guided tutorial walks you through building");
ImGui::Text("and probing your first signal chain.");
ImGui::Spacing();
ImGui::TextDisabled("Re-run it anytime from Help > Tutorial.");
ImGui::Separator();
// Either answer marks the tutorial completed: this prompt is a one-time
// offer, not a reminder that returns until the walkthrough is finished.
if (ImGui::Button("Start Tutorial", ImVec2(140, 0))) {
m_tutorial_state.markCompleted();
m_show_tutorial_first_run_prompt = false;
ImGui::CloseCurrentPopup();
requestTutorial();
}
ImGui::SameLine();
if (ImGui::Button("Not Now", ImVec2(140, 0))) {
m_tutorial_state.markCompleted();
m_show_tutorial_first_run_prompt = false;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}

// Unsaved Changes popup — use a bool flag instead of OpenPopup/BeginPopupModal,
// which can be unreliable when called from inside a menu bar context.
if (m_show_unsaved_dialog) {
Expand Down Expand Up @@ -726,6 +792,9 @@ void RfSimulatorApp::draw_ui() {
case PendingAction::Exit:
std::exit(0);
break;
case PendingAction::Tutorial:
startTutorial();
break;
default:
break;
}
Expand All @@ -748,6 +817,9 @@ void RfSimulatorApp::draw_ui() {
case PendingAction::Exit:
std::exit(0);
break;
case PendingAction::Tutorial:
startTutorial();
break;
default:
break;
}
Expand Down Expand Up @@ -865,6 +937,13 @@ void RfSimulatorApp::draw_ui() {

if (m_show_help)
m_help_widget.draw("How to Use", &m_show_help);

if (m_show_tutorial) {
m_tutorial_widget.draw(m_tutorial_state);
// Finish/Exit deactivate TutorialState from inside the widget; mirror
// that back onto the app's visibility flag.
m_show_tutorial = m_tutorial_state.isActive();
}
}

RfSimulatorApp::~RfSimulatorApp() {
Expand Down
3 changes: 2 additions & 1 deletion help/src/help_widget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ const HelpSection sections[] = {
"Select multiple nodes: Ctrl+click or rubber-band selection",
"Delete selected nodes: press Delete key",
"Right-click canvas: open context menu to add components",
"New here? Help > Tutorial runs a guided walkthrough of this workflow",
},
6},
7},
{"Adding and Removing Components",
{
"Right-click the canvas and choose a component from the context menu",
Expand Down
129 changes: 129 additions & 0 deletions test_engine/ui_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ static RfSimulatorApp *s_app = nullptr;

void RegisterUiTests(ImGuiTestEngine *e, RfSimulatorApp &app) {
s_app = &app;

// The first-run tutorial offer is a blocking modal, which would stop every
// other test from reaching the menu bar. Suppress it here (before the first
// frame) — tutorial_first_run_prompt_marks_completed re-arms it explicitly.
app.m_show_tutorial_first_run_prompt = false;

ImGuiTest *t = nullptr;

t = IM_REGISTER_TEST(e, "rf_simulator", "node_editor_exists");
Expand Down Expand Up @@ -465,4 +471,127 @@ void RegisterUiTests(ImGuiTestEngine *e, RfSimulatorApp &app) {

IM_CHECK(!std::filesystem::exists(path));
};

// Tutorial tests are registered last on purpose: starting the tutorial calls
// newProject(), which clears the components and node IDs the tests above
// rely on.

t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_launches_from_help_menu");
t->TestFunc = [](ImGuiTestContext *ctx) {
s_app->newProject(); // clean slate, so the unsaved-changes guard stays out of the way
ctx->SetRef("##MainMenuBar");
ctx->MenuClick("Help/Tutorial");
ctx->Yield(2);

IM_CHECK(s_app->testTutorialState().isActive());
IM_CHECK_EQ(s_app->testTutorialState().stepIndex(), 0);
IM_CHECK(ImGui::FindWindowByName("Tutorial Guide") != nullptr);

ctx->SetRef("Tutorial Guide");
ctx->ItemClick("Exit");
ctx->SetRef("");
ctx->Yield(2);
IM_CHECK(!s_app->testTutorialState().isActive());
};

t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_start_guards_unsaved_changes");
t->TestFunc = [](ImGuiTestContext *ctx) {
s_app->newProject();
s_app->testMakeDirty();

ctx->SetRef("##MainMenuBar");
ctx->MenuClick("Help/Tutorial");
ctx->Yield(2);
// Dirty project — the tutorial must wait behind the unsaved-changes modal.
IM_CHECK(!s_app->testTutorialState().isActive());

ctx->SetRef("Unsaved Changes");
ctx->ItemClick("Discard");
ctx->SetRef("");
ctx->Yield(2);
IM_CHECK(s_app->testTutorialState().isActive());

ctx->SetRef("Tutorial Guide");
ctx->ItemClick("Exit");
ctx->SetRef("");
ctx->Yield(2);
};

t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_step_navigation");
t->TestFunc = [](ImGuiTestContext *ctx) {
s_app->newProject();
ctx->SetRef("##MainMenuBar");
ctx->MenuClick("Help/Tutorial");
ctx->Yield(2);

ctx->SetRef("Tutorial Guide");
IM_CHECK(s_app->testTutorialState().atFirstStep());

ctx->ItemClick("Next");
ctx->Yield();
IM_CHECK_EQ(s_app->testTutorialState().stepIndex(), 1);

ctx->ItemClick("Back");
ctx->Yield();
IM_CHECK_EQ(s_app->testTutorialState().stepIndex(), 0);

ctx->ItemClick("Skip");
ctx->Yield();
IM_CHECK(s_app->testTutorialState().atLastStep());
IM_CHECK(s_app->testTutorialState().isActive());

ctx->ItemClick("Exit");
ctx->SetRef("");
ctx->Yield(2);
IM_CHECK(!s_app->testTutorialState().isActive());
};

t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_completes_and_persists");
t->TestFunc = [](ImGuiTestContext *ctx) {
std::filesystem::path marker(s_app->testTutorialState().markerPath());
std::filesystem::remove(marker);

s_app->newProject();
ctx->SetRef("##MainMenuBar");
ctx->MenuClick("Help/Tutorial");
ctx->Yield(2);

ctx->SetRef("Tutorial Guide");
// Bounded walk to the last step — never loop on the state alone.
for (int i = 0; i < s_app->testTutorialState().stepCount(); ++i) {
if (s_app->testTutorialState().atLastStep())
break;
ctx->ItemClick("Next");
ctx->Yield();
}
IM_CHECK(s_app->testTutorialState().atLastStep());
IM_CHECK(!std::filesystem::exists(marker)); // not marked until Finish

ctx->ItemClick("Finish");
ctx->SetRef("");
ctx->Yield(2);

IM_CHECK(!s_app->testTutorialState().isActive());
IM_CHECK(std::filesystem::exists(marker));
};

t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_first_run_prompt_marks_completed");
t->TestFunc = [](ImGuiTestContext *ctx) {
std::filesystem::path marker(s_app->testTutorialState().markerPath());
std::filesystem::remove(marker);

// Re-arm the prompt suppressed in RegisterUiTests.
s_app->m_show_tutorial_first_run_prompt = true;
ctx->Yield(2);

ctx->SetRef("Welcome to Tiny RF Simulator");
ctx->ItemClick("Not Now");
ctx->SetRef("");
ctx->Yield(2);

IM_CHECK(!s_app->m_show_tutorial_first_run_prompt);
IM_CHECK(!s_app->testTutorialState().isActive());
// Dismissing the offer counts as completed — it must never nag again.
IM_CHECK(std::filesystem::exists(marker));
};
}
1 change: 1 addition & 0 deletions tests/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Own the Catch2 v3.4.0 unit test suite and ImGui test engine UI tests. Verify all
- Adding a new module? Add its test source to `TEST_SOURCES` in `CMakeLists.txt` and link the library target
- **MinGW-w64 test-registration ceiling:** this toolchain silently drops any `TEST_CASE` registered beyond the ~217 already linked into the main `tests` executable (confirmed via a from-scratch clean rebuild; see the comment above `test_component_authoring` in `CMakeLists.txt`). Do not add new `TEST_CASE`s to `test_main.cpp` or any file already compiled into the `tests` target — give the new coverage its own standalone executable instead (`add_executable(test_<name> test_<name>.cpp)` + `target_link_libraries` + `add_test`, following `test_attenuator`/`test_combiner`/`test_component_authoring`/`test_extensions`/`test_signal_domain`), and run it directly (`build/bin/test_<name>.exe`) rather than relying on `ctest`.
- Platform-specific tests (e.g., Windows-only session state) are gated with `#ifdef WIN32` in CMakeLists.txt
- `test_component_authoring` and `test_tutorial_state` are standalone executables, not part of `TEST_SOURCES`: the MinGW-w64 toolchain silently drops `TEST_CASE`s registered beyond the ~217 already linked into `tests`. New test files that must run on Windows should follow that pattern.

## Work Guidance

Expand Down
12 changes: 12 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ target_link_libraries(test_component_authoring PRIVATE
target_compile_definitions(test_component_authoring PRIVATE PROJECT_SOURCE_DIR="${CMAKE_SOURCE_DIR}")
add_test(NAME test_component_authoring COMMAND test_component_authoring WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})

# Standalone for the same reason as test_component_authoring: TEST_CASEs appended
# to the main `tests` binary are silently dropped by the MinGW-w64 toolchain, and
# these must actually run on every CI platform (TutorialState is cross-platform,
# unlike SessionState).
add_executable(test_tutorial_state test_tutorial_state.cpp)
target_link_libraries(test_tutorial_state PRIVATE
simulator::tutorial
Catch2::Catch2WithMain
)
target_compile_definitions(test_tutorial_state PRIVATE PROJECT_SOURCE_DIR="${CMAKE_SOURCE_DIR}")
add_test(NAME test_tutorial_state COMMAND test_tutorial_state WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})

add_executable(test_extensions test_extensions.cpp)
target_link_libraries(test_extensions PRIVATE
simulator::app
Expand Down
Loading