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 }) };
}
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 }) };
}