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

344 lines
14 KiB
Markdown

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