using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using XFEExtension.NetCore.XUnit.Assertions; using XFEExtension.NetCore.XUnit.Attributes; using XFEWorkflow.Abstractions; using XFEWorkflow.Core.Runtime; using XFEWorkflow.Infrastructure.Compilation; namespace XFEWorkflow.Test; [TestFixture] public class PortTypeTests { [Test] public void ConcreteTypesRequireAnExactMatchAndNominalMessagesRejectPlainObjects() { foreach (var source in Enum.GetValues()) foreach (var target in Enum.GetValues()) Assert.Equal(source == target || target == WorkflowValueType.Any, GraphValidator.Compatible(new PortDefinition { Type = source }, new PortDefinition { Type = target })); var message = new PortDefinition { Type = WorkflowValueType.Object, TypeId = "xfe.wecom.message" }; Assert.True(GraphValidator.Compatible(message, WorkflowJson.Clone(message))); Assert.True(!GraphValidator.Compatible(message, new() { Type = WorkflowValueType.Object })); Assert.True(!GraphValidator.Compatible(new() { Type = WorkflowValueType.Object }, message)); Assert.True(!GraphValidator.Compatible(message, new() { Type = WorkflowValueType.Object, TypeId = "other.message" })); Assert.True(GraphValidator.Compatible(message, new() { Type = WorkflowValueType.Any })); } [Test] public async Task TriggerDeclaredFieldsPathsAndLegacyAliasesReachIndependentTypedInputs() { var registry = new CapabilityRegistry(); new Plugins.WeCom.WeComPlugin().Register(registry); JsonObject? captured = null; registry.RegisterAction(new() { Id = "test.capture", Inputs = [P("message", WorkflowValueType.Object, "xfe.wecom.message"), P("legacy", WorkflowValueType.Object, "xfe.wecom.message"), P("content", WorkflowValueType.String), P("groupId", WorkflowValueType.String), P("senderId", WorkflowValueType.String), P("hasContent", WorkflowValueType.Boolean)] }, () => new Capture(input => captured = WorkflowJson.Copy(input))); var receive = new NodeDefinition { Id = "receive", CapabilityId = "xfe.wecom.message.received", Trigger = new() { Kind = TriggerKind.Plugin, CapabilityId = "xfe.wecom.message.received" } }; var capture = new NodeDefinition { Id = "capture", CapabilityId = "test.capture" }; var workflow = new WorkflowDefinition { Graph = new() { Nodes = [receive, capture], Edges = [new() { FromNode = receive.Id, ToNode = capture.Id }] } }; foreach (var name in new[] { "message", "content", "groupId", "senderId", "hasContent" }) workflow.Graph.Edges.Add(new() { Kind = PortKind.Data, FromNode = receive.Id, FromPort = name, ToNode = capture.Id, ToPort = name }); workflow.Graph.Edges.Add(new() { Kind = PortKind.Data, FromNode = receive.Id, FromPort = "event", ToNode = capture.Id, ToPort = "legacy" }); var data = new JsonObject { ["dispatchId"] = "fixture", ["content"] = "正文", ["conversationId"] = "群", ["senderId"] = "成员", ["hasContent"] = true }; var context = TestSupport.Context(registry, new() { ["trigger"] = new JsonObject { ["data"] = data.DeepClone() } }, entryNodeId: receive.Id); using var compiler = new CodeCompiler(); var catalog = CoreCapabilities.Catalog(registry.Descriptors.Values); Assert.True(catalog[receive.CapabilityId].Outputs.Count > 5); Assert.True(!catalog[receive.CapabilityId].Outputs.Any(x => x.Name == "event")); Assert.True(!catalog[receive.CapabilityId].Inputs.Any()); Assert.Equal(0, GraphValidator.Validate(workflow, catalog).Count); await new WorkflowEngine(compiler, catalog).ExecuteAsync(workflow, context, default); Assert.True(captured is not null && JsonNode.DeepEquals(data, captured["message"]) && JsonNode.DeepEquals(data, captured["legacy"])); Assert.Equal("正文", captured!.String("content")); Assert.Equal("群", captured.String("groupId")); Assert.Equal("成员", captured.String("senderId")); Assert.True(captured.Boolean("hasContent")); Assert.True(JsonNode.DeepEquals(data, context.NodeOutputs[receive.Id]["event"])); } [Test] public void TriggerProjectionDoesNotModifyThePluginAndObjectCannotFeedMessageInput() { var plugin = new Plugins.WeCom.WeComPlugin().Manifest; var original = plugin.Capabilities.Single(x => x.IsTrigger); var descriptor = WorkflowTriggerNodes.AsNodeDescriptor(original); descriptor.Outputs[0].Label = "changed"; Assert.True(original.Outputs[0].Label != "changed"); var flow = Plugins.WeCom.WeComWorkflowImport.Template(); var receive = flow.Graph.Nodes[0]; flow.Graph.Nodes[1].CapabilityId = "xfe.wecom.quoteReply"; flow.Graph.Nodes[1].Parameters["content"] = "1"; flow.Graph.Edges.First(x => x.Kind == PortKind.Data).ToPort = "message"; var catalog = CoreCapabilities.Catalog(plugin.Capabilities); flow.Graph.Edges.First(x => x.Kind == PortKind.Data).FromPort = "content"; Assert.True(GraphValidator.Validate(flow, catalog).Any(x => x.Message.Contains("类型不兼容"))); flow.Graph.Edges.First(x => x.Kind == PortKind.Data).FromPort = "event"; Assert.Equal(0, GraphValidator.Validate(flow, catalog).Count); var fallback = WorkflowTriggerNodes.AsNodeDescriptor(new() { Id = "test.legacy", IsTrigger = true }); Assert.Equal("", fallback.Outputs.Single(x => x.Name == "event").EventPath); var end = new NodeDefinition { CapabilityId = "core.end" }; flow.Graph.Nodes.Add(end); flow.Graph.Edges.Add(new() { Kind = PortKind.Data, FromNode = receive.Id, FromPort = "content", ToNode = end.Id, ToPort = "result" }); Assert.Equal(WorkflowValueType.String, WorkflowPortCatalog.Resolve(flow.Graph, end.Id, "result", false, catalog)!.Type); } [Test] public async Task OlderTriggerManifestsKeepTheirWholeEventAndInputsBindings() { var legacy = new CapabilityDescriptor { Id = "legacy.received", IsTrigger = true, Outputs = [P("event", WorkflowValueType.Object)] }; var catalog = CoreCapabilities.Catalog([legacy]); var receive = new NodeDefinition { CapabilityId = legacy.Id, Trigger = new() { Kind = TriggerKind.Plugin, CapabilityId = legacy.Id } }; var end = new NodeDefinition { CapabilityId = "core.end" }; var flow = new WorkflowDefinition { Graph = new() { Nodes = [receive, end], Edges = [new() { FromNode = receive.Id, ToNode = end.Id }, new() { Kind = PortKind.Data, FromNode = receive.Id, FromPort = "event", ToNode = end.Id, ToPort = "result" }] } }; using var compiler = new CodeCompiler(); var context = TestSupport.Context(inputs: new() { ["trigger"] = new JsonObject { ["data"] = new JsonObject { ["messageId"] = "old" } } }, entryNodeId: receive.Id); var result = await new WorkflowEngine(compiler, catalog).ExecuteAsync(flow, context, default); Assert.Equal("old", result["result"]!["messageId"]!.GetValue()); Assert.True(catalog[legacy.Id].Outputs.Any(x => x.Name == "inputs")); var timer = CoreCapabilities.Catalog(new Plugins.Standard.TimerPlugin().Manifest.Capabilities)["xfe.timer.interval"]; Assert.True(timer.Outputs.Any(x => x.Name == "timestamp") && timer.Outputs.Any(x => x.Name == "event")); } [Test] public void ConversionsParseInvariantValuesAndRejectMalformedOrWrongShapes() { var culture = CultureInfo.CurrentCulture; try { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); Assert.Equal(12.5m, WorkflowConversions.Convert(JsonValue.Create("12.5"), "number")!.GetValue()); Assert.True(WorkflowConversions.Convert(JsonValue.Create("TRUE"), "boolean")!.GetValue()); Assert.True(!WorkflowConversions.Convert(JsonValue.Create(0), "boolean")!.GetValue()); Assert.Equal("中文", WorkflowConversions.Convert(JsonValue.Create("中文"), "string")!.GetValue()); Assert.Equal(2, WorkflowConversions.Convert(JsonValue.Create("[1,2]"), "array")!.AsArray().Count); Assert.Equal(1, WorkflowConversions.Convert(JsonValue.Create("{\"n\":1}"), "object")!["n"]!.GetValue()); Assert.Equal("null", WorkflowConversions.Convert(null, "json")!.GetValue()); var obj = new JsonObject { ["x"] = "a" }; var copy = WorkflowConversions.Convert(obj, "object")!.AsObject(); copy["x"] = "b"; Assert.Equal("a", obj.String("x")); foreach (var invalid in new[] { "NaN", "Infinity", "1,25", "wrong" }) Assert.Throws(() => WorkflowConversions.Convert(JsonValue.Create(invalid), "number")); Assert.Throws(() => WorkflowConversions.Convert(null, "number")); Assert.Throws(() => WorkflowConversions.Convert(JsonValue.Create("yes"), "boolean")); Assert.Throws(() => WorkflowConversions.Convert(JsonValue.Create("[]"), "object")); Assert.Throws(() => WorkflowConversions.Convert(JsonValue.Create("{}"), "array")); Assert.Throws(() => WorkflowConversions.Convert(JsonValue.Create("broken"), "object")); } finally { CultureInfo.CurrentCulture = culture; } } [Test] public async Task ActionPortAliasesPreserveConnectionsAndNodeBindings() { var registry = new CapabilityRegistry(); registry.RegisterAction(new() { Id = "test.renamed", Outputs = [new() { Name = "content", Type = WorkflowValueType.String, Aliases = ["oldContent"] }] }, () => new RenamedAction()); registry.RegisterAction(new() { Id = "test.consume", Inputs = [new() { Name = "message", Type = WorkflowValueType.String, Aliases = ["oldMessage"] }, P("binding", WorkflowValueType.String)] }, () => new Capture(input => { Assert.Equal("兼容正文", input.String("message")); Assert.Equal("兼容正文", input.String("binding")); })); var source = new NodeDefinition { Id = "source", CapabilityId = "test.renamed" }; var target = new NodeDefinition { CapabilityId = "test.consume", Parameters = new() { ["binding"] = new JsonObject { ["$node"] = "source.oldContent" } } }; var workflow = TestSupport.Graph(source, target); workflow.Graph.Edges.Add(new() { Kind = PortKind.Data, FromNode = source.Id, FromPort = "oldContent", ToNode = target.Id, ToPort = "oldMessage" }); using var compiler = new CodeCompiler(); await new WorkflowEngine(compiler, CoreCapabilities.Catalog(registry.Descriptors.Values)).ExecuteAsync(workflow, TestSupport.Context(registry), default); } [Test] public async Task ExplicitConversionWorksInGraphAndGeneratedCodeButUnknownAnyCannotFeedTypedInput() { var set = new NodeDefinition { CapabilityId = "core.set", Parameters = new() { ["name"] = "n", ["value"] = "12.5" } }; var convert = new NodeDefinition { CapabilityId = "core.convert.number" }; var workflow = TestSupport.Graph(set, convert); workflow.Outputs = [P("result", WorkflowValueType.Number)]; workflow.Graph.Edges.AddRange([new() { Kind = PortKind.Data, FromNode = set.Id, FromPort = "value", ToNode = convert.Id, ToPort = "value" }, new() { Kind = PortKind.Data, FromNode = convert.Id, FromPort = "value", ToNode = workflow.Graph.Nodes[^1].Id, ToPort = "result" }]); using var compiler = new CodeCompiler(); var catalog = CoreCapabilities.Catalog(); var result = await new WorkflowEngine(compiler, catalog).ExecuteAsync(workflow, TestSupport.Context(), default); Assert.Equal(12.5m, result["result"]!.GetValue()); workflow.Code = GraphCodeGenerator.Generate(workflow.Graph, workflow.Name); workflow.Kind = WorkflowKind.CSharp; var codeResult = await new WorkflowEngine(compiler, catalog).ExecuteAsync(workflow, TestSupport.Context(), default); Assert.Equal(12.5m, codeResult["result"]!.GetValue()); workflow.Kind = WorkflowKind.Graph; workflow.Graph.Edges[^1].FromNode = set.Id; Assert.True(GraphValidator.Validate(workflow, catalog).Any(x => x.Message.Contains("类型不兼容"))); } private static PortDefinition P(string name, WorkflowValueType type, string typeId = "") => new() { Name = name, Type = type, TypeId = typeId, Required = true }; private sealed class Capture(Action capture) : IWorkflowAction { public ValueTask ExecuteAsync(JsonObject input, IWorkflowContext context, CancellationToken ct) { capture(input); return ValueTask.FromResult(new JsonObject()); } } private sealed class RenamedAction : IWorkflowAction { public ValueTask ExecuteAsync(JsonObject input, IWorkflowContext context, CancellationToken ct) => ValueTask.FromResult(new JsonObject { ["content"] = "兼容正文" }); } }