using Exchanger.Core.Session; using Exchanger.Hardware.Abstractions; using Exchanger.Hardware.PeripheryController; using NUnit.Framework; namespace Exchanger.Tests; [TestFixture] public sealed class PeripheryMachineControllerTests { private static readonly byte[] HostChallenge = Convert.FromHexString("00112233445566778899AABBCCDDEEFF"); [Test] public void CashEvents_AreAcknowledgedAndAggregated() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); var balances = new List(); controller.CashBalanceChanged += balances.Add; int commandCount = port.SentCommands.Count; controller.BeginCashAcceptance(); port.RaiseData("MONEY:BILL:50"); port.RaiseData("MONEY:COINS:10"); using (Assert.EnterMultipleScope()) { Assert.That(balances, Is.EqualTo(new[] { 0, 50, 60 })); Assert.That(port.SentCommands, Does.Contain("ASK:BILL:50")); Assert.That(port.SentCommands, Does.Contain("ASK:COINS:10")); Assert.That(port.SentCommands, Does.Contain("151")); Assert.That( port.SentCommands.Skip(commandCount).Take(3), Is.EqualTo(new[] { "162", "141", "151" })); } } [Test] public void ReadyInitialization_EntersSmartModeAndDisablesEveryPaymentChannel() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); Assert.That( port.SentCommands.TakeLast(4), Is.EqualTo(new[] { "141", "142", "152", "162" })); } [Test] public void CashCancel_DisablesOnlyActiveCashChannelsOnce() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); controller.BeginCashAcceptance(); int commandCount = port.SentCommands.Count; controller.CancelActiveOperation(); controller.EndCashAcceptance(); controller.CancelActiveOperation(); Assert.That( port.SentCommands.Skip(commandCount), Is.EqualTo(new[] { "142", "152" })); } [Test] public void CashlessEvent_IsAcknowledgedAndApprovesExactRequestedAmount() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); MachineCardResult? result = null; controller.CardPaymentFinished += value => result = value; int commandCount = port.SentCommands.Count; controller.BeginCardPayment(150); port.RaiseData("MONEY:CASHLESS:150"); using (Assert.EnterMultipleScope()) { Assert.That(result, Is.EqualTo(MachineCardResult.Approved)); Assert.That(port.SentCommands, Does.Contain("163150")); Assert.That(port.SentCommands, Does.Contain("ASK:CASHLESS:150")); Assert.That(port.SentCommands.Last(), Is.EqualTo("162")); Assert.That( port.SentCommands.Skip(commandCount).Take(4), Is.EqualTo(new[] { "142", "152", "161", "163150" })); } } [Test] public void CardCancel_DisablesCashlessOnlyOnce() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); controller.BeginCardPayment(150); int commandCount = port.SentCommands.Count; controller.CancelActiveOperation(); controller.CancelActiveOperation(); Assert.That(port.SentCommands.Skip(commandCount), Is.EqualTo(new[] { "162" })); } [Test] public void CashlessAmountMismatch_IsAcknowledgedButDeclined() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); MachineCardResult? result = null; controller.CardPaymentFinished += value => result = value; controller.BeginCardPayment(150); port.RaiseData("MONEY:CASHLESS:100"); using (Assert.EnterMultipleScope()) { Assert.That(result, Is.EqualTo(MachineCardResult.Declined)); Assert.That(port.SentCommands, Does.Contain("ASK:CASHLESS:100")); } } [Test] public void CashlessKeepAlive_IsSentBeforeControllerDeadline() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto, keepAliveSeconds: 120); controller.BeginCardPayment(100); int initialEnableCount = port.SentCommands.Count(command => command == "161"); controller.Update(1_000); controller.Update(120_999); controller.Update(121_000); Assert.That(port.SentCommands.Count(command => command == "161"), Is.EqualTo(initialEnableCount + 1)); } [Test] public void CashlessKeepAlive_IsSentWhileIdleAndPausedDuringCashAcceptance() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto, keepAliveSeconds: 120); int initialEnableCount = port.SentCommands.Count(command => command == "161"); controller.Update(1_000); controller.Update(121_000); Assert.That(port.SentCommands.Count(command => command == "161"), Is.EqualTo(initialEnableCount + 2)); controller.BeginCashAcceptance(); controller.Update(300_000); Assert.That(port.SentCommands.Count(command => command == "161"), Is.EqualTo(initialEnableCount + 2)); controller.EndCashAcceptance(); controller.Update(300_001); Assert.That(port.SentCommands.Count(command => command == "161"), Is.EqualTo(initialEnableCount + 3)); } [Test] public void DisconnectDuringPayment_RaisesConnectionLost() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); MachineFault? fault = null; controller.FaultOccurred += value => fault = value; controller.BeginCardPayment(100); port.RaiseConnection(false); Assert.That(fault, Is.EqualTo(MachineFault.ConnectionLost)); } [Test] public void BusinessFramesBeforeReady_AreIgnoredWithoutAcknowledgement() { var port = new FakeHardwarePort(); var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); using var controller = new PeripheryMachineController(port, authenticator, PeripheryDispenseMode.Hopper1Auto); bool serviceRequested = false; controller.ServiceRequested += () => serviceRequested = true; port.RaiseConnection(true); int commandsAfterAuthRequest = port.SentCommands.Count; port.RaiseData("MONEY:BILL:50"); port.RaiseData("SERVICE"); port.RaiseData("READY"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count, Is.EqualTo(commandsAfterAuthRequest)); Assert.That(port.SentCommands, Does.Not.Contain("ASK:BILL:50")); Assert.That(serviceRequested, Is.False); Assert.That(controller.IsReady, Is.False); } } [Test] public void ConnectionWaitsForStartupBannerAndFullTwoSecondDelayBeforeSending07() { var port = new FakeHardwarePort(); var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); using var controller = new PeripheryMachineController(port, authenticator); port.RaiseConnection(true); controller.Update(0); controller.Update(19_999); Assert.That(port.SentCommands, Is.Empty); port.RaiseData("periphery controller ver_test"); controller.Update(20_000); controller.Update(21_999); Assert.That(port.SentCommands, Is.Empty); controller.Update(22_000); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands, Has.Count.EqualTo(1)); Assert.That(port.SentCommands[0], Does.StartWith("07")); Assert.That(port.SentCommands[0], Has.Length.EqualTo(34)); } } [Test] public void MissingChallenge_FailsAfterStartupDelayAndHandshakeTimeout() { var port = new FakeHardwarePort(); var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); using var controller = new PeripheryMachineController( port, authenticator, PeripheryDispenseMode.Hopper1Auto, handshakeTimeoutMilliseconds: 5000); MachineFault? fault = null; controller.FaultOccurred += value => fault = value; port.RaiseConnection(true); port.RaiseData("periphery controller ver_test"); controller.Update(1_000); controller.Update(3_000); controller.Update(3_001); controller.Update(8_000); Assert.That(fault, Is.Null); controller.Update(8_001); using (Assert.EnterMultipleScope()) { Assert.That(fault, Is.EqualTo(MachineFault.Unavailable)); Assert.That(controller.AuthenticationState, Is.EqualTo(ControllerAuthenticationState.Failed)); Assert.That(controller.IsReady, Is.False); Assert.That(port.IsConnected, Is.False); } } [Test] public void MissingProof_FailsAtOverallHandshakeTimeout() { var port = new FakeHardwarePort(); var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); using var controller = new PeripheryMachineController( port, authenticator, PeripheryDispenseMode.Hopper1Auto, handshakeTimeoutMilliseconds: 5000); MachineFault? fault = null; controller.FaultOccurred += value => fault = value; port.RaiseConnection(true); port.RaiseData("periphery controller ver_test"); controller.Update(1_000); controller.Update(3_000); port.RaiseData("CHALFFEEDDCCBBAA99887766554433221100"); controller.Update(3_001); controller.Update(8_000); Assert.That(fault, Is.Null); controller.Update(8_001); using (Assert.EnterMultipleScope()) { Assert.That(fault, Is.EqualTo(MachineFault.Unavailable)); Assert.That(controller.AuthenticationState, Is.EqualTo(ControllerAuthenticationState.Failed)); } } [Test] public void ValidControllerProof_RequiresExactReadyBang() { var port = new FakeHardwarePort(); var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); using var controller = new PeripheryMachineController( port, authenticator, PeripheryDispenseMode.Hopper1Auto, handshakeTimeoutMilliseconds: 5000); MachineFault? fault = null; controller.FaultOccurred += value => fault = value; port.RaiseConnection(true); port.RaiseData("periphery controller ver_test"); controller.Update(1_000); controller.Update(3_000); port.RaiseData("CHALFFEEDDCCBBAA99887766554433221100"); port.RaiseData("RAES00112233445566778899AABBCCDDEEFF"); port.RaiseData("AUTH_PC_OK"); port.RaiseData("AUTH_CNT_OK"); port.RaiseData("READY"); using (Assert.EnterMultipleScope()) { Assert.That(fault, Is.Null); Assert.That(controller.AuthenticationState, Is.EqualTo(ControllerAuthenticationState.AwaitingReady)); Assert.That(controller.IsReady, Is.False); } port.RaiseData("READY!"); using (Assert.EnterMultipleScope()) { Assert.That(fault, Is.Null); Assert.That(controller.AuthenticationState, Is.EqualTo(ControllerAuthenticationState.Ready)); Assert.That(controller.IsReady, Is.True); Assert.That(port.IsConnected, Is.True); } } [Test] public void InvalidControllerProof_FailsAndClosesPortImmediately() { var port = new FakeHardwarePort(); var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); using var controller = new PeripheryMachineController(port, authenticator, PeripheryDispenseMode.Hopper1Auto); var faults = new List(); controller.FaultOccurred += faults.Add; port.RaiseConnection(true); port.RaiseData("periphery controller ver_test"); controller.Update(1_000); controller.Update(3_000); port.RaiseData("CHALFFEEDDCCBBAA99887766554433221100"); port.RaiseData("RAESFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); using (Assert.EnterMultipleScope()) { Assert.That(faults, Is.EqualTo(new[] { MachineFault.Unavailable })); Assert.That(controller.AuthenticationState, Is.EqualTo(ControllerAuthenticationState.Failed)); Assert.That(controller.IsReady, Is.False); Assert.That(port.IsConnected, Is.False); } } [Test] public void Disconnect_CancelsPendingHandshakeTimeout() { var port = new FakeHardwarePort(); var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); using var controller = new PeripheryMachineController( port, authenticator, PeripheryDispenseMode.Hopper1Auto, handshakeTimeoutMilliseconds: 5000); var faults = new List(); controller.FaultOccurred += faults.Add; port.RaiseConnection(true); controller.Update(1_000); port.RaiseConnection(false); controller.Update(100_000); using (Assert.EnterMultipleScope()) { Assert.That(faults, Is.EqualTo(new[] { MachineFault.Unavailable })); Assert.That(controller.AuthenticationState, Is.EqualTo(ControllerAuthenticationState.NotStarted)); } } [Test] public void ServiceAndGenericControllerError_AreTypedAfterReady() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); bool serviceRequested = false; MachineFault? fault = null; controller.ServiceRequested += () => serviceRequested = true; controller.FaultOccurred += value => fault = value; port.RaiseData("SERVICE"); port.RaiseData("ERROR:DEVICE_FAILURE"); using (Assert.EnterMultipleScope()) { Assert.That(serviceRequested, Is.True); Assert.That(fault, Is.EqualTo(MachineFault.Unavailable)); } } [Test] public void ServiceInput_RisingEdgePublishesOneRequestPerPress() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, serviceButtonInputIndex: PeripheryControllerInputs.ServiceButton, serviceButtonActiveHigh: false); int requestCount = 0; controller.ServiceRequested += () => requestCount++; int inactiveMask = 67_108_863; int pressedMask = inactiveMask & ~(1 << PeripheryControllerInputs.ServiceButton); port.RaiseData($"INPUT{inactiveMask}"); port.RaiseData($"INPUT{pressedMask}"); port.RaiseData($"INPUT{pressedMask}"); port.RaiseData($"INPUT{inactiveMask}"); port.RaiseData($"INPUT{pressedMask}"); Assert.That(requestCount, Is.EqualTo(2)); } [Test] public void ServiceFrame_IsIgnoredWhenPhysicalServiceInputIsConfigured() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, serviceButtonInputIndex: PeripheryControllerInputs.ServiceButton, serviceButtonActiveHigh: false); int requestCount = 0; controller.ServiceRequested += () => requestCount++; int inactiveMask = 67_108_863; int pressedMask = inactiveMask & ~(1 << PeripheryControllerInputs.ServiceButton); port.RaiseData($"INPUT{inactiveMask}"); port.RaiseData($"INPUT{pressedMask}"); port.RaiseData("SERVICE"); Assert.That(requestCount, Is.EqualTo(1)); } [Test] public void ServiceInput_RequiresInactiveBaselineBeforeFirstPress() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, serviceButtonInputIndex: PeripheryControllerInputs.ServiceButton, serviceButtonActiveHigh: false); int requestCount = 0; controller.ServiceRequested += () => requestCount++; int inactiveMask = 67_108_863; int pressedMask = inactiveMask & ~(1 << PeripheryControllerInputs.ServiceButton); port.RaiseData("INPUT0"); Assert.That(requestCount, Is.Zero); port.RaiseData($"INPUT{inactiveMask}"); port.RaiseData($"INPUT{pressedMask}"); Assert.That(requestCount, Is.EqualTo(1)); } [Test] public void ServiceAndDispenseInputs_UseFastConfigurablePolling() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, serviceButtonInputIndex: PeripheryControllerInputs.ServiceButton, serviceButtonActiveHigh: false, inputPollMilliseconds: 100); int initialQueries = port.SentCommands.Count(command => command == "91"); controller.Update(1_000); controller.Update(1_099); Assert.That(port.SentCommands.Count(command => command == "91"), Is.EqualTo(initialQueries)); controller.Update(1_100); Assert.That(port.SentCommands.Count(command => command == "91"), Is.EqualTo(initialQueries + 1)); } [Test] public void Dispense_IsBlockedWhenPowerChannelCommandIsUnknown() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port); (bool Success, int Count)? result = null; controller.DispenseFinished += (success, count, _) => result = (success, count); controller.DispenseTokens(5); Assert.That(result, Is.EqualTo((false, 0))); Assert.That(port.SentCommands, Does.Not.Contain("3095")); } [Test] public void Payment_IsBlockedWhenDispenseCommandIsUnknown() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port); MachineFault? fault = null; controller.FaultOccurred += value => fault = value; int commandCount = port.SentCommands.Count; controller.BeginCardPayment(100); using (Assert.EnterMultipleScope()) { Assert.That(fault, Is.EqualTo(MachineFault.Unavailable)); Assert.That(port.SentCommands.Skip(commandCount), Does.Not.Contain("163100")); } } [Test] public void ProtocolRangeValidation_DoesNotLeavePaymentOrDispenseActive() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); var cardResults = new List(); var dispenseResults = new List<(bool Success, int Count, string? Error)>(); controller.CardPaymentFinished += cardResults.Add; controller.DispenseFinished += (success, count, error) => dispenseResults.Add((success, count, error)); int commandCount = port.SentCommands.Count; controller.BeginCardPayment(10_000); controller.DispenseTokens(0); controller.DispenseTokens(1); using (Assert.EnterMultipleScope()) { Assert.That(cardResults, Is.EqualTo(new[] { MachineCardResult.Declined })); Assert.That(dispenseResults, Does.Contain((false, 0, "DISPENSE_COUNT_OUT_OF_RANGE"))); Assert.That(port.SentCommands.Skip(commandCount), Does.Not.Contain("161")); Assert.That(port.SentCommands.Skip(commandCount), Does.Contain("3091")); } } [Test] public void LargeDispense_IsSplitIntoConfirmedChunksOfAtMostNinetyNine() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); var results = new List<(bool Success, int Count, string? Error)>(); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); controller.DispenseTokens(120); for (int index = 0; index < 98; index++) { port.RaiseData("HOPPER1 OK"); } using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "30999"), Is.EqualTo(1)); Assert.That(port.SentCommands, Does.Not.Contain("30921")); Assert.That(results, Is.Empty); } port.RaiseData("HOPPER1 OK"); Assert.That(port.SentCommands.Count(command => command == "30921"), Is.EqualTo(1)); for (int index = 0; index < 21; index++) { port.RaiseData("HOPPER1 OK"); } Assert.That(results, Is.EqualTo(new[] { (true, 120, (string?)null) })); } [Test] public void LargeDispenseFailure_ReportsConfirmedTotalAcrossChunks() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); (bool Success, int Count, string? Error)? result = null; controller.DispenseFinished += (success, count, error) => result = (success, count, error); controller.DispenseTokens(120); for (int index = 0; index < 100; index++) { port.RaiseData("HOPPER1 OK"); } port.RaiseData("HOPPER1 ERROR"); port.RaiseData("HOPPER1 CREDIT=20"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "30999"), Is.EqualTo(1)); Assert.That(port.SentCommands.Count(command => command == "30921"), Is.EqualTo(1)); Assert.That(result, Is.EqualTo((false, 100, "HOPPER1_ERROR"))); } } [Test] public void Hopper1Auto_CompletesOnlyAfterAllOkConfirmations() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); var results = new List<(bool Success, int Count)>(); var progress = new List<(int Confirmed, int Requested)>(); controller.DispenseFinished += (success, count, _) => results.Add((success, count)); controller.DispenseProgressChanged += (confirmed, requested) => progress.Add((confirmed, requested)); controller.DispenseTokens(2); port.RaiseData("HOPPER1 OK"); port.RaiseData("HOPPER1 OK"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands, Does.Contain("3092")); Assert.That(results, Is.EqualTo(new[] { (true, 2) })); Assert.That(progress, Is.EqualTo(new[] { (1, 2), (2, 2) })); Assert.That(controller.StockLevel, Is.EqualTo(MachineStockLevel.Unknown)); } } [Test] public void Hopper1Dispense_UsesOkFramesAndDoesNotDoubleCountInputEdges() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, hopper1DispenseSensorInputIndex: PeripheryControllerInputs.Hopper1DispensePulse, hopper1DispenseSensorActiveHigh: false); var results = new List<(bool Success, int Count)>(); controller.DispenseFinished += (success, count, _) => results.Add((success, count)); string inactiveMask = (1 << PeripheryControllerInputs.Hopper1DispensePulse).ToString(); port.RaiseData("INPUT" + inactiveMask); controller.DispenseTokens(2); port.RaiseData("HOPPER1 OK"); port.RaiseData("INPUT0"); port.RaiseData("INPUT0"); port.RaiseData("INPUT" + inactiveMask); port.RaiseData("INPUT0"); Assert.That(results, Is.Empty); port.RaiseData("HOPPER1 OK"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "3092"), Is.EqualTo(1)); Assert.That(results, Is.EqualTo(new[] { (true, 2) })); } } [Test] public void Hopper2Auto_UsesConfirmed40XProtocol() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper2Auto, hopper2DispenseSensorInputIndex: PeripheryControllerInputs.Hopper2DispensePulse, hopper2DispenseSensorActiveHigh: false); var results = new List<(bool Success, int Count, string? Error)>(); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); controller.DispenseTokens(3); port.RaiseData("HOPPER2 OK"); port.RaiseData("HOPPER2 OK"); port.RaiseData("HOPPER2 OK"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands, Does.Contain("4093")); Assert.That(port.SentCommands, Does.Not.Contain("3093")); Assert.That(results, Is.EqualTo(new[] { (true, 3, (string?)null) })); } } [Test] public void Hopper1Auto_IgnoresAllSecondHopperFeedback() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); var results = new List<(bool Success, int Count, string? Error)>(); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); controller.DispenseTokens(1); port.RaiseData("HOPPER2 APPEND=1"); port.RaiseData("HOPPER2 PULSE=1"); port.RaiseData("HOPPER2 CREDIT=1"); port.RaiseData("HOPPER2 ERROR"); Assert.That(results, Is.Empty); port.RaiseData("HOPPER1 OK"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "3091"), Is.EqualTo(1)); Assert.That(results, Is.EqualTo(new[] { (true, 1, (string?)null) })); } } [Test] public void Hopper1Dispense_DoesNotRequireInitialInputSnapshot() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, hopper1DispenseSensorInputIndex: PeripheryControllerInputs.Hopper1DispensePulse, hopper1DispenseSensorActiveHigh: false); (bool Success, int Count, string? Error)? result = null; controller.DispenseFinished += (success, count, error) => result = (success, count, error); controller.DispenseTokens(1); using (Assert.EnterMultipleScope()) { Assert.That(result, Is.Null); Assert.That(port.SentCommands, Does.Contain("3091")); } port.RaiseData("HOPPER1 OK"); Assert.That(result, Is.EqualTo((true, 1, (string?)null))); } [Test] public void HopperError_UsesCreditAndAllowsNextDispenseWithoutRestart() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); var results = new List<(bool Success, int Count, string? Error)>(); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); controller.DispenseTokens(2); controller.DispenseTokens(2); port.RaiseData("HOPPER1 ERROR"); controller.DispenseTokens(2); Assert.That(results, Is.Empty); port.RaiseData("HOPPER1 CREDIT=2"); controller.DispenseTokens(2); port.RaiseData("HOPPER1 OK"); port.RaiseData("HOPPER1 OK"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "3092"), Is.EqualTo(2)); Assert.That(results, Is.EqualTo(new[] { (false, 0, "HOPPER1_ERROR"), (true, 2, (string?)null), })); } } [Test] public void HopperError_WithoutCreditFinishesAfterThreeSecondsWithConfirmedCount() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); (bool Success, int Count, string? Error)? result = null; controller.DispenseFinished += (success, count, error) => result = (success, count, error); controller.DispenseTokens(3); port.RaiseData("HOPPER1 OK"); port.RaiseData("HOPPER1 ERROR"); controller.Update(4_999); Assert.That(result, Is.Null); controller.Update(5_000); Assert.That(result, Is.EqualTo((false, 1, "HOPPER1_CREDIT_TIMEOUT"))); } [Test] public void UnexpectedHopperErrorOutsideDispense_IsIgnored() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.Hopper1Auto); int resultCount = 0; controller.DispenseFinished += (_, _, _) => resultCount++; port.RaiseData("HOPPER1 ERROR"); Assert.That(resultCount, Is.Zero); } [Test] public void ConfiguredDigitalInput_ReportsOnlyEnoughOrLow() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, stockSensorInputIndex: 1, stockLowWhenInputHigh: false); var levels = new List(); controller.StockChanged += levels.Add; port.RaiseData("INPUT 67108863"); port.RaiseData("INPUT 67108861"); port.RaiseConnection(false); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands, Does.Contain("91")); Assert.That(levels, Is.EqualTo(new[] { MachineStockLevel.Enough, MachineStockLevel.Low, MachineStockLevel.Unknown, })); } } [Test] public void Hopper1StockInput_ActiveLowIn1MeansLowForConfirmedBoardMapping() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, stockSensorInputIndex: PeripheryControllerInputs.Hopper1StockLow, stockLowWhenInputHigh: false); var levels = new List(); controller.StockChanged += levels.Add; port.RaiseData("INPUT67108863"); port.RaiseData("INPUT67108861"); port.RaiseData("INPUT67108861"); port.RaiseData("INPUT67108863"); using (Assert.EnterMultipleScope()) { Assert.That(levels, Is.EqualTo(new[] { MachineStockLevel.Enough, MachineStockLevel.Low, MachineStockLevel.Enough, })); Assert.That(controller.StockLevel, Is.EqualTo(MachineStockLevel.Enough)); } } [Test] public void ConfiguredStockSensor_IsPolledPeriodically() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, stockSensorInputIndex: 1); int initialQueries = port.SentCommands.Count(command => command == "91"); controller.Update(1_000); controller.Update(5_999); controller.Update(6_000); Assert.That(port.SentCommands.Count(command => command == "91"), Is.EqualTo(initialQueries + 1)); } [Test] public void DisconnectDuringDispense_ProducesOneUnknownOutcomeInSessionFlow() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, stockSensorInputIndex: 1, stockLowWhenInputHigh: false); using var flow = new SessionFlow(TestSettings.Create(), controller, new ManualClock(), new SequenceSessionIdGenerator()); var outcomes = new List(); flow.OperationFinished += snapshot => outcomes.Add(snapshot.Outcome); port.RaiseData("INPUT 67108863"); flow.BeginCard(); flow.SelectCardAmount(100); port.RaiseData("MONEY:CASHLESS:100"); Assert.That(flow.State, Is.EqualTo(SessionState.Dispensing)); port.RaiseConnection(false); using (Assert.EnterMultipleScope()) { Assert.That(flow.ErrorCode, Is.EqualTo(SessionErrorCode.DispenseStatusUnknown)); Assert.That(outcomes, Is.EqualTo(new[] { OperationOutcome.DispenseStatusUnknown })); } } [Test] public void Hopper1TokenRecount_CountsFirmwarePulseFramesAndStopsAfterSilence() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto); var progress = new List(); var results = new List<(bool Success, int Count, string? Error)>(); controller.TokenRecountProgressChanged += progress.Add; controller.TokenRecountFinished += (success, count, error) => results.Add((success, count, error)); Assert.That(controller.TryStartTokenRecount(2_000), Is.True); port.RaiseData("HOPPER1 OK"); port.RaiseData("HOPPER1 PULSE=1"); port.RaiseData("HOPPER1 PULSE=1"); controller.Update(11_999); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "301"), Is.EqualTo(1)); Assert.That(port.SentCommands, Does.Not.Contain("300")); Assert.That(progress, Is.EqualTo(new[] { 0, 1, 2 })); Assert.That(results, Is.Empty); } controller.Update(12_000); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "300"), Is.EqualTo(1)); Assert.That(results, Is.EqualTo(new[] { (true, 2, (string?)null) })); Assert.That(controller.IsTokenRecountInProgress, Is.False); } } [Test] public void TokenRecount_UsesFirstHopperAndCancellationStopsMotor() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.Hopper1Auto, hopper1DispenseSensorInputIndex: PeripheryControllerInputs.Hopper1DispensePulse, hopper1DispenseSensorActiveHigh: false, hopper2DispenseSensorInputIndex: PeripheryControllerInputs.Hopper2DispensePulse, hopper2DispenseSensorActiveHigh: false); var results = new List<(bool Success, int Count, string? Error)>(); controller.TokenRecountFinished += (success, count, error) => results.Add((success, count, error)); Assert.That(controller.TryStartTokenRecount(500), Is.True); controller.CancelTokenRecount(); controller.CancelTokenRecount(); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count(command => command == "301"), Is.EqualTo(1)); Assert.That(port.SentCommands.Count(command => command == "300"), Is.EqualTo(1)); Assert.That(port.SentCommands, Does.Not.Contain("401")); Assert.That(results, Is.EqualTo(new[] { (false, 0, "TOKEN_RECOUNT_CANCELLED") })); } } [Test] public void DualHopperDispense_AccountsConfirmedTokensPerHopper() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); controller.SetHopperInventory(1, 2); var accounted = new List<(int Hopper1, int Hopper2)>(); var results = new List<(bool Success, int Count)>(); controller.HopperDispenseAccounted += (hopper1, hopper2) => accounted.Add((hopper1, hopper2)); controller.DispenseFinished += (success, count, _) => results.Add((success, count)); controller.DispenseTokens(3); port.RaiseData("HOPPER1 OK"); port.RaiseData("HOPPER2 OK"); port.RaiseData("HOPPER2 OK"); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands, Does.Contain("3091")); Assert.That(port.SentCommands, Does.Contain("4092")); Assert.That(accounted, Is.EqualTo(new[] { (1, 2) })); Assert.That(results, Is.EqualTo(new[] { (true, 3) })); } } [Test] public void DualHopperDispense_ExhaustsFirstHopperBeforeUsingSecond() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); controller.SetHopperInventory(6, 20); var accounted = new List<(int Hopper1, int Hopper2)>(); var results = new List<(bool Success, int Count, string? Error)>(); controller.HopperDispenseAccounted += (hopper1, hopper2) => accounted.Add((hopper1, hopper2)); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); controller.DispenseTokens(10); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands[^1], Is.EqualTo("3096")); Assert.That(port.SentCommands, Does.Not.Contain("4094")); } for (int index = 0; index < 6; index++) { port.RaiseData("HOPPER1 OK"); } Assert.That(port.SentCommands[^1], Is.EqualTo("4094")); for (int index = 0; index < 4; index++) { port.RaiseData("HOPPER2 OK"); } using (Assert.EnterMultipleScope()) { Assert.That(accounted, Is.EqualTo(new[] { (6, 4) })); Assert.That(results, Is.EqualTo(new[] { (true, 10, (string?)null) })); } } [Test] public void DualHopperDispense_RejectsRequestWhenCombinedInventoryIsInsufficient() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); controller.SetHopperInventory(3, 6); var results = new List<(bool Success, int Count, string? Error)>(); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); int commandsBeforeDispense = port.SentCommands.Count; controller.DispenseTokens(10); using (Assert.EnterMultipleScope()) { Assert.That(port.SentCommands.Count, Is.EqualTo(commandsBeforeDispense)); Assert.That(results, Is.EqualTo(new[] { (false, 0, (string?)"INSUFFICIENT_ACCOUNTED_TOKENS"), })); } } [Test] public void DualHopperDispense_WithInventoryAccountingDisabled_IgnoresStoredCountAndChunksHopperOne() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); controller.SetHopperInventory(0, 0); controller.SetInventoryAccountingEnabled(false); var results = new List<(bool Success, int Count, string? Error)>(); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); int commandsBeforeDispense = port.SentCommands.Count; controller.DispenseTokens(120); for (int index = 0; index < 99; index++) { port.RaiseData("HOPPER1 OK"); } Assert.That( port.SentCommands .Skip(commandsBeforeDispense) .Where(command => command.StartsWith("309", StringComparison.Ordinal) || command.StartsWith("409", StringComparison.Ordinal)), Is.EqualTo(new[] { "30999", "30921" })); for (int index = 0; index < 21; index++) { port.RaiseData("HOPPER1 OK"); } using (Assert.EnterMultipleScope()) { Assert.That(results, Is.EqualTo(new[] { (true, 120, (string?)null) })); Assert.That(port.SentCommands, Does.Not.Contain("40921")); } } [Test] public void DualHopperDispense_WithPurchaseLimitDisabled_TracksKnownFirstHopperAndAllowsRemainder() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); controller.SetHopperInventory(3, 0); controller.SetPurchaseLimitEnabled(false); var accounted = new List<(int Hopper1, int Hopper2)>(); var results = new List<(bool Success, int Count, string? Error)>(); controller.HopperDispenseAccounted += (hopper1, hopper2) => accounted.Add((hopper1, hopper2)); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); controller.DispenseTokens(5); for (int index = 0; index < 3; index++) { port.RaiseData("HOPPER1 OK"); } Assert.That(port.SentCommands[^1], Is.EqualTo("4092")); for (int index = 0; index < 2; index++) { port.RaiseData("HOPPER2 OK"); } using (Assert.EnterMultipleScope()) { Assert.That(accounted, Is.EqualTo(new[] { (3, 2) })); Assert.That(results, Is.EqualTo(new[] { (true, 5, (string?)null) })); } } [TestCase(205, 0, "HOPPER1", "309")] [TestCase(0, 205, "HOPPER2", "409")] public void DualHopperLargeDispense_SendsOneConfirmedChunkAtATime( int hopper1Inventory, int hopper2Inventory, string responsePrefix, string commandPrefix) { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); controller.SetHopperInventory(hopper1Inventory, hopper2Inventory); var accounted = new List<(int Hopper1, int Hopper2)>(); var results = new List<(bool Success, int Count, string? Error)>(); controller.HopperDispenseAccounted += (hopper1, hopper2) => accounted.Add((hopper1, hopper2)); controller.DispenseFinished += (success, count, error) => results.Add((success, count, error)); int commandsBeforeDispense = port.SentCommands.Count; controller.DispenseTokens(205); port.RaiseData($"{responsePrefix} APPEND=99"); for (int index = 0; index < 98; index++) { port.RaiseData($"{responsePrefix} OK"); } Assert.That( port.SentCommands .Skip(commandsBeforeDispense) .Where(command => command.StartsWith("309", StringComparison.Ordinal) || command.StartsWith("409", StringComparison.Ordinal)), Is.EqualTo(new[] { $"{commandPrefix}99" }), "Следующая пачка не должна отправляться до последнего OK текущей пачки."); port.RaiseData($"{responsePrefix} OK"); Assert.That( port.SentCommands .Skip(commandsBeforeDispense) .Where(command => command.StartsWith("309", StringComparison.Ordinal) || command.StartsWith("409", StringComparison.Ordinal)), Is.EqualTo(new[] { $"{commandPrefix}99", $"{commandPrefix}99" })); port.RaiseData($"{responsePrefix} APPEND=99"); for (int index = 0; index < 99; index++) { port.RaiseData($"{responsePrefix} OK"); } Assert.That( port.SentCommands .Skip(commandsBeforeDispense) .Where(command => command.StartsWith("309", StringComparison.Ordinal) || command.StartsWith("409", StringComparison.Ordinal)), Is.EqualTo(new[] { $"{commandPrefix}99", $"{commandPrefix}99", $"{commandPrefix}7" })); port.RaiseData($"{responsePrefix} APPEND=7"); for (int index = 0; index < 7; index++) { port.RaiseData($"{responsePrefix} OK"); } using (Assert.EnterMultipleScope()) { Assert.That(accounted, Is.EqualTo(new[] { (hopper1Inventory, hopper2Inventory) })); Assert.That(results, Is.EqualTo(new[] { (true, 205, (string?)null) })); } } [Test] public void DualHopperLargeSplit_CompletesAllFirstHopperChunksBeforeSecondHopper() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); controller.SetHopperInventory(150, 50); var accounted = new List<(int Hopper1, int Hopper2)>(); controller.HopperDispenseAccounted += (hopper1, hopper2) => accounted.Add((hopper1, hopper2)); int commandsBeforeDispense = port.SentCommands.Count; controller.DispenseTokens(180); for (int index = 0; index < 99; index++) { port.RaiseData("HOPPER1 OK"); } for (int index = 0; index < 50; index++) { port.RaiseData("HOPPER1 OK"); } Assert.That( port.SentCommands .Skip(commandsBeforeDispense) .Where(command => command.StartsWith("309", StringComparison.Ordinal) || command.StartsWith("409", StringComparison.Ordinal)), Is.EqualTo(new[] { "30999", "30951" }), "Второй хоппер не должен запускаться до последнего OK второго пакета первого хоппера."); port.RaiseData("HOPPER1 OK"); Assert.That(port.SentCommands[^1], Is.EqualTo("40930")); for (int index = 0; index < 30; index++) { port.RaiseData("HOPPER2 OK"); } Assert.That(accounted, Is.EqualTo(new[] { (150, 30) })); } [Test] public void DualHopperRecount_UsesSecondHopperMotorAndPulseFrames() { var port = new FakeHardwarePort(); using var controller = CreateReadyController(port, PeripheryDispenseMode.DualHopperAuto); var progress = new List<(HopperChannel Hopper, int Count)>(); var results = new List<(HopperChannel Hopper, bool Success, int Count)>(); controller.HopperTokenRecountProgressChanged += (hopper, count) => progress.Add((hopper, count)); controller.HopperTokenRecountFinished += (hopper, success, count, _) => results.Add((hopper, success, count)); bool started = controller.TryStartTokenRecount(HopperChannel.Hopper2, 500); port.RaiseData("HOPPER2 PULSE=1"); port.RaiseData("HOPPER2 PULSE=1"); controller.Update(10_500); using (Assert.EnterMultipleScope()) { Assert.That(started, Is.True); Assert.That(port.SentCommands, Does.Contain("401")); Assert.That(port.SentCommands, Does.Contain("400")); Assert.That(progress, Is.EqualTo(new[] { (HopperChannel.Hopper2, 0), (HopperChannel.Hopper2, 1), (HopperChannel.Hopper2, 2), })); Assert.That(results, Is.EqualTo(new[] { (HopperChannel.Hopper2, true, 2) })); } } [Test] public void DualStockSensors_ReportIn1AndIn4Separately() { var port = new FakeHardwarePort(); using var controller = CreateReadyController( port, PeripheryDispenseMode.DualHopperAuto, stockSensorInputIndex: PeripheryControllerInputs.Hopper1StockLow, stockLowWhenInputHigh: false, hopper2StockSensorInputIndex: PeripheryControllerInputs.Hopper2StockLow, hopper2StockLowWhenInputHigh: false); var levels = new List<(HopperChannel Hopper, MachineStockLevel Level)>(); controller.HopperStockChanged += (hopper, level) => levels.Add((hopper, level)); port.RaiseData($"INPUT{(1 << 1) | (1 << 4)}"); port.RaiseData($"INPUT{1 << 1}"); using (Assert.EnterMultipleScope()) { Assert.That(levels, Does.Contain((HopperChannel.Hopper1, MachineStockLevel.Enough))); Assert.That(levels, Does.Contain((HopperChannel.Hopper2, MachineStockLevel.Enough))); Assert.That(levels, Does.Contain((HopperChannel.Hopper2, MachineStockLevel.Low))); } } private static PeripheryMachineController CreateReadyController( FakeHardwarePort port, PeripheryDispenseMode dispenseMode = PeripheryDispenseMode.Unsupported, int keepAliveSeconds = 120, int stockSensorInputIndex = -1, bool stockLowWhenInputHigh = false, int serviceButtonInputIndex = -1, bool serviceButtonActiveHigh = true, int hopper1DispenseSensorInputIndex = -1, bool hopper1DispenseSensorActiveHigh = false, int hopper2DispenseSensorInputIndex = -1, bool hopper2DispenseSensorActiveHigh = false, int inputPollMilliseconds = 100, int hopper2StockSensorInputIndex = -1, bool hopper2StockLowWhenInputHigh = false) { var authenticator = new ControllerAuthenticator(new IdentityBlockCipher(), () => HostChallenge); var controller = new PeripheryMachineController( port, authenticator, dispenseMode, keepAliveSeconds, stockSensorInputIndex, stockLowWhenInputHigh, serviceButtonInputIndex: serviceButtonInputIndex, serviceButtonActiveHigh: serviceButtonActiveHigh, hopper1DispenseSensorInputIndex: hopper1DispenseSensorInputIndex, hopper1DispenseSensorActiveHigh: hopper1DispenseSensorActiveHigh, hopper2DispenseSensorInputIndex: hopper2DispenseSensorInputIndex, hopper2DispenseSensorActiveHigh: hopper2DispenseSensorActiveHigh, inputPollMilliseconds: inputPollMilliseconds, hopper2StockSensorInputIndex: hopper2StockSensorInputIndex, hopper2StockLowWhenInputHigh: hopper2StockLowWhenInputHigh); port.RaiseConnection(true); port.RaiseData("periphery controller ver_test"); controller.Update(0); controller.Update(2_000); port.RaiseData("CHALFFEEDDCCBBAA99887766554433221100"); port.RaiseData("RAES00112233445566778899AABBCCDDEEFF"); port.RaiseData("READY!"); port.RaiseData("AUTH_CNT_OK"); Assert.That(controller.IsReady, Is.True); return controller; } }