---
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
---

<objective>
## 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.
</objective>

<context>
@.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
</context>

<acceptance_criteria>

## 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: <VAR> 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<IMachineFingerprint> that returns empty string from GetFingerprint()
When LicenseValidator.Validate(mockFingerprint.GetFingerprint(), anyLicense) is called
Then it returns false without throwing
```

</acceptance_criteria>

<tasks>

<task type="auto">
  <name>Task 1: Create HardwareTestHelper and add hardware test variants to 3 integration test files</name>
  <files>
    NcProgramManager.Tests/Integration/HardwareTestHelper.cs,
    NcProgramManager.Tests/Integration/HeidenhainMachineIntegrationTests.cs,
    NcProgramManager.Tests/Integration/SiemensMachineIntegrationTests.cs,
    NcProgramManager.Tests/Integration/MitsubishiMachineIntegrationTests.cs
  </files>
  <action>
    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
  </action>
  <verify>
    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
  </verify>
  <done>AC-1 and AC-2 satisfied: hardware tests skip if env var absent, connect if present</done>
</task>

<task type="auto">
  <name>Task 2: Create LicenseFileTests and add IMachineFingerprint mock test to LicenseValidatorTests</name>
  <files>
    NcProgramManager.Tests/Unit/LicenseFileTests.cs,
    NcProgramManager.Tests/Unit/LicenseValidatorTests.cs
  </files>
  <action>
    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
  </action>
  <verify>
    - 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
  </verify>
  <done>AC-3, AC-4, AC-5 satisfied: LicenseFile edge cases covered; empty fingerprint handled gracefully</done>
</task>

</tasks>

<boundaries>

## 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()`)

</boundaries>

<verification>
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
</verification>

<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>

<output>
After completion, create `.paul/phases/14-test-hardening/14-01-SUMMARY.md`
</output>
