XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Drawing;
using System.Drawing.Imaging;
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;
using XFEWorkflow.Plugins.Vision;

namespace XFEWorkflow.Test;

[TestFixture]
public class VisionTests
{
    [Test]
    public void ReverseDragUsesPhysicalPixelsAcrossNegativeMonitorCoordinates()
    {
        var bounds = new ScreenRegion(-1920, -200, 4480, 1640);
        var region = ScreenRegion.FromDrag(800, 400, 100, 80, bounds, 1.5);
        Assert.Equal(new ScreenRegion(-1770, -80, 1050, 480), region);
        region.RequireInside(bounds);
        Assert.Equal(region, ScreenRegion.Parse(JsonNode.Parse(region.ToJson().ToJsonString())!));
        Assert.Equal(new ScreenRegion(-1920, -200, 4480, 1640), ScreenRegion.FromDrag(-10, -50, 10000, 10000, bounds, 2));
    }
    [Test]
    public void InvalidRegionsCannotSilentlyCaptureAnotherArea()
    {
        Assert.Throws<ArgumentException>(() => new ScreenRegion(0, 0, 0, 10).Validate());
        Assert.Throws<ArgumentException>(() => new ScreenRegion(0, 0, 10000, 10000).Validate());
        Assert.Throws<ArgumentException>(() => new ScreenRegion(90, 0, 20, 10).RequireInside(new(0, 0, 100, 100)));
        Assert.Throws<ArgumentException>(() => ScreenRegion.Parse(JsonNode.Parse("{\"x\":0.5,\"y\":0,\"width\":10,\"height\":10}")!));
        Assert.Throws<ArgumentException>(() => ScreenRegion.FromDrag(0, 0, 1, 1, new(0, 0, 100, 100), double.NaN));
    }
    [Test]
    public async Task CroppingFilesKeepsOriginalImageCoordinates()
    {
        await using var workspace = new TestWorkspace(); var path = Path.Combine(workspace.Paths.Temp, "crop.png");
        using (var image = new Bitmap(100, 80)) { using var graphics = Graphics.FromImage(image); graphics.Clear(Color.White); image.SetPixel(30, 20, Color.Blue); image.Save(path, ImageFormat.Png); }
        using var frame = ScreenCapture.Load(path, new(25, 15, 20, 20));
        Assert.Equal(Color.Blue.ToArgb(), frame.Bitmap.GetPixel(5, 5).ToArgb()); Assert.Equal("image", frame.CoordinateSpace);
        Assert.Equal(new ScreenRegion(25, 15, 20, 20), frame.Region);
        Assert.Throws<ArgumentException>(() => ScreenCapture.Load(path, new(-1, 0, 10, 10)));
    }
    [Test]
    public void ImageMatchingFindsMultipleTargetsAndChecksPixelsBeyondAnchors()
    {
        using var template = new Bitmap(9, 9); using (var graphics = Graphics.FromImage(template)) { graphics.Clear(Color.Navy); graphics.FillRectangle(Brushes.Orange, 2, 2, 5, 5); }
        using var source = new Bitmap(70, 40); using (var graphics = Graphics.FromImage(source)) { graphics.Clear(Color.White); graphics.DrawImageUnscaled(template, 4, 8); graphics.DrawImageUnscaled(template, 30, 20); graphics.DrawImageUnscaled(template, 50, 5); }
        source.SetPixel(53, 8, Color.Lime); // A pixel not present in the anchor grid.
        var matches = ImageMatcher.Find(source, template, new(-100, 40, 70, 40), 0, 10, default);
        Assert.Equal(2, matches.Count); Assert.Equal(-96, matches[0]!.AsObject().Integer("x")); Assert.Equal(48, matches[0]!.AsObject().Integer("y"));
        Assert.Equal(1, ImageMatcher.Find(source, template, new(0, 0, 70, 40), 0, 1, default).Count);
        using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
        Assert.Throws<OperationCanceledException>(() => ImageMatcher.Find(source, template, new(0, 0, 70, 40), 0, 10, cancelled.Token));
    }
    [Test]
    public void ImageToleranceIsAppliedToEachChannel()
    {
        using var target = new Bitmap(3, 3); using (var graphics = Graphics.FromImage(target)) graphics.Clear(Color.FromArgb(40, 70, 90));
        using var source = new Bitmap(3, 3); using (var graphics = Graphics.FromImage(source)) graphics.Clear(Color.FromArgb(44, 74, 94));
        Assert.Equal(0, ImageMatcher.Find(source, target, new(0, 0, 3, 3), 3, 1, default).Count);
        Assert.Equal(1, ImageMatcher.Find(source, target, new(0, 0, 3, 3), 4, 1, default).Count);
    }
    [Test]
    public void TextSearchMapsChinesePhrasesToOnlyTheirWords()
    {
        var data = Recognition();
        var result = OcrTextSearch.Find(data, "订单编号");
        Assert.True(result.Boolean("found")); Assert.Equal(1, result["matches"]!.AsArray().Count);
        Assert.Equal(-100, result["region"]!.AsObject().Integer("x")); Assert.Equal(100, result["region"]!.AsObject().Integer("width"));
        Assert.Equal(-50, result["point"]!.AsObject().Integer("x"));
        Assert.True(OcrTextSearch.Find(data, "XFE 12345").Boolean("found"));
        Assert.False(OcrTextSearch.Find(data, "订单编号", "exact").Boolean("found"));
        Assert.True(OcrTextSearch.Find(data, "订单编号 XFE 12345", "exact").Boolean("found"));
        Assert.False(OcrTextSearch.Find(data, "missing").Boolean("found"));
        Assert.True(OcrTextSearch.Find(data, "XFE\\s+\\d+", "regex").Boolean("found"));
        Assert.Throws<ArgumentException>(() => OcrTextSearch.Find(data, "[", "regex"));
    }
    [Test]
    public async Task OfflineOcrRecognizesChineseEnglishAndPreservesCropCoordinates()
    {
        await using var workspace = new TestWorkspace(); var path = Path.Combine(workspace.Paths.Temp, "OCR 中文.png");
        CreateOcrFixture(path);
        using var frame = ScreenCapture.Load(path, new(40, 35, 850, 250));
        using var ocr = new OcrRecognizer();
        var result = ocr.Recognize(frame, new() { ["scale"] = 1.5, ["minimumConfidence"] = 0, ["segmentation"] = "SingleBlock" }, default);
        var compact = new string(result.String("text").Where(c => !char.IsWhiteSpace(c)).ToArray());
        Assert.Contains("订单", compact); Assert.Contains("XFE12345", compact);
        Assert.True(result["words"]!.AsArray().Count > 0);
        foreach (var word in result["words"]!.AsArray().OfType<JsonObject>()) ScreenRegion.Parse(word).RequireInside(frame.Region);
        Assert.True(OcrTextSearch.Find(result, "12345").Boolean("found"));
        using var cancel = new CancellationTokenSource(); cancel.Cancel();
        Assert.Throws<OperationCanceledException>(() => ocr.Recognize(frame, [], cancel.Token));
    }
    [Test]
    public async Task OcrIsComposableFromGraphAndPureCodeWithoutDesktopAccess()
    {
        await using var workspace = new TestWorkspace(); var path = Path.Combine(workspace.Paths.Temp, "source.png"); CreateOcrFixture(path);
        var registry = new CapabilityRegistry(); new VisionPlugin().Register(registry);
        using var compiler = new CodeCompiler(); var context = TestSupport.Context(registry);
        var node = new NodeDefinition { CapabilityId = "xfe.vision.ocr", Parameters = new() { ["imagePath"] = path, ["language"] = "eng", ["scale"] = 1, ["segmentation"] = "SingleBlock" } };
        var graph = TestSupport.Graph(node); graph.Graph.Nodes[^1].Parameters["text"] = new JsonObject { ["$node"] = node.Id + ".text" };
        var graphResult = await new WorkflowEngine(compiler, CoreCapabilities.Catalog(registry.Descriptors.Values)).ExecuteAsync(graph, context, default);
        Assert.Contains("12345", graphResult.String("text"));
        var code = """
            using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using XFEWorkflow.Abstractions;
            public sealed class Workflow : IWorkflow {
                public async ValueTask<JsonObject> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
                    await context.InvokeAsync("xfe.vision.ocr", context.Inputs, cancellationToken);
            }
            """;
        var codeResult = await compiler.ExecuteAsync(code, false, "OCR.cs", TestSupport.Context(registry, WorkflowJson.Copy(node.Parameters)), default);
        Assert.Contains("12345", codeResult.String("text"));
    }
    [Test]
    public async Task WaitReturnsAUsableTimeoutAndStopsOnCancellation()
    {
        var probes = 0;
        Task<JsonObject> Probe() { probes++; return Task.FromResult(new JsonObject { ["found"] = false }); }
        var result = await VisionPolling.WaitAsync(Probe, new() { ["timeoutMilliseconds"] = 0 }, default);
        Assert.True(result.Boolean("timedOut")); Assert.Equal(1, probes);
        using var cancel = new CancellationTokenSource(50);
        await Assert.ThrowsAsync<OperationCanceledException>(() => VisionPolling.WaitAsync(Probe, new() { ["timeoutMilliseconds"] = 10000 }, cancel.Token));
        var success = await VisionPolling.WaitAsync(() => Task.FromResult(new JsonObject { ["found"] = true }), [], default);
        Assert.False(success.Boolean("timedOut"));
    }
    [Test]
    public async Task MouseAndClipboardNodesUseExclusiveHostOperations()
    {
        var calls = new List<(string Method, JsonObject Input)>();
        var context = new WorkflowExecutionContext { RunId = "vision-test", Capabilities = new CapabilityRegistry(), Secret = (_, _) => throw new NotSupportedException(), Subworkflow = (_, _, _) => throw new NotSupportedException(),
            Host = (method, input, _) => { calls.Add((method, WorkflowJson.Copy(input))); return ValueTask.FromResult(new JsonObject()); } };
        await new VisionAction("xfe.vision.click").ExecuteAsync(new() { ["point"] = new JsonObject { ["x"] = -500, ["y"] = 120 }, ["button"] = "right", ["clicks"] = 2 }, context, default);
        Assert.Equal("desktop.acquire", calls[0].Method); Assert.Equal("desktop.input", calls[1].Method);
        Assert.Equal(-500, calls[1].Input.Integer("x")); Assert.Equal("right", calls[1].Input.String("button")); Assert.Equal(2, calls[1].Input.Integer("clicks"));
        await new VisionAction("xfe.vision.clipboardWrite").ExecuteAsync(new() { ["text"] = "剪贴板中文" }, context, default);
        Assert.Equal("screen.clipboardWrite", calls[^1].Method); Assert.Equal("剪贴板中文", calls[^1].Input.String("text"));
        Assert.Throws<ArgumentException>(() => VisionParameters.Input("click", new() { ["point"] = new JsonObject { ["x"] = 1, ["y"] = 1 }, ["clicks"] = 3 }));
        Assert.Throws<ArgumentException>(() => VisionParameters.Input("move", new() { ["point"] = new JsonObject { ["x"] = 1.2, ["y"] = 1 } }));
    }
    [Test]
    public void PluginDeclaresAllNodesAndPortableFormHints()
    {
        var manifest = VisionPlugin.Manifest; PluginPackage.Validate(manifest);
        Assert.Equal("1.1.0", manifest.SdkMinimumVersion);
        Assert.Equal(17, manifest.Capabilities.Count);
        var registry = new CapabilityRegistry(); new VisionPlugin().Register(registry); Assert.Equal(17, registry.Descriptors.Count);
        var restored = JsonSerializer.Deserialize<PluginManifest>(JsonSerializer.Serialize(manifest, WorkflowJson.Options), WorkflowJson.Options)!;
        Assert.Equal("screen-region", restored.Capabilities.Single(c => c.Id == "xfe.vision.ocr").Inputs.Single(p => p.Name == "region").EditorHint);
        Assert.Throws<ArgumentException>(() => VisionParameters.Color("red"));
        Assert.Equal(Color.FromArgb(12, 34, 56).ToArgb(), VisionParameters.Color("#0c2238").ToArgb());
    }
    [Test]
    public async Task EmptySourceBindingsNeverFallBackToTheWholeDesktop()
    {
        var context = TestSupport.Context(); // Any host acquisition is an error in this test.
        await Assert.ThrowsAsync<ArgumentException>(() => new VisionAction("xfe.vision.ocr").ExecuteAsync(new() { ["imagePath"] = null }, context, default).AsTask());
        await Assert.ThrowsAsync<ArgumentException>(() => new VisionAction("xfe.vision.capture").ExecuteAsync(new() { ["region"] = null, ["path"] = "unused.png" }, context, default).AsTask());
    }
    [Test]
    public async Task ShippedGraphAndCodeExamplesValidateWithTheSdk()
    {
        var registry = new CapabilityRegistry(); new VisionPlugin().Register(registry);
        var catalog = CoreCapabilities.Catalog(registry.Descriptors.Values);
        Assert.Empty(GraphValidator.Validate(WorkflowTemplates.ScreenOcr(), catalog));
        using var compiler = new CodeCompiler();
        foreach (var kind in new[] { "graph", "code" })
        {
            var path = Path.Combine(TestSupport.Root, "samples", "workflows", "screen-ocr-" + kind + ".xfeworkflow");
            var sample = JsonSerializer.Deserialize<WorkflowDefinition>(await File.ReadAllTextAsync(path), WorkflowJson.Options)!;
            if (sample.Kind == WorkflowKind.Graph) Assert.Empty(GraphValidator.Validate(sample, catalog));
            else Assert.Empty((await compiler.AnalyzeAsync(sample.Code, false, "屏幕识别.cs")).Where(d => d.IsError));
        }
    }
    internal static void CreateOcrFixture(string path)
    {
        using var bitmap = new Bitmap(960, 340); using var graphics = Graphics.FromImage(bitmap); graphics.Clear(Color.White);
        graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit;
        using var font = new Font("Microsoft YaHei UI", 32, FontStyle.Regular, GraphicsUnit.Pixel);
        graphics.DrawString("订单编号 XFE 12345", font, Brushes.Black, 70, 65);
        graphics.DrawString("待处理订单 8", font, Brushes.Black, 70, 140); bitmap.Save(path, ImageFormat.Png);
    }
    private static JsonObject Recognition() => new() { ["coordinateSpace"] = "screen", ["text"] = "订单 编号 XFE 12345", ["words"] = new JsonArray(
        new JsonObject { ["text"] = "订单", ["line"] = 0, ["x"] = -100, ["y"] = 10, ["width"] = 45, ["height"] = 20 },
        new JsonObject { ["text"] = "编号", ["line"] = 0, ["x"] = -50, ["y"] = 10, ["width"] = 50, ["height"] = 20 },
        new JsonObject { ["text"] = "XFE", ["line"] = 0, ["x"] = 10, ["y"] = 10, ["width"] = 40, ["height"] = 20 },
        new JsonObject { ["text"] = "12345", ["line"] = 0, ["x"] = 60, ["y"] = 10, ["width"] = 60, ["height"] = 20 }) };
}