nc_program_manager/NcProgramManager.Tests/Unit/MazakMachineTests.cs
dtrentin 89487256d7 fix(mazak): harden FTP list parsing + surface root-list failure
- FtpListParser: tolerant DOS/IIS regex (optional AM/PM, 2-or-4-digit
  year, 1-or-2-digit hour) for 24h/locale-dependent Mazak servers;
  NLST fallback now only accepts single-token lines (multi-token
  garbage/header -> skip) to avoid whole-line-as-filename.
- MazakMachine.BuildTreeAsync: root-level LIST failure now propagates
  -> Fail (no more silent exit 0 on a diagnostic probe). Children keep
  best-effort inline "errore lista" swallow. Depth semantics preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:14:57 +02:00

379 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using NUnit.Framework;
using Moq;
using NcProgramManager.Cnc;
using NcProgramManager.Cnc.Models;
using NcProgramManager.Cnc.Mazak;
namespace NcProgramManager.Tests.Unit
{
[TestFixture]
public class MazakMachineTests
{
private MazakConnectionConfig _config;
private Mock<IMazakFtpClient> _ftpMock;
private Mock<INcProgramValidator> _validatorMock;
private MazakMachine _machine;
[SetUp]
public void SetUp()
{
_config = new MazakConnectionConfig
{
Name = "TestMazak",
IpAddress = "192.168.1.4",
Port = 21,
ProgramDirectory = "/PRG/"
};
_ftpMock = new Mock<IMazakFtpClient>(MockBehavior.Strict);
// Always-ok validator: Mazak addresses files verbatim (passthrough),
// so validation must not interfere with path/verbatim assertions.
_validatorMock = new Mock<INcProgramValidator>();
_validatorMock.Setup(v => v.Validate(It.IsAny<string>(), It.IsAny<string>()))
.Returns(ValidationResult.Ok());
_machine = new MazakMachine(_config, _ftpMock.Object, _validatorMock.Object);
}
[TearDown]
public void TearDown()
{
_machine?.Dispose();
}
// ConnectAsync
[Test]
public void ConnectAsync_PingTrue_StateConnectedAndResultOk()
{
_ftpMock.Setup(f => f.Ping()).Returns(true);
var result = _machine.ConnectAsync().GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
Assert.That(result.Value, Is.True);
Assert.That(_machine.State, Is.EqualTo(ConnectionState.Connected));
}
[Test]
public void ConnectAsync_PingFalse_StateFaultedAndResultFail()
{
_ftpMock.Setup(f => f.Ping()).Returns(false);
var result = _machine.ConnectAsync().GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
Assert.That(_machine.State, Is.EqualTo(ConnectionState.Faulted));
}
// ReadProgramAsync - verbatim filename, NO extension appended
[Test]
public void ReadProgramAsync_FullPath_CallsDownloadFileWithExactPath()
{
_ftpMock.Setup(f => f.Ping()).Returns(true);
_ftpMock.Setup(f => f.DownloadFile("/PRG/1234")).Returns("EIA_DATA");
_machine.ConnectAsync().GetAwaiter().GetResult();
var result = _machine.ReadProgramAsync("/PRG/1234").GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
Assert.That(result.Value, Is.EqualTo("EIA_DATA"));
_ftpMock.Verify(f => f.DownloadFile("/PRG/1234"), Times.Once);
}
[Test]
public void ReadProgramAsync_BareIdentifier_ResolvesWithDirectoryNoExtension()
{
_ftpMock.Setup(f => f.Ping()).Returns(true);
// Bare "1234" -> ProgramDirectory + "1234" verbatim, NO .eia / extension.
_ftpMock.Setup(f => f.DownloadFile("/PRG/1234")).Returns("DATA");
_machine.ConnectAsync().GetAwaiter().GetResult();
_machine.ReadProgramAsync("1234").GetAwaiter().GetResult();
_ftpMock.Verify(f => f.DownloadFile("/PRG/1234"), Times.Once);
// Must NOT append any extension (e.g. .eia / .EIA).
_ftpMock.Verify(f => f.DownloadFile(It.Is<string>(s => s.Contains("."))), Times.Never);
}
// WriteProgramAsync
[Test]
public void WriteProgramAsync_BareIdentifier_ResolvesWithDirectory_SymmetricWithRead()
{
// ISS-020 symmetry regression: write("1234") must upload to the SAME
// resolved path that read("1234") downloads from ("/PRG/1234"), so the
// roundtrip targets one location. Path comes from the identifier verbatim,
// NOT from a directory combined with program.Name.
const string readPath = "/PRG/1234"; // path used by ReadProgramAsync("1234")
_ftpMock.Setup(f => f.Ping()).Returns(true);
_ftpMock.Setup(f => f.UploadFile(readPath, "CONTENT"));
_machine.ConnectAsync().GetAwaiter().GetResult();
// program.Name deliberately differs from the identifier to prove the path
// is built from the identifier ("1234"), not from program.Name.
var prog = new CncProgram { Name = "O0001", Content = "CONTENT" };
var result = _machine.WriteProgramAsync("1234", prog).GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
// write-path == read-path for identical input -> symmetry guard.
_ftpMock.Verify(f => f.UploadFile(readPath, "CONTENT"), Times.Once);
}
[Test]
public void WriteProgramAsync_FullPath_CallsUploadFileWithExactPath()
{
_ftpMock.Setup(f => f.Ping()).Returns(true);
_ftpMock.Setup(f => f.UploadFile("/PRG/1234", "CONTENT"));
_machine.ConnectAsync().GetAwaiter().GetResult();
var prog = new CncProgram { Name = "1234", Content = "CONTENT" };
var result = _machine.WriteProgramAsync("/PRG/1234", prog).GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
_ftpMock.Verify(f => f.UploadFile("/PRG/1234", "CONTENT"), Times.Once);
}
[Test]
public void WriteProgramAsync_LetterName_VerbatimPassthroughNoRename()
{
// Passthrough proof: a letter name "MYPART.NC" is uploaded to
// ProgramDirectory + "MYPART.NC" UNCHANGED - no rename, no O#### enforcement,
// no extension mangling. Validator is always-ok (Mazak passthrough).
_ftpMock.Setup(f => f.Ping()).Returns(true);
_ftpMock.Setup(f => f.UploadFile("/PRG/MYPART.NC", "CONTENT"));
_machine.ConnectAsync().GetAwaiter().GetResult();
var prog = new CncProgram { Name = "MYPART.NC", Content = "CONTENT" };
var result = _machine.WriteProgramAsync("MYPART.NC", prog).GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
_ftpMock.Verify(f => f.UploadFile("/PRG/MYPART.NC", "CONTENT"), Times.Once);
}
[Test]
public void WriteProgramAsync_EmptyContent_FailsWithoutCallingUploadFile()
{
_ftpMock.Setup(f => f.Ping()).Returns(true);
_machine.ConnectAsync().GetAwaiter().GetResult();
var prog = new CncProgram { Name = "1234", Content = "" };
var result = _machine.WriteProgramAsync("/PRG/1234", prog).GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
_ftpMock.Verify(f => f.UploadFile(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Test]
public void WriteProgramAsync_NullProgram_FailsWithoutCallingUploadFile()
{
_ftpMock.Setup(f => f.Ping()).Returns(true);
_machine.ConnectAsync().GetAwaiter().GetResult();
var result = _machine.WriteProgramAsync("/PRG/1234", null).GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
_ftpMock.Verify(f => f.UploadFile(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
// DeleteProgramAsync
[Test]
public void DeleteProgramAsync_BareIdentifier_ResolvesWithDirectory_SymmetricWithReadWrite()
{
// ISS-020 symmetry regression: delete("1234") must target the SAME
// resolved path read/write use for "1234" ("/PRG/1234"). Path comes from
// ResolvePath(identifier), NOT the raw identifier verbatim.
_ftpMock.Setup(f => f.Ping()).Returns(true);
_ftpMock.Setup(f => f.DeleteFile("/PRG/1234"));
_machine.ConnectAsync().GetAwaiter().GetResult();
var result = _machine.DeleteProgramAsync("1234").GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
_ftpMock.Verify(f => f.DeleteFile("/PRG/1234"), Times.Once);
}
// ReadActiveProgramAsync - not supported via FTP
[Test]
public void ReadActiveProgramAsync_ReturnsFailNotSupported()
{
var result = _machine.ReadActiveProgramAsync().GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
}
// SelectMainProgramAsync - not supported via FTP
[Test]
public void SelectMainProgramAsync_ReturnsFailNotSupported()
{
var result = _machine.SelectMainProgramAsync("/PRG/1234").GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
}
// ListDirectoryAsync
private void Connect()
{
_ftpMock.Setup(f => f.Ping()).Returns(true);
_machine.ConnectAsync().GetAwaiter().GetResult();
}
[Test]
public void ListDirectoryAsync_NullPath_UsesProgramDirectory()
{
Connect();
var entries = new List<FtpEntry>
{
new FtpEntry { Name = "O1000", FullPath = "/PRG/O1000", IsDirectory = false, Size = 10 },
new FtpEntry { Name = "O2000", FullPath = "/PRG/O2000", IsDirectory = false, Size = 20 }
};
_ftpMock.Setup(f => f.ListDirectory("/PRG/")).Returns(entries);
var result = _machine.ListDirectoryAsync(null).GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
Assert.That(result.Value.Count, Is.EqualTo(2));
Assert.That(result.Value[0].Name, Is.EqualTo("O1000"));
_ftpMock.Verify(f => f.ListDirectory("/PRG/"), Times.Once);
}
[Test]
public void ListDirectoryAsync_ExplicitPath_PassesThrough()
{
Connect();
var entries = new List<FtpEntry>
{
new FtpEntry { Name = "O3000", FullPath = "/OTHER/O3000", IsDirectory = false, Size = 30 }
};
_ftpMock.Setup(f => f.ListDirectory("/OTHER/")).Returns(entries);
var result = _machine.ListDirectoryAsync("/OTHER/").GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
Assert.That(result.Value.Count, Is.EqualTo(1));
_ftpMock.Verify(f => f.ListDirectory("/OTHER/"), Times.Once);
}
[Test]
public void ListDirectoryAsync_FtpThrows_ResultFail()
{
Connect();
_ftpMock.Setup(f => f.ListDirectory("/PRG/")).Throws(new Exception("boom"));
var result = _machine.ListDirectoryAsync(null).GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
}
[Test]
public void ListDirectoryAsync_NotConnected_Fail()
{
// No ConnectAsync -> EnsureConnected throws, wrapped as Fail.
var result = _machine.ListDirectoryAsync(null).GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
}
// BuildTreeAsync
[Test]
public void BuildTreeAsync_FlatDir_RendersEntries()
{
Connect();
var entries = new List<FtpEntry>
{
new FtpEntry { Name = "O1000", FullPath = "/PRG/O1000", IsDirectory = false, Size = 10 },
new FtpEntry { Name = "O2000", FullPath = "/PRG/O2000", IsDirectory = false, Size = 20 }
};
_ftpMock.Setup(f => f.ListDirectory("/PRG/")).Returns(entries);
var result = _machine.BuildTreeAsync().GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
Assert.That(result.Value, Does.Contain("O1000"));
Assert.That(result.Value, Does.Contain("O2000"));
}
[Test]
public void BuildTreeAsync_NestedDir_RecursesWithinDepth()
{
Connect();
var top = new List<FtpEntry>
{
new FtpEntry { Name = "O1", FullPath = "/PRG/O1", IsDirectory = false, Size = 10 },
// FullPath has NO trailing slash: real FtpListParser.Parse produces
// "/PRG/SUB" (dir + name), and recursion keys ListDirectory on it verbatim.
new FtpEntry { Name = "SUB", FullPath = "/PRG/SUB", IsDirectory = true, Size = -1 }
};
var sub = new List<FtpEntry>
{
new FtpEntry { Name = "O2", FullPath = "/PRG/SUB/O2", IsDirectory = false, Size = 20 }
};
_ftpMock.Setup(f => f.ListDirectory("/PRG/")).Returns(top);
_ftpMock.Setup(f => f.ListDirectory("/PRG/SUB")).Returns(sub);
var result = _machine.BuildTreeAsync(null, 2).GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
Assert.That(result.Value, Does.Contain("O1"));
Assert.That(result.Value, Does.Contain("SUB"));
Assert.That(result.Value, Does.Contain("O2"));
}
[Test]
public void BuildTreeAsync_MaxDepthCutoff()
{
Connect();
var top = new List<FtpEntry>
{
new FtpEntry { Name = "SUB", FullPath = "/PRG/SUB", IsDirectory = true, Size = -1 }
};
// maxDepth 1 -> only top level; deeper ListDirectory NOT set up.
// Strict mock: if "/PRG/SUB" were listed the test would fail (proves no descent).
_ftpMock.Setup(f => f.ListDirectory("/PRG/")).Returns(top);
var result = _machine.BuildTreeAsync(null, 1).GetAwaiter().GetResult();
Assert.That(result.Success, Is.True);
Assert.That(result.Value, Does.Contain("SUB"));
Assert.That(result.Value, Does.Not.Contain("O2"));
}
[Test]
public void BuildTreeAsync_SubDirListThrows_InlineErrorNotFatal()
{
Connect();
var top = new List<FtpEntry>
{
new FtpEntry { Name = "SUB", FullPath = "/PRG/SUB", IsDirectory = true, Size = -1 }
};
_ftpMock.Setup(f => f.ListDirectory("/PRG/")).Returns(top);
_ftpMock.Setup(f => f.ListDirectory("/PRG/SUB")).Throws(new Exception("denied"));
var result = _machine.BuildTreeAsync(null, 2).GetAwaiter().GetResult();
// Child (depth>0) list error is swallowed inline -> Success stays true.
Assert.That(result.Success, Is.True);
Assert.That(result.Value, Does.Contain("errore lista"));
}
[Test]
public void BuildTreeAsync_RootListThrows_ResultFail()
{
// Root-level list failure now fetched DIRECTLY and MUST propagate ->
// RunGuardedAsync catch -> Fail. Contrast with SubDirListThrows (child,
// swallowed inline, Success stays true).
Connect();
_ftpMock.Setup(f => f.ListDirectory("/PRG/")).Throws(new Exception("530"));
var result = _machine.BuildTreeAsync(null).GetAwaiter().GetResult();
Assert.That(result.Success, Is.False);
}
}
}