nc_program_manager/.paul/phases/14-test-hardening/14-01-PLAN.md
dtrentin 32f68c5ed5 feat(14-test-hardening): env-var hardware test scaffolding + LicenseFile edge cases
Phase 14 complete — v0.4 Robustness milestone closed:
- HardwareTestHelper.GetIpOrSkip: Assert.Ignore when HEIDENHAIN/SIEMENS/MITSUBISHI_IP not set (ISS-007)
- Hardware_ConnectAsync_RealMachine [Category("Hardware")] added to all 3 integration test files
- LicenseFileTests: 4 tests covering empty, whitespace, valid base64, nonexistent path
- Validate_EmptyFingerprint_ReturnsFalse: confirms validator safe against empty input (ISS-008 scaffold)
- NcProgramManager.Tests.csproj: <Compile> entries for 2 new files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:24:58 +02:00

14 KiB

phase plan type wave depends_on files_modified autonomous
14-test-hardening 01 tdd 1
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
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

<acceptance_criteria>

AC-1: Hardware tests skip when env var not set

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: <VAR> not set."

AC-2: Hardware tests run when env var is set

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

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

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

Given a Moq Mock<IMachineFingerprint> that returns empty string from GetFingerprint()
When LicenseValidator.Validate(mockFingerprint.GetFingerprint(), anyLicense) is called
Then it returns false without throwing

</acceptance_criteria>

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

<success_criteria>

  • All 2 tasks completed
  • All 5 ACs covered
  • ISS-007 and ISS-008 addressed
  • No existing tests broken
  • No production code modified </success_criteria>
After completion, create `.paul/phases/14-test-hardening/14-01-SUMMARY.md`