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>
120 lines
4.1 KiB
Python
120 lines
4.1 KiB
Python
"""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 == ""
|