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