--- phase: 14-test-hardening plan: 01 type: tdd wave: 1 depends_on: [] files_modified: - NcProgramManager.Tests/Integration/HardwareTestHelper.cs - NcProgramManager.Tests/Integration/HeidenhainMachineIntegrationTests.cs - NcProgramManager.Tests/Integration/SiemensMachineIntegrationTests.cs - NcProgramManager.Tests/Integration/MitsubishiMachineIntegrationTests.cs - NcProgramManager.Tests/Unit/LicenseFileTests.cs - NcProgramManager.Tests/Unit/LicenseValidatorTests.cs autonomous: true --- ## Goal Fix ISS-007 and ISS-008: add opt-in hardware test scaffolding via env vars, and cover LicenseFile edge cases plus IMachineFingerprint mock integration in unit tests. ## Purpose Stub-based integration tests already run; without an env-var pattern there is no supported path to run against real hardware in CI or manually. LicenseFile has only one test (non-existent path) — empty-file and whitespace-only branches are untested. The IMachineFingerprint mock pattern ensures the license flow is documented as testable without WMI. ## Output Two new test files (`HardwareTestHelper.cs`, `LicenseFileTests.cs`) and additions to three existing test files. No production code changes. @.paul/PROJECT.md @.paul/ISSUES.md @NcProgramManager.Tests/Integration/HeidenhainMachineIntegrationTests.cs @NcProgramManager.Tests/Integration/SiemensMachineIntegrationTests.cs @NcProgramManager.Tests/Integration/MitsubishiMachineIntegrationTests.cs @NcProgramManager.Tests/Unit/LicenseValidatorTests.cs @Licensing/LicenseFile.cs ## AC-1: Hardware tests skip when env var not set ```gherkin Given no HEIDENHAIN_IP / SIEMENS_IP / MITSUBISHI_IP environment variable is set When the [Category("Hardware")] test runs in standard CI Then NUnit reports it as Ignored (not Failed), with message "Hardware test skipped: not set." ``` ## AC-2: Hardware tests run when env var is set ```gherkin Given HEIDENHAIN_IP (or SIEMENS_IP / MITSUBISHI_IP) is set to a valid IP string When the [Category("Hardware")] test runs Then it attempts ConnectAsync against that IP with the default protocol port (no stub) ``` ## AC-3: LicenseFile empty/whitespace files return false ```gherkin Given a real temp file that exists but contains empty bytes or only whitespace/newlines When LicenseFile.TryRead() is called with that path Then it returns false and the out parameter is null ``` ## AC-4: LicenseFile valid content returns true with trimmed base64 ```gherkin Given a real temp file containing a valid base64 string followed by a newline When LicenseFile.TryRead() is called with that path Then it returns true and the out parameter equals the trimmed base64 string ``` ## AC-5: IMachineFingerprint mock — empty fingerprint does not crash validator ```gherkin Given a Moq Mock that returns empty string from GetFingerprint() When LicenseValidator.Validate(mockFingerprint.GetFingerprint(), anyLicense) is called Then it returns false without throwing ``` Task 1: Create HardwareTestHelper and add hardware test variants to 3 integration test files NcProgramManager.Tests/Integration/HardwareTestHelper.cs, NcProgramManager.Tests/Integration/HeidenhainMachineIntegrationTests.cs, NcProgramManager.Tests/Integration/SiemensMachineIntegrationTests.cs, NcProgramManager.Tests/Integration/MitsubishiMachineIntegrationTests.cs 1. Create `NcProgramManager.Tests/Integration/HardwareTestHelper.cs`: ```csharp using System; using NUnit.Framework; namespace NcProgramManager.Tests.Integration { internal static class HardwareTestHelper { internal static string GetIpOrSkip(string envVarName) { string ip = Environment.GetEnvironmentVariable(envVarName); if (string.IsNullOrEmpty(ip)) Assert.Ignore("Hardware test skipped: " + envVarName + " not set."); return ip; } } } ``` 2. In `HeidenhainMachineIntegrationTests.cs`, add this method inside the class (after existing tests): ```csharp [Test] [Category("Hardware")] public void Hardware_ConnectAsync_RealMachine() { string ip = HardwareTestHelper.GetIpOrSkip("HEIDENHAIN_IP"); var config = new HeidenhainConnectionConfig { Name = "HeidenhainHardware", IpAddress = ip, Port = 19000, Username = Environment.GetEnvironmentVariable("HEIDENHAIN_USER") ?? "user", Password = Environment.GetEnvironmentVariable("HEIDENHAIN_PASS") ?? "pass", ConnectTimeout = TimeSpan.FromSeconds(10) }; using (var machine = new HeidenhainMachine(config)) { var result = machine.ConnectAsync().GetAwaiter().GetResult(); Assert.That(result.Success, Is.True, "ConnectAsync failed: " + result.Error); } } ``` Do NOT modify the existing stub-based tests — only add the new hardware method. 3. In `SiemensMachineIntegrationTests.cs`, add inside the class: ```csharp [Test] [Category("Hardware")] public void Hardware_ConnectAsync_RealMachine() { string ip = HardwareTestHelper.GetIpOrSkip("SIEMENS_IP"); var config = new SiemensConnectionConfig { Name = "SiemensHardware", IpAddress = ip, Port = 21, Username = Environment.GetEnvironmentVariable("SIEMENS_USER") ?? "user", Password = Environment.GetEnvironmentVariable("SIEMENS_PASS") ?? "pass", ProgramDirectory = "/_N_MPF_DIR/", RequestTimeout = TimeSpan.FromSeconds(10) }; using (var machine = new SiemensMachine(config)) { var result = machine.ConnectAsync().GetAwaiter().GetResult(); Assert.That(result.Success, Is.True, "ConnectAsync failed: " + result.Error); } } ``` 4. In `MitsubishiMachineIntegrationTests.cs`, add inside the class: ```csharp [Test] [Category("Hardware")] public void Hardware_ConnectAsync_RealMachine() { string ip = HardwareTestHelper.GetIpOrSkip("MITSUBISHI_IP"); var config = new MitsubishiConnectionConfig { Name = "MitsubishiHardware", IpAddress = ip, Port = 21, Username = Environment.GetEnvironmentVariable("MITSUBISHI_USER") ?? "user", Password = Environment.GetEnvironmentVariable("MITSUBISHI_PASS") ?? "pass", ProgramDirectory = "/PRG/", RequestTimeout = TimeSpan.FromSeconds(10) }; using (var machine = new MitsubishiMachine(config)) { var result = machine.ConnectAsync().GetAwaiter().GetResult(); Assert.That(result.Success, Is.True, "ConnectAsync failed: " + result.Error); } } ``` Constraints: - Use `System.Environment.GetEnvironmentVariable` (available .NET 4.7.2) - Use `Assert.Ignore()` (NUnit 3) — NOT `[Ignore]` attribute (static) — so it skips at runtime when var missing - Do NOT use `CancellationToken` or async/await — machine classes are sync-by-design - The hardware test disposes the machine itself (using block) — does not use class-level `_machine` or `_stub` fields Read all 4 files: - HardwareTestHelper.cs exists with `GetIpOrSkip` calling `Assert.Ignore` - Each integration test file has `Hardware_ConnectAsync_RealMachine` with `[Category("Hardware")]` - No existing stub tests modified AC-1 and AC-2 satisfied: hardware tests skip if env var absent, connect if present Task 2: Create LicenseFileTests and add IMachineFingerprint mock test to LicenseValidatorTests NcProgramManager.Tests/Unit/LicenseFileTests.cs, NcProgramManager.Tests/Unit/LicenseValidatorTests.cs 1. Create `NcProgramManager.Tests/Unit/LicenseFileTests.cs`: ```csharp using System.IO; using NUnit.Framework; using NcProgramManager.Licensing; namespace NcProgramManager.Tests.Unit { [TestFixture] internal class LicenseFileTests { private string _tempPath; [SetUp] public void SetUp() { _tempPath = Path.GetTempFileName(); } [TearDown] public void TearDown() { if (File.Exists(_tempPath)) File.Delete(_tempPath); } [Test] public void TryRead_EmptyFile_ReturnsFalse() { File.WriteAllText(_tempPath, string.Empty); bool result = LicenseFile.TryRead(_tempPath, out string base64); Assert.IsFalse(result); Assert.IsNull(base64); } [Test] public void TryRead_WhitespaceOnly_ReturnsFalse() { File.WriteAllText(_tempPath, " \n\r\n "); bool result = LicenseFile.TryRead(_tempPath, out string base64); Assert.IsFalse(result); Assert.IsNull(base64); } [Test] public void TryRead_ValidBase64Content_ReturnsTrueWithTrimmedValue() { string expected = "SGVsbG8gV29ybGQ="; File.WriteAllText(_tempPath, expected + "\n"); bool result = LicenseFile.TryRead(_tempPath, out string base64); Assert.IsTrue(result); Assert.AreEqual(expected, base64); } [Test] public void TryRead_NonExistentPath_ReturnsFalse() { bool result = LicenseFile.TryRead(_tempPath + ".notexist", out string base64); Assert.IsFalse(result); Assert.IsNull(base64); } } } ``` 2. In `LicenseValidatorTests.cs`, add one test method at the bottom of the class (before closing brace): ```csharp [Test] public void Validate_EmptyFingerprint_ReturnsFalse() { // Empty fingerprint — matches no valid license; must not throw Assert.IsFalse(_validator.Validate(string.Empty, Sign("CPU123"))); } ``` This uses `_validator` already set up in `[SetUp]` (test-keyed validator), so no new fields needed. Constraints: - `Path.GetTempFileName()` creates a 0-byte file, so `TearDown` must delete it - `LicenseFile.TryRead` is internal + static — accessible from test project because NcProgramManager uses `[assembly: InternalsVisibleTo("NcProgramManager.Tests")]` (verify this exists; if not, note it as a blocker) - Do NOT test `LicenseFile.TryRead` with a directory path — edge case not worth the OS-specific behavior - Do NOT add Moq for `IMachineFingerprint` — existing `LicenseValidator(string publicKeyXml)` ctor makes the empty-fingerprint test straightforward without mock overhead - Read `LicenseFileTests.cs` — 4 test methods present, all use temp file, TearDown deletes - Read `LicenseValidatorTests.cs` — `Validate_EmptyFingerprint_ReturnsFalse` present at end of class - Check `Properties/AssemblyInfo.cs` or project-level for `InternalsVisibleTo` declaration — if missing, flag it AC-3, AC-4, AC-5 satisfied: LicenseFile edge cases covered; empty fingerprint handled gracefully ## DO NOT CHANGE - `Licensing/LicenseFile.cs` — production code; no changes in this phase - `Licensing/LicenseValidator.cs` — public key constant must not be modified - `Licensing/MachineFingerprint.cs` — fixed in Phase 13; not in scope - All existing test methods — no modifications; only additions - `Cnc/**/*.cs` — no production machine code ## SCOPE LIMITS - No Moq mocking of `IMachineFingerprint` in tests — the `LicenseValidator(string)` ctor injection already enables unit testing without mocks; Moq mock would add complexity for no test coverage gain - Hardware tests only add `ConnectAsync` verification — no write/read against real hardware (unsafe without known machine state) - No new NuGet packages — Moq 4.18.4 already present; NUnit 3.13.3 already present - No async/await in test methods — stay consistent with existing test style (`.GetAwaiter().GetResult()`) Before declaring plan complete: - [ ] `HardwareTestHelper.cs` exists with `GetIpOrSkip(string) → string` calling `Assert.Ignore` if env var empty - [ ] `HeidenhainMachineIntegrationTests.cs` — `Hardware_ConnectAsync_RealMachine` test added, `[Category("Hardware")]` present - [ ] `SiemensMachineIntegrationTests.cs` — `Hardware_ConnectAsync_RealMachine` test added - [ ] `MitsubishiMachineIntegrationTests.cs` — `Hardware_ConnectAsync_RealMachine` test added - [ ] `LicenseFileTests.cs` exists with 4 test methods - [ ] `LicenseValidatorTests.cs` — `Validate_EmptyFingerprint_ReturnsFalse` added - [ ] `InternalsVisibleTo` for `LicenseFile` accessible from test project (check or flag if missing) - [ ] No production files modified - All 2 tasks completed - All 5 ACs covered - ISS-007 and ISS-008 addressed - No existing tests broken - No production code modified After completion, create `.paul/phases/14-test-hardening/14-01-SUMMARY.md`