feat: add dual-mode tree editor with Code Mode, variables, and markdown sync
Implements the full dual-mode tree editor (Plan Phases 1-5): Backend: - JSONB↔Markdown bidirectional serializer/parser with mistune - Markdown validator with line/column error reporting - 3 API endpoints: export-markdown, import-markdown, validate-markdown - Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS]) - Session variables JSONB column (migration 028) - 39 tree markdown tests + variable service tests (403 total passing) Frontend: - Monaco-based Code Mode with custom Monarch tokenizer and dark theme - Autocomplete for @node_id refs, type values, variable names - Debounced validation (800ms) with inline Monaco error markers - Syntax help panel (absolute overlay, toggleable) - Starter template for new trees with valid cross-references - Bidirectional metadata sync (name/description/category/tags frontmatter) - Synchronous tree→markdown serializer (fixes async race condition) - Pre-save validation blocks save on broken refs or missing tree name - Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode - Variable prompt modal and frontend resolver for session navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
119
backend/tests/test_variable_service.py
Normal file
119
backend/tests/test_variable_service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for variable extraction and resolution service."""
|
||||
import pytest
|
||||
|
||||
from app.services.variable_service import extract_variables, resolve_variables
|
||||
|
||||
|
||||
TREE_WITH_VARIABLES = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "What is the hostname of [USER_INPUT:hostname]?",
|
||||
"help_text": "Enter the server hostname",
|
||||
"options": [
|
||||
{"id": "opt1", "label": "Check [VAR:hostname]", "next_node_id": "action1"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "action1",
|
||||
"type": "action",
|
||||
"title": "Ping [VAR:hostname]",
|
||||
"description": "Run diagnostics on [VAR:hostname].\n\n[SAVE_AS:test_results]",
|
||||
"commands": ["ping [VAR:hostname]"],
|
||||
"expected_outcome": "Replies from [VAR:hostname]",
|
||||
"next_node_id": "solution1",
|
||||
"children": [
|
||||
{
|
||||
"id": "solution1",
|
||||
"type": "solution",
|
||||
"title": "Resolved",
|
||||
"description": "Issue on [VAR:hostname] resolved.",
|
||||
"resolution_steps": [
|
||||
"Document results for [VAR:hostname]",
|
||||
"Save as [SAVE_AS:final_result]"
|
||||
],
|
||||
"solution": "Resolved",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
TREE_WITHOUT_VARIABLES = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Simple Fix",
|
||||
"description": "Just restart it.",
|
||||
"solution": "Restart",
|
||||
}
|
||||
|
||||
|
||||
class TestExtractVariables:
|
||||
def test_extracts_user_input(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
user_inputs = [v for v in variables if v.kind == "user_input"]
|
||||
assert len(user_inputs) == 1
|
||||
assert user_inputs[0].name == "hostname"
|
||||
assert user_inputs[0].node_id == "root"
|
||||
|
||||
def test_extracts_var_references(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
refs = [v for v in variables if v.kind == "reference"]
|
||||
assert len(refs) >= 4 # hostname used in multiple places
|
||||
|
||||
def test_extracts_save_as(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
saves = [v for v in variables if v.kind == "save_as"]
|
||||
assert len(saves) == 2
|
||||
names = {v.name for v in saves}
|
||||
assert "test_results" in names
|
||||
assert "final_result" in names
|
||||
|
||||
def test_no_variables(self):
|
||||
variables = extract_variables(TREE_WITHOUT_VARIABLES)
|
||||
assert len(variables) == 0
|
||||
|
||||
def test_extracts_from_commands(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
cmd_vars = [v for v in variables if v.node_id == "action1" and v.kind == "reference"]
|
||||
# hostname in title, description, commands, expected_outcome
|
||||
assert len(cmd_vars) >= 3
|
||||
|
||||
|
||||
class TestResolveVariables:
|
||||
def test_resolves_var_reference(self):
|
||||
text = "Ping [VAR:hostname] now"
|
||||
result = resolve_variables(text, {"hostname": "server01"})
|
||||
assert result == "Ping server01 now"
|
||||
|
||||
def test_resolves_user_input(self):
|
||||
text = "Server: [USER_INPUT:hostname]"
|
||||
result = resolve_variables(text, {"hostname": "server01"})
|
||||
assert result == "Server: server01"
|
||||
|
||||
def test_removes_save_as(self):
|
||||
text = "Done [SAVE_AS:result] here"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "Done here"
|
||||
|
||||
def test_unresolved_var_preserved(self):
|
||||
text = "Server: [VAR:unknown_var]"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "Server: [VAR:unknown_var]"
|
||||
|
||||
def test_multiple_variables(self):
|
||||
text = "[VAR:hostname] ([VAR:ip]) - [USER_INPUT:ticket]"
|
||||
result = resolve_variables(text, {
|
||||
"hostname": "server01",
|
||||
"ip": "10.0.0.1",
|
||||
"ticket": "INC001",
|
||||
})
|
||||
assert result == "server01 (10.0.0.1) - INC001"
|
||||
|
||||
def test_empty_variables_dict(self):
|
||||
text = "No vars here"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "No vars here"
|
||||
|
||||
def test_empty_text(self):
|
||||
result = resolve_variables("", {"hostname": "server01"})
|
||||
assert result == ""
|
||||
Reference in New Issue
Block a user