- 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>
379 lines
15 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|