diff --git a/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs b/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs index c265099..a9678f2 100644 --- a/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs +++ b/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs @@ -570,5 +570,399 @@ public static class ForgeSchemaHelper } } "; + + #region CacheVars Schemas + + /// + /// Basic CacheVars test — static Roslyn expression used in ShouldSelect. + /// + public const string CacheVars_StaticExpression = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""myVal"": ""C#|42"" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|(int)Cache.myVal == 42"", + ""Child"": ""CorrectValue"" + }, + { + ""Child"": ""WrongValue"" + } + ] + }, + ""CorrectValue"": { + ""Type"": ""Leaf"" + }, + ""WrongValue"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars referencing Session.GetOutput to extract ActionResponse data. + /// + public const string CacheVars_SessionGetOutput = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""TheCommand"" + } + } + }, + ""CacheVars"": { + ""actionStatus"": ""C#|Session.GetOutput(\""Root_CollectDiagnosticsAction\"").Status"" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.actionStatus == \""Success\"""", + ""Child"": ""Found"" + }, + { + ""Child"": ""NotFound"" + } + ] + }, + ""Found"": { + ""Type"": ""Leaf"" + }, + ""NotFound"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars using UserContext. + /// + public const string CacheVars_UserContext = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""userName"": ""C#|UserContext.Name"" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.userName == \""MyName\"""", + ""Child"": ""Found"" + }, + { + ""Child"": ""NotFound"" + } + ] + }, + ""Found"": { + ""Type"": ""Leaf"" + }, + ""NotFound"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars are node-scoped — second node should NOT see first node's cache variables. + /// + public const string CacheVars_NodeScoped = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""firstNodeVar"": ""C#|99"" + }, + ""ChildSelector"": [ + { + ""Child"": ""SecondNode"" + } + ] + }, + ""SecondNode"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""secondNodeCheck"": ""C#|Cache == null ? \""isolated\"" : \""leaked\"""" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.secondNodeCheck == \""isolated\"""", + ""Child"": ""Isolated"" + }, + { + ""Child"": ""Leaked"" + } + ] + }, + ""Isolated"": { + ""Type"": ""Leaf"" + }, + ""Leaked"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars with invalid expression — should throw EvaluateDynamicPropertyException. + /// + public const string CacheVars_InvalidExpression = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""badVar"": ""C#|NonExistentObject.Property"" + }, + ""ChildSelector"": [ + { + ""Child"": ""End"" + } + ] + }, + ""End"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// Multiple CacheVars on the same node. + /// + public const string CacheVars_Multiple = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""greeting"": ""C#|\""hello\"""", + ""target"": ""C#|\""world\"""", + ""suffix"": ""C#|\""!\"""" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.greeting.ToString() == \""hello\"" && Cache.target.ToString() == \""world\"" && Cache.suffix.ToString() == \""!\"""", + ""Child"": ""Found"" + }, + { + ""Child"": ""NotFound"" + } + ] + }, + ""Found"": { + ""Type"": ""Leaf"" + }, + ""NotFound"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars with string literal (non-Roslyn value). + /// + public const string CacheVars_StringLiteral = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""literal"": ""hello world"" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.literal.ToString() == \""hello world\"""", + ""Child"": ""Found"" + }, + { + ""Child"": ""NotFound"" + } + ] + }, + ""Found"": { + ""Type"": ""Leaf"" + }, + ""NotFound"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars with ActionResponse object value — access nested property via Cache. + /// + public const string CacheVars_ObjectValue = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""TheCommand"" + } + } + }, + ""CacheVars"": { + ""response"": ""C#|Session.GetOutput(\""Root_CollectDiagnosticsAction\"")"" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.response != null"", + ""Child"": ""Found"" + }, + { + ""Child"": ""NotFound"" + } + ] + }, + ""Found"": { + ""Type"": ""Leaf"" + }, + ""NotFound"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars with boolean expression — use Cache.IsReady in ShouldSelect. + /// + public const string CacheVars_BooleanExpression = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""IsReady"": ""C#|true"" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|(bool)Cache.IsReady"", + ""Child"": ""Ready"" + }, + { + ""Child"": ""NotReady"" + } + ] + }, + ""Ready"": { + ""Type"": ""Leaf"" + }, + ""NotReady"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars on multiple node types — Selection and Action. + /// + public const string CacheVars_AllNodeTypes = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""nodeType"": ""C#|\""selection\"""" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.nodeType.ToString() == \""selection\"""", + ""Child"": ""ActionNode"" + }, + { + ""Child"": ""End"" + } + ] + }, + ""ActionNode"": { + ""Type"": ""Action"", + ""Actions"": { + ""ActionNode_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""TheCommand"" + } + } + }, + ""CacheVars"": { + ""nodeType"": ""C#|\""action\"""" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.nodeType.ToString() == \""action\"""", + ""Child"": ""End"" + }, + { + ""Child"": ""Fail"" + } + ] + }, + ""End"": { + ""Type"": ""Leaf"" + }, + ""Fail"": { + ""Type"": ""Leaf"" + } + } + }"; + + /// + /// CacheVars same-named property across two nodes — confirms isolation (second node re-defines same var). + /// + public const string CacheVars_SameNameAcrossNodes = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""status"": ""C#|\""first\"""" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.status.ToString() == \""first\"""", + ""Child"": ""SecondNode"" + }, + { + ""Child"": ""Fail"" + } + ] + }, + ""SecondNode"": { + ""Type"": ""Selection"", + ""CacheVars"": { + ""status"": ""C#|\""second\"""" + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Cache.status.ToString() == \""second\"""", + ""Child"": ""End"" + }, + { + ""Child"": ""Fail"" + } + ] + }, + ""End"": { + ""Type"": ""Leaf"" + }, + ""Fail"": { + ""Type"": ""Leaf"" + } + } + }"; + + #endregion CacheVars Schemas } -} \ No newline at end of file +} diff --git a/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs b/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs index 8dbc702..4369ae8 100644 --- a/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs +++ b/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs @@ -1346,5 +1346,176 @@ private TreeWalkerSession InitializeSubroutineTree(SubroutineInput subroutineInp return new TreeWalkerSession(subroutineParameters); } + + #region CacheVars + + [TestMethod] + public void TestCacheVars_StaticExpression() + { + // Test - CacheVars with a static Roslyn expression binds value available in ShouldSelect. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_StaticExpression); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("CorrectValue", currentNode, "Expected Cache.myVal == 42 to route to CorrectValue."); + } + + [TestMethod] + public void TestCacheVars_SessionGetOutput() + { + // Test - CacheVars can reference Session.GetOutput to extract ActionResponse data. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_SessionGetOutput); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Found", currentNode, "Expected Cache.actionStatus == 'Success'."); + } + + [TestMethod] + public void TestCacheVars_UserContext() + { + // Test - CacheVars can bind UserContext properties. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_UserContext); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Found", currentNode, "Expected Cache.userName == 'MyName'."); + } + + [TestMethod] + public void TestCacheVars_NodeScoped_ClearedBetweenNodes() + { + // Test - CacheVars are scoped to the current node only. Second node should not see first node's vars. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_NodeScoped); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Isolated", currentNode, "Expected Cache to be cleared between nodes."); + } + + [TestMethod] + public void TestCacheVars_InvalidExpression_ThrowsEvaluateDynamicPropertyException() + { + // Test - Invalid CacheVariable expression throws EvaluateDynamicPropertyException, status is Failed_EvaluateDynamicProperty. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_InvalidExpression); + + Assert.ThrowsException(() => + { + this.session.WalkTree("Root").GetAwaiter().GetResult(); + }); + Assert.AreEqual("Failed_EvaluateDynamicProperty", this.session.Status); + } + + [TestMethod] + public void TestCacheVars_MultipleCacheVars() + { + // Test - Multiple CacheVars on the same node all resolve correctly. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_Multiple); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Found", currentNode, "Expected all three string cache variables to resolve correctly."); + } + + [TestMethod] + public void TestCacheVars_StringLiteral() + { + // Test - CacheVars with a non-Roslyn string literal value. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_StringLiteral); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Found", currentNode, "Expected string literal to be accessible as Cache.literal."); + } + + [TestMethod] + public void TestCacheVars_BackwardCompat_NoCacheVars() + { + // Test - Schemas without CacheVars work identically (backward compat). + this.TestFromFileInitialize(filePath: TardigradeSchemaPath); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + } + + [TestMethod] + public void TestCacheVars_SetCachePublicApi() + { + // Test - Public SetCache API pre-fills the Cache for ShouldSelect evaluation. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_StaticExpression); + + // Manually set Cache via public API before WalkTree + this.session.SetCache(new { myVal = 42 }).GetAwaiter().GetResult(); + + // The schema will evaluate its own CacheVars (overwriting), but this tests that SetCache doesn't throw. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + } + + [TestMethod] + public void TestCacheVars_ObjectValue() + { + // Test - CacheVars can store an object (ActionResponse) and access it in ShouldSelect. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_ObjectValue); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Found", currentNode, "Expected Cache.response to not be null."); + } + + [TestMethod] + public void TestCacheVars_BooleanExpression() + { + // Test - CacheVars with boolean value used directly in ShouldSelect. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_BooleanExpression); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Ready", currentNode, "Expected Cache.IsReady == true to route to Ready."); + } + + [TestMethod] + public void TestCacheVars_AllNodeTypes() + { + // Test - CacheVars works on Selection and Action node types. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_AllNodeTypes); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("End", currentNode, "Expected CacheVars to work on all node types."); + } + + [TestMethod] + public void TestCacheVars_SameNameAcrossNodes() + { + // Test - Same-named CacheVar across two nodes confirms isolation (each node gets fresh value). + this.TestInitialize(jsonSchema: ForgeSchemaHelper.CacheVars_SameNameAcrossNodes); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("End", currentNode, "Expected second node to have its own 'status' = 'second'."); + } + + #endregion CacheVars } -} \ No newline at end of file +} diff --git a/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json b/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json index 3af7616..feda591 100644 --- a/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json +++ b/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json @@ -50,6 +50,12 @@ }, "Properties": { "type": "object" + }, + "CacheVars": { + "type": "object", + "patternProperties": { + ".*": { "type": "string" } + } } }, "additionalProperties": false, @@ -95,6 +101,12 @@ }, "Properties": { "type": "object" + }, + "CacheVars": { + "type": "object", + "patternProperties": { + ".*": { "type": "string" } + } } }, "additionalProperties": false, @@ -156,6 +168,12 @@ }, "Properties": { "type": "object" + }, + "CacheVars": { + "type": "object", + "patternProperties": { + ".*": { "type": "string" } + } } }, "additionalProperties": false, @@ -281,4 +299,4 @@ ] } } -} \ No newline at end of file +} diff --git a/Forge.TreeWalker/contracts/ForgeTree.cs b/Forge.TreeWalker/contracts/ForgeTree.cs index 38d9834..b69f266 100644 --- a/Forge.TreeWalker/contracts/ForgeTree.cs +++ b/Forge.TreeWalker/contracts/ForgeTree.cs @@ -58,6 +58,16 @@ public class TreeNode [DataMember] public ChildSelector[] ChildSelector { get; private set; } + /// + /// Optional cache variables that are evaluated via EvaluateDynamicProperty after actions complete + /// and made available in ShouldSelect expressions via the Cache object (e.g., "Cache.myVar"). + /// Each property key is the variable name, and the value is an expression + /// (e.g., "C#|Session.GetOutput(\"ActionKey\").Output.PropertyName"). + /// Cache variables are scoped to the current node only — they are cleared after SelectChild. + /// + [DataMember] + public dynamic CacheVars { get; private set; } + #region Properties used only by TreeNodeType.Action nodes /// diff --git a/Forge.TreeWalker/src/ExpressionExecutor.cs b/Forge.TreeWalker/src/ExpressionExecutor.cs index 51bf3bd..886f009 100644 --- a/Forge.TreeWalker/src/ExpressionExecutor.cs +++ b/Forge.TreeWalker/src/ExpressionExecutor.cs @@ -79,7 +79,8 @@ public ExpressionExecutor(ITreeSession session, object userContext, List d { UserContext = userContext, Session = session, - TreeInput = treeInput + TreeInput = treeInput, + Cache = null }; this.scriptCache = scriptCache ?? new ConcurrentDictionary>(); @@ -208,6 +209,32 @@ public bool ScriptCacheContainsKey(string expression) return this.scriptCache.ContainsKey(expression); } + /// + /// Gets the Cache object (the evaluated CacheVars result, or null). + /// + /// The cache object. + public object GetCache() + { + return this.parameters.Cache; + } + + /// + /// Sets the Cache object to the evaluated CacheVars result. + /// + /// The evaluated cache object from EvaluateDynamicProperty. + public void SetCache(object cache) + { + this.parameters.Cache = cache; + } + + /// + /// Clears the Cache, resetting it to null for the next node visit. + /// + public void ClearCache() + { + this.parameters.Cache = null; + } + /// /// This class defines the global parameter that will be passed into the Roslyn expression evaluator. /// @@ -232,6 +259,13 @@ public class CodeGenInputParams /// For Subroutines, this is evaluated from the SubroutineInput on the schema. /// public dynamic TreeInput { get; set; } + + /// + /// The dynamic Cache object that holds node-scoped CacheVars. + /// Variables are set after actions complete and are available in ShouldSelect expressions. + /// Cache is cleared after SelectChild. + /// + public dynamic Cache { get; set; } } /// diff --git a/Forge.TreeWalker/src/TreeWalkerSession.cs b/Forge.TreeWalker/src/TreeWalkerSession.cs index 38efbf3..92c0683 100644 --- a/Forge.TreeWalker/src/TreeWalkerSession.cs +++ b/Forge.TreeWalker/src/TreeWalkerSession.cs @@ -282,6 +282,17 @@ public string GetCurrentNodeSkipActionContext() return this.currentNodeSkipActionContext; } + /// + /// Evaluates the given CacheVars schema object and sets the result on the Cache. + /// This is useful for external UTs that need to pre-fill the Cache before evaluating ShouldSelect expressions. + /// + /// The CacheVars schema object to evaluate (typically from TreeNode.CacheVars). + public async Task SetCache(dynamic cacheVars) + { + object cacheResult = await this.EvaluateDynamicProperty(cacheVars, null).ConfigureAwait(false); + this.expressionExecutor.SetCache(cacheResult); + } + /// /// Signals the WalkTree and VisitNode cancellation token sources to cancel. /// @@ -514,8 +525,17 @@ public async Task VisitNode(string treeNodeKey) return null; } - // Return next child to visit, if possible. - return await this.SelectChild(treeNode).ConfigureAwait(false); + // Evaluate CacheVars after actions complete, before selecting child. + // Uses the same EvaluateDynamicProperty pattern as Properties/Input. + await this.SetCache(treeNode.CacheVars).ConfigureAwait(false); + + // Select next child to visit. + string result = await this.SelectChild(treeNode).ConfigureAwait(false); + + // Clear Cache after SelectChild — locks Cache access to ShouldSelect only. + this.expressionExecutor.ClearCache(); + + return result; } ///