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>
344 lines
14 KiB
Markdown
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>
|