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? start = null, IEnumerable? 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().LastResultText == "NG STUB" && run.Snapshot().NgCount >= 1, 5000); var s = run.Snapshot(); Assert.Equal("NG STUB", s.LastResultText); Assert.Equal("NG", s.BadgeText); Assert.True(s.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 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 Inspect(object frameHandle, string recipeId, CancellationToken cancellationToken) => Task.FromResult(_r); } }