交运行工位桩:三口契约、运行服务、六态 VM、跨线程切 UI。

采集/PLC/Inspect 均为桩,验收 1–8 可测。不部署现场。
This commit is contained in:
C# 开发
2026-08-19 01:16:32 +00:00
commit bcddcd0bed
36 changed files with 1432 additions and 0 deletions
@@ -0,0 +1,220 @@
using VisionHost.Contracts;
using VisionHost.Runtime;
using Xunit;
namespace VisionHost.Runtime.Tests;
public sealed class RunServiceAcceptanceTests
{
private static (RunService run, StubFrameSource cam, ScriptedConfirm confirm) New(
IEnumerable<bool>? start = null,
IEnumerable<bool>? stop = null)
{
var cam = new StubFrameSource();
var confirm = new ScriptedConfirm(start, stop);
var run = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm);
return (run, cam, confirm);
}
[Fact]
public void Disconnected_StartDisabled_NoConfirm()
{
var (run, cam, confirm) = New();
cam.SetConnection(ConnectionState.Disconnected);
var s = run.Snapshot();
Assert.Equal(RunState.Disconnected, s.RunState);
Assert.Equal("断连", s.BadgeText);
Assert.False(s.CanStart);
Assert.Equal("未连接", s.ConnectionText);
Assert.False(run.RequestStart());
Assert.Equal(0, confirm.StartPrompts);
run.Dispose();
}
[Fact]
public void Reconnecting_StartDisabled_NoConfirm()
{
var (run, cam, confirm) = New();
cam.SetConnection(ConnectionState.Reconnecting);
Assert.False(run.Snapshot().CanStart);
Assert.False(run.RequestStart());
Assert.Equal(0, confirm.StartPrompts);
run.Dispose();
}
[Fact]
public void Start_CancelKeepsIdle()
{
var (run, _, confirm) = New(start: new[] { false });
Assert.True(run.Snapshot().CanStart);
Assert.False(run.RequestStart());
Assert.Equal(1, confirm.StartPrompts);
Assert.False(run.Snapshot().IsRunning);
Assert.Equal(RunState.Idle, run.Snapshot().RunState);
run.Dispose();
}
[Fact]
public async Task Start_Confirm_ClearsCounts_AndRuns()
{
var (run, _, _) = New(start: new[] { true }, stop: new[] { true });
Assert.True(run.RequestStart());
await WaitAsync(() => run.Snapshot().TotalCount >= 1);
var mid = run.Snapshot();
Assert.True(mid.IsRunning);
Assert.True(mid.CanStop);
Assert.False(mid.CanStart);
Assert.True(run.RequestStop());
Assert.True(mid.TotalCount >= 1);
var confirm2 = new ScriptedConfirm(new[] { true }, new[] { true });
run.Dispose();
var cam = new StubFrameSource();
var run2 = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm2);
Assert.True(run2.RequestStart());
Assert.Equal(0, run2.Snapshot().OkCount);
Assert.Equal(0, run2.Snapshot().NgCount);
run2.Dispose();
}
[Fact]
public async Task Stop_CancelKeepsRunning_ConfirmKeepsCounts()
{
var confirm = new ScriptedConfirm(start: new[] { true }, stop: new[] { false, true });
var cam = new StubFrameSource();
using var run = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm);
Assert.True(run.RequestStart());
await WaitAsync(() => run.Snapshot().TotalCount >= 2);
var before = run.Snapshot().TotalCount;
Assert.False(run.RequestStop());
Assert.True(run.Snapshot().IsRunning);
Assert.Equal(1, confirm.StopPrompts);
Assert.True(run.RequestStop());
var after = run.Snapshot();
Assert.False(after.IsRunning);
Assert.True(after.TotalCount >= before);
Assert.True(after.CanStart);
Assert.False(after.CanStop);
}
[Fact]
public async Task FifthShot_IsNgStub()
{
var (run, _, _) = New(start: new[] { true }, stop: new[] { true });
run.RequestStart();
await WaitAsync(() => run.Snapshot().TotalCount >= 5, 5000);
var s = run.Snapshot();
Assert.True(s.NgCount >= 1);
Assert.Contains(s.LastResultText, new[] { "NG STUB", "OK" });
await WaitAsync(() => run.Snapshot().LastResultText == "NG STUB" || run.Snapshot().NgCount >= 1);
run.RequestStop();
run.Dispose();
}
[Fact]
public void LastResult_Dash_WhenNoShot()
{
var (run, _, _) = New();
Assert.Equal("—", run.Snapshot().LastResultText);
run.Dispose();
}
[Fact]
public void LastResult_NgWithoutCode_IsNgOnly()
{
var cam = new StubFrameSource();
var inspect = new FixedInspect(new InspectResult(false, null, 1, null));
using var run = new RunService(cam, cam, inspect, new StubPlcIo(), new AlwaysConfirm());
run.RequestStart();
cam.GetType(); // keep
// inject via queue by starting grab
WaitAsync(() => run.Snapshot().TotalCount >= 1).GetAwaiter().GetResult();
Assert.Equal("NG", run.Snapshot().LastResultText);
run.RequestStop();
}
[Fact]
public void Alarm_WhileConnected_CanStopIfRunning()
{
var (run, _, _) = New(start: new[] { true });
run.RequestStart();
run.RaiseAlarm("光源异常");
var s = run.Snapshot();
Assert.Equal(RunState.Alarm, s.RunState);
Assert.Equal("报警", s.BadgeText);
Assert.Equal("光源异常", s.AlarmText);
Assert.True(s.CanStop);
run.Dispose();
}
[Fact]
public void Disconnect_OverridesAlarm_StartDisabled()
{
var (run, cam, confirm) = New();
run.RaiseAlarm("光源异常");
cam.SetConnection(ConnectionState.Disconnected);
var s = run.Snapshot();
Assert.Equal(RunState.Disconnected, s.RunState);
Assert.False(s.CanStart);
Assert.False(run.RequestStart());
Assert.Equal(0, confirm.StartPrompts);
run.Dispose();
}
[Fact]
public void Counts_PersistAcrossStop_ResetOnNextStart()
{
var confirm = new ScriptedConfirm(start: new[] { true, true }, stop: new[] { true });
var cam = new StubFrameSource();
using var run = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm);
run.RequestStart();
WaitAsync(() => run.Snapshot().TotalCount >= 3).GetAwaiter().GetResult();
run.RequestStop();
var kept = run.Snapshot().TotalCount;
Assert.True(kept >= 3);
run.RequestStart();
Assert.Equal(0, run.Snapshot().TotalCount);
}
private static async Task WaitAsync(Func<bool> pred, int ms = 3000)
{
var until = DateTime.UtcNow.AddMilliseconds(ms);
while (DateTime.UtcNow < until)
{
if (pred()) return;
await Task.Delay(20);
}
throw new TimeoutException("condition not met");
}
[Fact]
public async Task Changed_FromWorker_PostsToUiDispatch()
{
var ui = new RecordingDispatch();
using var vm = RunViewModel.CreateStub(new AlwaysConfirm(), ui);
Assert.True(vm.Service.RequestStart());
await WaitAsync(() => vm.TotalCount >= 1);
Assert.True(ui.PostCount >= 1);
vm.Service.RequestStop();
}
private sealed class RecordingDispatch : IUiDispatch
{
public int PostCount;
public void Post(Action action)
{
PostCount++;
action();
}
}
private sealed class FixedInspect : IInspectService
{
private readonly InspectResult _r;
public FixedInspect(InspectResult r) => _r = r;
public Task<InspectResult> Inspect(object frameHandle, string recipeId, CancellationToken cancellationToken)
=> Task.FromResult(_r);
}
}