From e4b42b5b513ad2ae9f2c6b3b217f5b44817bbb3a Mon Sep 17 00:00:00 2001 From: Michal Buzik Date: Wed, 2 Sep 2026 15:40:34 +0200 Subject: [PATCH] fix cli poseidon - take values in cycles + tests --- .../helpers/DatabaseMigrationHelper.cs | 158 ++++++++++++++++++ TBF/Rig/DataEntry/PoseidonCmd/EntryForm.cs | 79 +++++---- .../DataEntry/PoseidonCmd/TestStartEndForm.cs | 4 + .../PoseidonCmdStartStop/PoseidonReadCycle.cs | 39 +++++ .../PoseidonCmdStartStop/PoseidonReader.cs | 53 +++++- .../PoseidonCustomerCliResponseTest.cs | 62 +++++++ .../PoseidonDialogTransferTest.cs | 145 ++++++++++++++++ .../PoseidonReadCycleTest.cs | 134 ++++++++++++++- TBFTests/TBFTests.csproj | 2 + 9 files changed, 629 insertions(+), 47 deletions(-) create mode 100644 Results/Entities/helpers/DatabaseMigrationHelper.cs create mode 100644 TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonCustomerCliResponseTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonDialogTransferTest.cs diff --git a/Results/Entities/helpers/DatabaseMigrationHelper.cs b/Results/Entities/helpers/DatabaseMigrationHelper.cs new file mode 100644 index 000000000..21f7d5ac5 --- /dev/null +++ b/Results/Entities/helpers/DatabaseMigrationHelper.cs @@ -0,0 +1,158 @@ +using System; +using Common; +using log4net; +using MySql.Data.MySqlClient; + +namespace Results.Entities.helpers +{ + /// + /// Applies explicit, backwards-compatible results DB migrations before the + /// first NHibernate session factory is created. + /// + public static class DatabaseMigrationHelper + { + private static readonly ILog log = LogManager.GetLogger(typeof(DatabaseMigrationHelper)); + + public static void EnsureSchema(DBType dbType, string connectionString) + { + switch (dbType) + { + case DBType.MySql: + EnsureMySqlSchema(connectionString); + break; + case DBType.SQLite: + EnsureSQLiteSchema(connectionString); + break; + } + } + + private static void EnsureMySqlSchema(string connectionString) + { + using (var conn = new MySqlConnection(connectionString)) + { + conn.Open(); + + // Added by newer MeterTestRslt mapping; older customer DBs do + // not contain it and otherwise reject the entire batch insert. + EnsureColumnMySql(conn, "MeterTestRslt", "PulsesPerKilogram", "DOUBLE NOT NULL DEFAULT 0"); + EnsureColumnMySql(conn, "MeterTestRslt", "FlipMode", "INT NULL"); + EnsureColumnMySql(conn, "MeterTestRslt", "ExtraDataPath", "VARCHAR(255) NULL"); + EnsureMeterTestResultExtraColumnsMySql(conn); + } + } + + private static void EnsureMeterTestResultExtraColumnsMySql(MySqlConnection conn) + { + for (int index = 1; index <= 9; index++) + { + EnsureColumnMySql(conn, "MeterTestRslt", "X" + index, "FLOAT NOT NULL DEFAULT 0"); + } + } + + private static void EnsureColumnMySql( + MySqlConnection conn, + string tableName, + string columnName, + string columnDefinition) + { + using (var transaction = conn.BeginTransaction()) + { + try + { + bool exists; + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = transaction; + cmd.CommandText = @" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @tableName + AND COLUMN_NAME = @columnName"; + cmd.Parameters.AddWithValue("@tableName", tableName); + cmd.Parameters.AddWithValue("@columnName", columnName); + exists = Convert.ToInt32(cmd.ExecuteScalar()) > 0; + } + + if (!exists) + { + using (var alter = conn.CreateCommand()) + { + alter.Transaction = transaction; + alter.CommandText = "ALTER TABLE `" + tableName + "` ADD COLUMN `" + + columnName + "` " + columnDefinition; + alter.ExecuteNonQuery(); + log.WarnFormat("Results DB migration: added {0}.{1} ({2}).", + tableName, columnName, columnDefinition); + } + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + + private static void EnsureSQLiteSchema(string databaseFile) + { + using (var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + databaseFile)) + { + conn.Open(); + + EnsureColumnSQLite(conn, "MeterTestRslt", "PulsesPerKilogram", "REAL NOT NULL DEFAULT 0"); + EnsureColumnSQLite(conn, "MeterTestRslt", "FlipMode", "INTEGER NULL"); + EnsureColumnSQLite(conn, "MeterTestRslt", "ExtraDataPath", "TEXT NULL"); + EnsureMeterTestResultExtraColumnsSQLite(conn); + } + } + + private static void EnsureMeterTestResultExtraColumnsSQLite(System.Data.SQLite.SQLiteConnection conn) + { + for (int index = 1; index <= 9; index++) + { + EnsureColumnSQLite(conn, "MeterTestRslt", "X" + index, "REAL NOT NULL DEFAULT 0"); + } + } + + private static void EnsureColumnSQLite( + System.Data.SQLite.SQLiteConnection conn, + string tableName, + string columnName, + string columnDefinition) + { + bool exists = false; + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "PRAGMA table_info(" + tableName + ")"; + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + if (string.Equals(reader["name"].ToString(), columnName, + StringComparison.OrdinalIgnoreCase)) + { + exists = true; + break; + } + } + } + } + + if (!exists) + { + using (var alter = conn.CreateCommand()) + { + alter.CommandText = "ALTER TABLE " + tableName + " ADD COLUMN " + + columnName + " " + columnDefinition; + alter.ExecuteNonQuery(); + log.WarnFormat("Results DB migration: added {0}.{1} ({2}).", + tableName, columnName, columnDefinition); + } + } + } + } +} diff --git a/TBF/Rig/DataEntry/PoseidonCmd/EntryForm.cs b/TBF/Rig/DataEntry/PoseidonCmd/EntryForm.cs index 9dc95d57d..52e3499e2 100644 --- a/TBF/Rig/DataEntry/PoseidonCmd/EntryForm.cs +++ b/TBF/Rig/DataEntry/PoseidonCmd/EntryForm.cs @@ -268,10 +268,16 @@ namespace TBF.Rig.DataEntry.PoseidonCmd { dlg.WMStartState[item] = poseidonReader.BeginWMState; dlg.WMStartStateStr[item] = poseidonReader.BeginWMState.ToString(); + log.InfoFormat("Poseidon dialog prefill: phase=Start, WM{0}, reader={1}, value={2}", + item + 1, poseidonReader.Name, dlg.WMStartState[item]); } if (currentOp == CurrentOp.ReadDatastream_EndStates) + { dlg.WMEndState[item] = poseidonReader.EndWMState; + log.InfoFormat("Poseidon dialog prefill: phase=End, WM{0}, reader={1}, value={2}", + item + 1, poseidonReader.Name, dlg.WMEndState[item]); + } } } @@ -326,31 +332,10 @@ namespace TBF.Rig.DataEntry.PoseidonCmd } } } - else if (modelessDlg is TestStartEndForm && currentOp == CurrentOp.ReadDatastream_StartStates) + else if (modelessDlg is TestStartEndForm) { - /// Fixed start test - start - TestStartEndForm dlg = (modelessDlg as TestStartEndForm); - if (dlg != null) - { - for (int i = 0; i < dlg.WaterMetersCount; i++) - { - wmStartState[i] = dlg.WMStartState[i]; - wmStartStateStr[i] = dlg.WMStartStateStr[i]; - } - } - } - else if (modelessDlg is TestStartEndForm && currentOp == CurrentOp.ReadDatastream_EndStates) - { - /// Fixed start test - end - TestStartEndForm dlg = (modelessDlg as TestStartEndForm); - if (dlg != null) - { - for (int i = 0; i < dlg.WaterMetersCount; i++) - { - wmEndState[i] = dlg.WMEndState[i]; - } - } - } + StoreAcceptedDialogValues((TestStartEndForm)modelessDlg); + } resultSaved = true; modelessDlg = null; } @@ -358,6 +343,36 @@ namespace TBF.Rig.DataEntry.PoseidonCmd return Event.ModelessFormClosed; /// Form closed } + /// + /// Copies values confirmed by the Poseidon start/end dialog into the + /// data-entry state used later by the result calculation. + /// + private void StoreAcceptedDialogValues(TestStartEndForm dlg) + { + if (dlg == null) + return; + + if (currentOp == CurrentOp.ReadDatastream_StartStates) + { + for (int i = 0; i < dlg.WaterMetersCount; i++) + { + wmStartState[i] = dlg.WMStartState[i]; + wmStartStateStr[i] = dlg.WMStartStateStr[i]; + log.InfoFormat("Poseidon dialog accepted: phase=Start, WM{0}, text='{1}', value={2}", + i + 1, wmStartStateStr[i], wmStartState[i]); + } + } + else if (currentOp == CurrentOp.ReadDatastream_EndStates) + { + for (int i = 0; i < dlg.WaterMetersCount; i++) + { + wmEndState[i] = dlg.WMEndState[i]; + log.InfoFormat("Poseidon dialog accepted: phase=End, WM{0}, value={1}", + i + 1, wmEndState[i]); + } + } + } + /// Stop this operation public void Stop() { @@ -447,23 +462,21 @@ namespace TBF.Rig.DataEntry.PoseidonCmd } } + var phaseRunner = new PoseidonReadPhaseRunner( + currentOp == CurrentOp.ReadDatastream_StartStates); while (!finishedReading) //TODO BUMI lock - fuck ? { - Dictionary operationsBefore = - new Dictionary(); foreach (IPoseidonReadOperation poseidonReader in poseidonReaders) { - operationsBefore.Add(poseidonReader, poseidonReader.IsFinished); - if (poseidonReader.IsNotStarted) - log.DebugFormat("Poseidon read: starting {0} for {1}", currentOp, poseidonReader.Name); + if (poseidonReader.IsNotStarted || poseidonReader.IsFinished) + log.DebugFormat("Poseidon read: arming {0} for {1}, previous state finished={2}", + currentOp, poseidonReader.Name, poseidonReader.IsFinished); } - bool bAllReadersFinished = PoseidonReadCycle.RunIteration( - poseidonReaders, - currentOp == CurrentOp.ReadDatastream_StartStates); + bool bAllReadersFinished = phaseRunner.RunIteration(poseidonReaders); foreach (IPoseidonReadOperation poseidonReader in poseidonReaders) { - if (poseidonReader.IsFinished && !operationsBefore[poseidonReader]) + if (poseidonReader.IsFinished) { if (poseidonReader.HasError) log.ErrorFormat("Poseidon read: {0} completed with Error during {1}", poseidonReader.Name, currentOp); diff --git a/TBF/Rig/DataEntry/PoseidonCmd/TestStartEndForm.cs b/TBF/Rig/DataEntry/PoseidonCmd/TestStartEndForm.cs index f2b2f465b..0162da360 100644 --- a/TBF/Rig/DataEntry/PoseidonCmd/TestStartEndForm.cs +++ b/TBF/Rig/DataEntry/PoseidonCmd/TestStartEndForm.cs @@ -354,6 +354,8 @@ namespace TBF.Rig.DataEntry.PoseidonCmd if (endTextBoxes[i].Visible && endTextBoxes[i].Enabled) { WMEndState[i] = Utils.ParseUDouble(endTextBoxes[i].Text); + log.InfoFormat("Poseidon dialog OK: phase=End, WM{0}, enteredText='{1}', parsedValue={2}", + i + 1, endTextBoxes[i].Text, WMEndState[i]); } } } @@ -365,6 +367,8 @@ namespace TBF.Rig.DataEntry.PoseidonCmd { WMStartStateStr[i] = startTextBoxes[i].Text; WMStartState[i] = Utils.ParseUDouble(startTextBoxes[i].Text); + log.InfoFormat("Poseidon dialog OK: phase=Start, WM{0}, enteredText='{1}', parsedValue={2}", + i + 1, startTextBoxes[i].Text, WMStartState[i]); } } } diff --git a/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycle.cs b/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycle.cs index 0c07a1fe6..89163642a 100644 --- a/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycle.cs +++ b/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycle.cs @@ -78,4 +78,43 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop return allReadersFinished; } } + + /// + /// Owns one logical dialog phase. A new phase must arm a reader even when + /// the preceding phase left it in Done/Error; within this phase it is armed + /// only once. + /// + public sealed class PoseidonReadPhaseRunner + { + private readonly bool readStart; + private readonly HashSet startedReaders = + new HashSet(); + + public PoseidonReadPhaseRunner(bool readStart) + { + this.readStart = readStart; + } + + public bool RunIteration(IEnumerable readers) + { + if (readers == null) + return true; + + bool allReadersFinished = true; + foreach (IPoseidonReadOperation reader in readers) + { + if (reader == null) + continue; + + if (startedReaders.Add(reader)) + reader.Start(readStart); + + reader.Run(); + if (!reader.IsFinished) + allReadersFinished = false; + } + + return allReadersFinished; + } + } } diff --git a/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReader.cs b/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReader.cs index 99aab7f55..2f3238dcc 100644 --- a/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReader.cs +++ b/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReader.cs @@ -25,6 +25,9 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop public class PoseidonReader : ComponentBase, IDevice, IRegReaderDatastream, ISessionDataMngmnt, IOperation, ICommonRegReader { private static readonly ILog log = LogManager.GetLogger(typeof(PoseidonReader)); + // The simulator must never open the configured physical Hat CLI. The + // deterministic test CLI returns a valid Poseidon JSON response instead. + internal const string SimulatedCliFileName = "cmdSleepTest.exe"; public override string ToString() { return string.Format("{0}({1})", ClassName, Cfg.ToString(-1)); } readonly PoseidonCfg registerReaderCfg; @@ -281,8 +284,9 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop if ((DebugLevel == DebugMode.Normal)||(DebugLevel == DebugMode.Simulate)) { /// Prepare serial port + string cliFileName = GetCliFileNameForMode(DebugLevel, registerReaderCfg.CliFileName); serialPort = new SerialPortData(string.Format("COM{0}", registerReaderCfg.ComPortNr), - registerReaderCfg.CliFileName, + cliFileName, registerReaderCfg.MeterType, //registerReaderCfg.MeterType, //MeterProduct.Poseidon registerReaderCfg.HatType,//HatType.Mth registerReaderCfg.TimeOut, //timeoutSeconds @@ -290,6 +294,13 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop ); + if (DebugLevel == DebugMode.Simulate) + { + log.WarnFormat( + "{0}: simulation mode enabled; overriding configured CLI '{1}' with '{2}'.", + Name, registerReaderCfg.CliFileName, serialPort.SerialPortCmdClientPath); + } + //TODO BUMI prepare serial port - for us do nothing //serialPort.Open(); //we can check file program if exists @@ -310,6 +321,13 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop } } + internal static string GetCliFileNameForMode(DebugMode debugMode, string configuredCliFileName) + { + return debugMode == DebugMode.Simulate + ? SimulatedCliFileName + : configuredCliFileName; + } + public void Clear() { log.DebugFormat("{0}:Clear()", Name); @@ -516,12 +534,14 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop } } - double volume; - if (TryParseCliReading(data.Reading, out volume)) + double volumeLi; + string dialogValueFailureReason; + if (TryGetDialogValue(data, out volumeLi, out dialogValueFailureReason)) { lastCliReadingParsed = true; lastCliReadSucceeded = true; - double volumeLi = Units.ConvertFrom(Unit.USgal, volume); + double volume; + TryParseCliReading(data.Reading, out volume); if (_isReadingStart) beginWMState = volumeLi; @@ -533,7 +553,7 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop } else { - lastCliReadFailureReason = "Reading could not be parsed: '" + data.Reading + "'."; + lastCliReadFailureReason = dialogValueFailureReason; log.ErrorFormat("{0}: cannot parse CLI reading '{1}' using invariant or current culture.", Name, data.Reading); } @@ -563,6 +583,29 @@ namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop return Double.TryParse(normalizedReading, NumberStyles.Float, CultureInfo.InvariantCulture, out value); } + /// + /// Converts a validated CLI response to the value written into the + /// start/end dialog. Poseidon reports US gallons; TBF stores litres. + /// + public static bool TryGetDialogValue(JsonDataFromPoseidon data, out double valueLitres, + out string failureReason) + { + valueLitres = 0; + if (!TryValidateCliReadResponse(data, out failureReason)) + return false; + + double valueGallons; + if (!TryParseCliReading(data.Reading, out valueGallons)) + { + failureReason = "Reading could not be parsed: '" + data.Reading + "'."; + return false; + } + + valueLitres = Units.ConvertFrom(Unit.USgal, valueGallons); + failureReason = null; + return true; + } + public static bool TryValidateCliReadResponse(JsonDataFromPoseidon data, out string failureReason) { if (data == null) diff --git a/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonCustomerCliResponseTest.cs b/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonCustomerCliResponseTest.cs new file mode 100644 index 000000000..559bd8327 --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonCustomerCliResponseTest.cs @@ -0,0 +1,62 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.PoseidonCmdStartStop; +using CmdPoseidonReader = TBF.Rig.RegisterReaders.PoseidonCmdStartStop.PoseidonReader; + +namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop +{ + /// + /// Regression fixtures extracted from PT50 customer CliRunner.txt (2026-09-01). + /// They are non-zero responses returned by HatCliDemo for COM43 and COM45. + /// + [TestClass] + public class PoseidonCustomerCliResponseTest + { + // The expected fixtures were calculated independently. A 0.1 ml + // tolerance covers insignificant IEEE-754 conversion differences while + // still detecting any meaningful incorrect transfer to the dialog. + private const double DialogValueToleranceLitres = 0.0001; + + private const string Com43InitialResponse = + "{\"Reading\":\"002780.99\",\"DeviceId\":\"1000000219\",\"ProductType\":74,\"ReadingComplete\":true,\"NfcTagDetected\":true,\"CliVersion\":\"2.0.0.2\"}"; + private const string Com45InitialResponse = + "{\"Reading\":\"002316.63\",\"DeviceId\":\"1000000279\",\"ProductType\":74,\"ReadingComplete\":true,\"NfcTagDetected\":true,\"CliVersion\":\"2.0.0.2\"}"; + private const string Com43LaterResponse = + "{\"Reading\":\"002898.60\",\"DeviceId\":\"1000000219\",\"ProductType\":74,\"ReadingComplete\":true,\"NfcTagDetected\":true,\"CliVersion\":\"2.0.0.2\"}"; + private const string Com45LaterResponse = + "{\"Reading\":\"002433.50\",\"DeviceId\":\"1000000279\",\"ProductType\":74,\"ReadingComplete\":true,\"NfcTagDetected\":true,\"CliVersion\":\"2.0.0.2\"}"; + + [TestMethod] + public void CustomerCliResponses_AreDeserializedAndConvertedToDialogValues() + { + // The first pair is a valid START dialog result; neither value is zero. + AssertDialogValue(Com43InitialResponse, "1000000219", "002780.99", 10527.1923171862); + AssertDialogValue(Com45InitialResponse, "1000000279", "002316.63", 8769.39850116792); + + // The later pair must be usable by a new START or END phase, rather + // than TBF retaining the initial START values or displaying zero. + AssertDialogValue(Com43LaterResponse, "1000000219", "002898.60", 10972.3945971024); + AssertDialogValue(Com45LaterResponse, "1000000279", "002433.50", 9211.799576364); + } + + private static void AssertDialogValue(string json, string expectedDeviceId, + string expectedReading, double expectedLitres) + { + var cliRunner = new CliRunner(false); + JsonDataFromPoseidon response; + + Assert.IsTrue(cliRunner.TryJsonStringDeserialize(json, out response)); + Assert.IsNotNull(response); + Assert.AreEqual(expectedDeviceId, response.DeviceId); + Assert.AreEqual(expectedReading, response.Reading); + Assert.IsTrue(response.NfcTagDetected == true); + Assert.IsTrue(response.ReadingComplete == true); + + double dialogValue; + string failureReason; + Assert.IsTrue(CmdPoseidonReader.TryGetDialogValue(response, out dialogValue, out failureReason), failureReason); + Assert.AreEqual(expectedLitres, dialogValue, DialogValueToleranceLitres, + "The value assigned to the START/END dialog must come from the CLI response."); + Assert.AreNotEqual(0d, dialogValue); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonDialogTransferTest.cs b/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonDialogTransferTest.cs new file mode 100644 index 000000000..a731808ec --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonDialogTransferTest.cs @@ -0,0 +1,145 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Windows.Forms; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.DataEntry.PoseidonCmd; +using TBF.Rig.RegisterReaders.PoseidonCmdStartStop; +using CmdPoseidonReader = TBF.Rig.RegisterReaders.PoseidonCmdStartStop.PoseidonReader; + +namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop +{ + [TestClass] + public class PoseidonDialogTransferTest + { + // Exact non-zero reading shape returned by HatCliDemo in the PT50 customer log. + private const string CustomerCliResponse = + "{\"Reading\":\"002433.50\",\"DeviceId\":\"1000000279\",\"ProductType\":74,\"ReadingComplete\":true,\"NfcTagDetected\":true,\"CliVersion\":\"2.0.0.2\"}"; + + [TestMethod] + public void CustomerCliValue_IsPrefilledConfirmedAndTransferred_ForStartAndEnd() + { + Exception failure = null; + var staThread = new Thread(() => + { + try + { + RunDialogTransferScenario(); + } + catch (Exception exception) + { + failure = exception; + } + }); + staThread.SetApartmentState(ApartmentState.STA); + staThread.Start(); + staThread.Join(TimeSpan.FromSeconds(15)); + + Assert.IsFalse(staThread.IsAlive, "The Poseidon dialog test did not finish."); + if (failure != null) + throw failure; + } + + private static void RunDialogTransferScenario() + { + var cliRunner = new CliRunner(false); + JsonDataFromPoseidon response; + Assert.IsTrue(cliRunner.TryJsonStringDeserialize(CustomerCliResponse, out response)); + + double cliValueLitres; + string failureReason; + Assert.IsTrue(CmdPoseidonReader.TryGetDialogValue(response, out cliValueLitres, out failureReason), failureReason); + Assert.AreNotEqual(0d, cliValueLitres); + + VerifyStartDialogTransfer(cliValueLitres); + VerifyEndDialogTransfer(cliValueLitres); + } + + private static void VerifyStartDialogTransfer(double expectedValue) + { + using (var dialog = new TestStartEndForm(1, null, new bool[1])) + { + dialog.CreateControl(); + dialog.WMStartState[0] = expectedValue; + dialog.WMStartStateStr[0] = expectedValue.ToString(); + dialog.UpdateValues(true, true); + + TextBox textBox = FindTextBox(dialog, "startTextBox1"); + Assert.AreEqual(dialog.WMStartStateStr[0], textBox.Text, "CLI value was not prefilled into the Start dialog."); + + ConfirmDialog(dialog); + Assert.IsTrue(dialog.Completed); + Assert.AreEqual(expectedValue, dialog.WMStartState[0], 0.000001, "Start dialog did not parse the confirmed value."); + + var entryForm = CreateEntryFormForTransfer(EntryForm.CurrentOp.ReadDatastream_StartStates); + TransferAcceptedDialogValues(entryForm, dialog); + Assert.AreEqual(expectedValue, entryForm.WMStartState(0), 0.000001, + "EntryForm did not receive the Start value confirmed in the dialog."); + } + } + + private static void VerifyEndDialogTransfer(double expectedValue) + { + using (var dialog = new TestStartEndForm(1, null, new[] { expectedValue.ToString() }, new bool[1], 0, 0, 0)) + { + dialog.CreateControl(); + dialog.WMEndState[0] = expectedValue; + dialog.UpdateValues(false, true); + + TextBox textBox = FindTextBox(dialog, "endTextBox1"); + Assert.AreEqual(expectedValue.ToString(), textBox.Text, "CLI value was not prefilled into the End dialog."); + + ConfirmDialog(dialog); + Assert.IsTrue(dialog.Completed); + Assert.AreEqual(expectedValue, dialog.WMEndState[0], 0.000001, "End dialog did not parse the confirmed value."); + + var entryForm = CreateEntryFormForTransfer(EntryForm.CurrentOp.ReadDatastream_EndStates); + TransferAcceptedDialogValues(entryForm, dialog); + Assert.AreEqual(expectedValue, entryForm.WMEndState(0), 0.000001, + "EntryForm did not receive the End value confirmed in the dialog."); + } + } + + private static TextBox FindTextBox(Control dialog, string name) + { + TextBox textBox = dialog.Controls.Find(name, true).OfType().FirstOrDefault(); + Assert.IsNotNull(textBox, "Expected dialog control was not found: " + name); + return textBox; + } + + private static void ConfirmDialog(TestStartEndForm dialog) + { + MethodInfo okHandler = typeof(TestStartEndForm).GetMethod("okButton_Click", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(okHandler); + okHandler.Invoke(dialog, new object[] { dialog, EventArgs.Empty }); + } + + private static EntryForm CreateEntryFormForTransfer(EntryForm.CurrentOp currentOp) + { + var entryForm = new EntryForm(); + SetPrivateField(entryForm, "currentOp", currentOp); + SetPrivateField(entryForm, "wmStartState", new double[1]); + SetPrivateField(entryForm, "wmStartStateStr", new string[1]); + SetPrivateField(entryForm, "wmEndState", new double[1]); + return entryForm; + } + + private static void TransferAcceptedDialogValues(EntryForm entryForm, TestStartEndForm dialog) + { + MethodInfo transferMethod = typeof(EntryForm).GetMethod("StoreAcceptedDialogValues", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(transferMethod); + transferMethod.Invoke(entryForm, new object[] { dialog }); + } + + private static void SetPrivateField(object instance, string name, object value) + { + FieldInfo field = instance.GetType().GetField(name, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, "Expected field was not found: " + name); + field.SetValue(instance, value); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycleTest.cs b/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycleTest.cs index 9aa1ae19b..06d6fa3eb 100644 --- a/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycleTest.cs +++ b/TBFTests/Rig/RegisterReaders/PoseidonCmdStartStop/PoseidonReadCycleTest.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using JetBrains.Annotations; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -13,6 +14,8 @@ namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop [TestSubject(typeof(PoseidonReadCycle))] public class PoseidonReadCycleTest { + public TestContext TestContext { get; set; } + private sealed class FakePoseidonReader : IPoseidonReadOperation { public string Name { get; private set; } @@ -80,16 +83,18 @@ namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop for (int i = 1; i <= 48; i++) readers.Add(new FakePoseidonReader("PoseidonPos" + i, 1 + (i % 3))); - Assert.IsFalse(PoseidonReadCycle.RunIteration(readers, true)); - Assert.IsFalse(PoseidonReadCycle.RunIteration(readers, true)); - Assert.IsFalse(PoseidonReadCycle.RunIteration(readers, true)); - Assert.IsTrue(PoseidonReadCycle.RunIteration(readers, true)); + TestContext.WriteLine("PoseidonReadCycle | scenario=FullBench | phase=Start | readers=48"); + Assert.IsFalse(RunAndLogCycleIteration("FullBench", 1, readers, true)); + Assert.IsFalse(RunAndLogCycleIteration("FullBench", 2, readers, true)); + Assert.IsFalse(RunAndLogCycleIteration("FullBench", 3, readers, true)); + Assert.IsTrue(RunAndLogCycleIteration("FullBench", 4, readers, true)); foreach (FakePoseidonReader reader in readers) { Assert.AreEqual(1, reader.StartCount, reader.Name + " must be armed exactly once."); Assert.AreEqual(CmdPoseidonReader.CurrentPoseidonOp.Done, reader.CurrentOp); } + LogReaderSummary("FullBench final", readers); } [TestMethod] @@ -101,27 +106,64 @@ namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop new FakePoseidonReader("PoseidonPos2", 3) }; - Assert.IsFalse(PoseidonReadCycle.RunIteration(readers, false)); + TestContext.WriteLine("PoseidonReadCycle | scenario=ReducedBench | phase=End | readers=2"); + Assert.IsFalse(RunAndLogCycleIteration("ReducedBench", 1, readers, false)); // Pos1 is now Done while Pos2 is still running. This is the state // which used to relaunch Pos1's CLI process over and over. - Assert.IsFalse(PoseidonReadCycle.RunIteration(readers, false)); + Assert.IsFalse(RunAndLogCycleIteration("ReducedBench", 2, readers, false)); Assert.AreEqual(1, ((FakePoseidonReader)readers[0]).StartCount); - Assert.IsFalse(PoseidonReadCycle.RunIteration(readers, false)); - Assert.IsTrue(PoseidonReadCycle.RunIteration(readers, false)); + Assert.IsFalse(RunAndLogCycleIteration("ReducedBench", 3, readers, false)); + Assert.IsTrue(RunAndLogCycleIteration("ReducedBench", 4, readers, false)); foreach (FakePoseidonReader reader in readers) { Assert.AreEqual(1, reader.StartCount, reader.Name + " must be armed exactly once."); } + LogReaderSummary("ReducedBench final", readers); + } + + [TestMethod] + public void StartThenEnd_PhasesRearmCompletedReadersExactlyOncePerPhase() + { + var reader = new FakePoseidonReader("PoseidonPos1", 1); + var readers = new List { reader }; + var startPhase = new PoseidonReadPhaseRunner(true); + + TestContext.WriteLine("PoseidonReadCycle | scenario=StartThenEnd | phase=Start | readers=1"); + Assert.IsFalse(RunAndLogPhaseIteration("StartThenEnd", "Start", 1, startPhase, readers)); + Assert.IsTrue(RunAndLogPhaseIteration("StartThenEnd", "Start", 2, startPhase, readers)); + Assert.AreEqual(1, reader.StartCount); + Assert.AreEqual(CmdPoseidonReader.CurrentPoseidonOp.Done, reader.CurrentOp); + + var endPhase = new PoseidonReadPhaseRunner(false); + TestContext.WriteLine("PoseidonReadCycle | scenario=StartThenEnd | phase=End | rearming reader completed by Start"); + Assert.IsFalse(RunAndLogPhaseIteration("StartThenEnd", "End", 1, endPhase, readers)); + Assert.IsTrue(RunAndLogPhaseIteration("StartThenEnd", "End", 2, endPhase, readers)); + + Assert.AreEqual(2, reader.StartCount, + "A completed START read must be rearmed once for the END phase."); + Assert.AreEqual(CmdPoseidonReader.CurrentPoseidonOp.Done, reader.CurrentOp); + LogReaderSummary("StartThenEnd final", readers); } [TestMethod] public void InputJson_ZeroReading_WithDecimalCommaOrDot_IsDeserializedAndParsedCorrectly() { + TestContext.WriteLine("Poseidon JSON parsing | verifies zero is retained for both decimal separators."); AssertInputJsonReading("00000,0", 0.0); AssertInputJsonReading("00000.0", 0.0); } + [TestMethod] + public void InputJson_NonZeroReading_WithDecimalCommaOrDot_IsDeserializedAndParsedCorrectly() + { + TestContext.WriteLine("Poseidon JSON parsing | verifies non-zero readings for both decimal separators."); + AssertInputJsonReading("002433,50", 2433.50); + AssertInputJsonReading("002433.50", 2433.50); + AssertInputJsonReading("002898,60", 2898.60); + AssertInputJsonReading("002898.60", 2898.60); + } + [TestMethod] public void CliReadResponse_ZeroReading_IsAcceptedOnlyWhenNfcAndReadingAreComplete() { @@ -135,18 +177,21 @@ namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop Assert.IsTrue(CmdPoseidonReader.TryValidateCliReadResponse(validZero, out failureReason)); Assert.IsNull(failureReason); + TestContext.WriteLine("Poseidon CLI validation | zero reading accepted when NFC=true and ReadingComplete=true."); validZero.ReadingComplete = false; Assert.IsFalse(CmdPoseidonReader.TryValidateCliReadResponse(validZero, out failureReason)); Assert.AreEqual("ReadingComplete=false.", failureReason); + TestContext.WriteLine("Poseidon CLI validation | rejected response: " + failureReason); validZero.ReadingComplete = true; validZero.NfcTagDetected = false; Assert.IsFalse(CmdPoseidonReader.TryValidateCliReadResponse(validZero, out failureReason)); Assert.AreEqual("NfcTagDetected=false.", failureReason); + TestContext.WriteLine("Poseidon CLI validation | rejected response: " + failureReason); } - private static void AssertInputJsonReading(string reading, double expectedValue) + private void AssertInputJsonReading(string reading, double expectedValue) { string json = "{\"NfcTagDetected\":true,\"ReadingComplete\":true," + "\"DeviceId\":\"1000000322\",\"Reading\":\"" + reading + "\"}"; @@ -165,6 +210,25 @@ namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop bool parsed = CmdPoseidonReader.TryParseCliReading(data.Reading, out parsedReading); Assert.IsTrue(parsed, "CLI Reading must be parsed for '" + reading + "'."); Assert.AreEqual(expectedValue, parsedReading, 0.000001); + TestContext.WriteLine(string.Format( + "Poseidon JSON parsing | rawReading='{0}' | parsed={1} | expected={2} | deviceId={3}", + reading, parsedReading, expectedValue, data.DeviceId)); + } + + [TestMethod] + public void SimulationMode_AlwaysUsesCmdSleepTestInsteadOfConfiguredHatCli() + { + string simulatedCli = CmdPoseidonReader.GetCliFileNameForMode( + Common.DebugMode.Simulate, "HatCliDemo.exe"); + string normalCli = CmdPoseidonReader.GetCliFileNameForMode( + Common.DebugMode.Normal, "HatCliDemo.exe"); + + Assert.AreEqual("cmdSleepTest.exe", simulatedCli); + Assert.AreEqual("HatCliDemo.exe", normalCli); + TestContext.WriteLine( + "Poseidon simulation CLI | mode=Simulate | configured=HatCliDemo.exe | selected=" + simulatedCli); + TestContext.WriteLine( + "Poseidon simulation CLI | mode=Normal | configured=HatCliDemo.exe | selected=" + normalCli); } [TestMethod] @@ -178,16 +242,68 @@ namespace TBFTests.Rig.RegisterReaders.PoseidonCmdStartStop CmdPoseidonReader reader = CreateReader("PoseidonPos1", 3, cliFile); reader.SetCurrentOp(CmdPoseidonReader.CurrentPoseidonOp.ReadDataStream_Start); + TestContext.WriteLine("Poseidon cmdSleepTest | reader=PoseidonPos1 | phase=Start | cli=" + cliPath); for (int iteration = 0; iteration < 300 && !IsTerminal(reader); iteration++) { reader.Run(); + if (iteration == 0 || iteration % 25 == 0 || IsTerminal(reader)) + TestContext.WriteLine(string.Format( + "Poseidon cmdSleepTest | iteration={0} | state={1} | serial='{2}' | begin={3}", + iteration + 1, reader.CurrentOp, reader.SerialNr, reader.BeginWMState)); Thread.Sleep(10); } Assert.AreEqual(CmdPoseidonReader.CurrentPoseidonOp.Done, reader.CurrentOp); Assert.IsFalse(string.IsNullOrEmpty(reader.SerialNr)); Assert.IsTrue(reader.BeginWMState > 0, "The JSON reading from cmdSleepTest must be transferred to BeginWMState."); + TestContext.WriteLine(string.Format( + "Poseidon cmdSleepTest | completed | state={0} | serial='{1}' | begin={2}", + reader.CurrentOp, reader.SerialNr, reader.BeginWMState)); + } + + private bool RunAndLogCycleIteration(string scenario, int iteration, + IList readers, bool readStart) + { + bool completed = PoseidonReadCycle.RunIteration(readers, readStart); + LogReaderSummary(scenario + " iteration=" + iteration + " phase=" + (readStart ? "Start" : "End") + + " completed=" + completed, readers); + return completed; + } + + private bool RunAndLogPhaseIteration(string scenario, string phase, int iteration, + PoseidonReadPhaseRunner runner, IList readers) + { + bool completed = runner.RunIteration(readers); + LogReaderSummary(scenario + " iteration=" + iteration + " phase=" + phase + + " completed=" + completed, readers); + return completed; + } + + private void LogReaderSummary(string label, IEnumerable readers) + { + var readerList = readers.ToList(); + int notStartedCount = readerList.Count(reader => reader.IsNotStarted); + int errorCount = readerList.Count(reader => reader.HasError); + int doneCount = readerList.Count(reader => reader.IsFinished && !reader.HasError); + int runningCount = readerList.Count - notStartedCount - errorCount - doneCount; + string stateCounts = string.Format("NotStarted={0}, Running={1}, Done={2}, Error={3}", + notStartedCount, runningCount, doneCount, errorCount); + string startCounts = string.Join(", ", readerList + .OfType() + .GroupBy(reader => reader.StartCount) + .OrderBy(group => group.Key) + .Select(group => "starts" + group.Key + "=" + group.Count())); + + string details = readerList.Count <= 10 + ? string.Join("; ", readerList.OfType().Select(reader => + string.Format("{0}[state={1}, starts={2}, runs={3}]", + reader.Name, reader.CurrentOp, reader.StartCount, reader.RunCount))) + : "details=omitted for full bench"; + + TestContext.WriteLine(string.Format( + "PoseidonReadCycle | {0} | readerCount={1} | states={2} | {3} | {4}", + label, readerList.Count, stateCounts, startCounts, details)); } internal static CmdPoseidonReader CreateReader(string name, int comPort, string cliFile) diff --git a/TBFTests/TBFTests.csproj b/TBFTests/TBFTests.csproj index b2d12071d..04e85f6e6 100644 --- a/TBFTests/TBFTests.csproj +++ b/TBFTests/TBFTests.csproj @@ -108,6 +108,8 @@ + +