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>
This commit is contained in:
dtrentin 2026-07-18 15:14:57 +02:00
parent 19da0e598e
commit 89487256d7
13 changed files with 641 additions and 2 deletions

View file

@ -75,6 +75,10 @@ namespace NcProgramManager
if (!string.IsNullOrEmpty(pathprogramma)) if (!string.IsNullOrEmpty(pathprogramma))
inputArgs.pathLocaleProgramma = pathprogramma.Substring(15); inputArgs.pathLocaleProgramma = pathprogramma.Substring(15);
var ftpDir = arguments.Find(it => new Regex("-ftpdir=.*", RegexOptions.IgnoreCase).IsMatch(it));
if (!string.IsNullOrEmpty(ftpDir))
inputArgs.ftpDir = ftpDir.Substring(8);
var hasToolOffsetData = arguments.Find(it => new Regex("-tooloffset.*", RegexOptions.IgnoreCase).IsMatch(it)); var hasToolOffsetData = arguments.Find(it => new Regex("-tooloffset.*", RegexOptions.IgnoreCase).IsMatch(it));
if (!string.IsNullOrEmpty(hasToolOffsetData)) if (!string.IsNullOrEmpty(hasToolOffsetData))
inputArgs.hasToolOffsetData = true; inputArgs.hasToolOffsetData = true;

View file

@ -63,7 +63,8 @@ namespace NcProgramManager.Cnc
IpAddress = args.ip, IpAddress = args.ip,
Port = string.IsNullOrEmpty(args.porta) ? 21 : int.Parse(args.porta), Port = string.IsNullOrEmpty(args.porta) ? 21 : int.Parse(args.porta),
Username = args.username, Username = args.username,
Password = args.password Password = args.password,
ProgramDirectory = string.IsNullOrEmpty(args.ftpDir) ? "/" : args.ftpDir
}); });
default: // Fanuc default: // Fanuc

24
Cnc/Mazak/FtpEntry.cs Normal file
View file

@ -0,0 +1,24 @@
namespace NcProgramManager.Cnc.Mazak
{
/// <summary>Single entry of an FTP directory listing (file or directory).</summary>
public sealed class FtpEntry
{
/// <summary>Entry name (no path).</summary>
public string Name { get; set; }
/// <summary>Full FTP path of the entry (parent dir + name).</summary>
public string FullPath { get; set; }
/// <summary>True if the entry is a directory.</summary>
public bool IsDirectory { get; set; }
/// <summary>File size in bytes; -1 when unknown (directories).</summary>
public long Size { get; set; } = -1;
/// <summary>Raw LIST line as returned by the server (diagnostics).</summary>
public string Raw { get; set; }
public override string ToString()
{
return IsDirectory
? string.Format("[DIR] {0}", Name)
: string.Format(" {0} ({1} B)", Name, Size >= 0 ? Size.ToString() : "?");
}
}
}

View file

@ -0,0 +1,91 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace NcProgramManager.Cnc.Mazak
{
/// <summary>
/// Parsing puro (senza I/O) delle righe LIST/ListDirectoryDetails FTP dei
/// controlli Mazak Smooth/Matrix. Supporta formato Unix (drwx...) e DOS/IIS
/// (MM-DD-YY ... &lt;DIR&gt;), piu' fallback NLST (solo nome).
/// </summary>
internal static class FtpListParser
{
/// <summary>Assicura path con slash iniziale e finale per uso come directory.</summary>
public static string NormalizeDir(string path)
{
string dir = string.IsNullOrEmpty(path) ? "/" : path;
if (!dir.StartsWith("/")) dir = "/" + dir;
if (!dir.EndsWith("/")) dir += "/";
return dir;
}
// DOS/IIS: "07-18-26 10:00AM <DIR> NAME"
// "07-18-26 10:00AM 1234 FILE.EIA"
private static readonly Regex _dosLine = new Regex(
@"^\d{2}-\d{2}-\d{2,4}\s+\d{1,2}:\d{2}\s*(AM|PM)?\s+(?<dir><DIR>|\d+)\s+(?<name>.+)$",
RegexOptions.IgnoreCase);
// Whitespace chars used to detect multi-token (non-NLST) lines in fallback.
private static readonly char[] _whitespace = { ' ', '\t', '\v', '\f' };
// Unix: "drwxr-xr-x 1 owner group 4096 Jul 18 10:00 NAME"
private static readonly Regex _unixLine = new Regex(
@"^(?<perm>[dl\-][rwxsStT\-]{9})\s+\d+\s+\S+\s+\S+\s+(?<size>\d+)\s+\S+\s+\S+\s+\S+\s+(?<name>.+)$");
/// <summary>Parsa una riga LIST. Ritorna null se non riconosciuta o '.'/'..'.</summary>
public static FtpEntry Parse(string line, string dir)
{
if (string.IsNullOrWhiteSpace(line)) return null;
string name = null;
bool isDir = false;
long size = -1;
var dos = _dosLine.Match(line);
if (dos.Success)
{
name = dos.Groups["name"].Value.Trim();
if (string.Equals(dos.Groups["dir"].Value, "<DIR>", StringComparison.OrdinalIgnoreCase))
isDir = true;
else
long.TryParse(dos.Groups["dir"].Value, out size);
}
else
{
var unix = _unixLine.Match(line);
if (unix.Success)
{
name = unix.Groups["name"].Value.Trim();
isDir = unix.Groups["perm"].Value[0] == 'd';
if (!isDir)
long.TryParse(unix.Groups["size"].Value, NumberStyles.Integer,
CultureInfo.InvariantCulture, out size);
}
else
{
// Fallback: NLST-style (bare name only). Only a single token
// (no internal whitespace) is a real name-only listing; a line
// with internal whitespace that matched neither regex is an
// unrecognized header/garbage -> skip (avoid whole-line-as-name).
string trimmed = line.Trim();
if (trimmed.IndexOfAny(_whitespace) >= 0)
return null;
name = trimmed;
}
}
if (string.IsNullOrEmpty(name) || name == "." || name == "..")
return null;
return new FtpEntry
{
Name = name,
FullPath = dir + name,
IsDirectory = isDir,
Size = size,
Raw = line
};
}
}
}

View file

@ -1,8 +1,11 @@
using System.Collections.Generic;
namespace NcProgramManager.Cnc.Mazak namespace NcProgramManager.Cnc.Mazak
{ {
internal interface IMazakFtpClient internal interface IMazakFtpClient
{ {
bool Ping(); bool Ping();
IReadOnlyList<FtpEntry> ListDirectory(string path);
string DownloadFile(string ncPath); string DownloadFile(string ncPath);
void UploadFile(string ncPath, string content); void UploadFile(string ncPath, string content);
void DeleteFile(string ncPath); void DeleteFile(string ncPath);

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net; using System.Net;
using System.Text; using System.Text;
@ -29,6 +30,31 @@ namespace NcProgramManager.Cnc.Mazak
catch { return false; } catch { return false; }
} }
/// <summary>
/// Lista una directory FTP (LIST/ListDirectoryDetails). Ritorna file e
/// sotto-cartelle con nome, path completo, flag directory e dimensione.
/// Parsa sia il formato Unix (drwx...) sia quello DOS/IIS (MM-DD-YY ... &lt;DIR&gt;),
/// cioe' i due layout esposti dai controlli Mazak Smooth/Matrix.
/// </summary>
public IReadOnlyList<FtpEntry> ListDirectory(string path)
{
string dir = FtpListParser.NormalizeDir(path);
var req = CreateRequest(dir, WebRequestMethods.Ftp.ListDirectoryDetails);
var entries = new List<FtpEntry>();
using (var resp = (FtpWebResponse)req.GetResponse())
using (var reader = new StreamReader(resp.GetResponseStream(), Encoding.ASCII))
{
string line;
while ((line = reader.ReadLine()) != null)
{
var entry = FtpListParser.Parse(line, dir);
if (entry != null)
entries.Add(entry);
}
}
return entries;
}
/// <summary>Scarica file dal Mazak in stringa.</summary> /// <summary>Scarica file dal Mazak in stringa.</summary>
public string DownloadFile(string ncPath) public string DownloadFile(string ncPath)
{ {

View file

@ -135,6 +135,85 @@ namespace NcProgramManager.Cnc.Mazak
}, ct); }, ct);
} }
/// <summary>
/// Lista una directory FTP del Mazak (un solo livello). Utile per sondare
/// la connessione e verificare che <see cref="MazakConnectionConfig.ProgramDirectory"/>
/// sia vista correttamente e quali file EIA/ISO siano disponibili.
/// Path vuoto = ProgramDirectory configurata.
/// </summary>
public Task<CncResult<IReadOnlyList<FtpEntry>>> ListDirectoryAsync(string path = null, CancellationToken ct = default(CancellationToken))
{
return RunGuardedAsync(() =>
{
EnsureConnected();
string dir = string.IsNullOrEmpty(path) ? _config.ProgramDirectory : path;
try { return _ftp.ListDirectory(dir); }
catch (Exception ex)
{
_errors.Add(new CncError(0, "ListDirectory", ex.Message));
return null;
}
}, ct);
}
/// <summary>
/// Costruisce un albero testuale (tree) della directory FTP a partire da
/// <paramref name="path"/>, scendendo ricorsivamente fino a
/// <paramref name="maxDepth"/> livelli. Diagnostica: mostra come il server
/// FTP del Mazak espone la cartella programmi e quali file contiene.
/// Errori per singola sottocartella sono annotati inline e non interrompono
/// l'intero albero.
/// </summary>
public Task<CncResult<string>> BuildTreeAsync(string path = null, int maxDepth = 2, CancellationToken ct = default(CancellationToken))
{
return RunGuardedAsync(() =>
{
EnsureConnected();
string root = string.IsNullOrEmpty(path) ? _config.ProgramDirectory : path;
var sb = new System.Text.StringBuilder();
sb.AppendLine(string.IsNullOrEmpty(root) ? "/" : root);
// Root-level list failure MUST propagate -> RunGuardedAsync -> Fail.
// A diagnostic probe that can't even list its root must not exit 0.
var rootEntries = _ftp.ListDirectory(root);
AppendEntries(sb, rootEntries, "", maxDepth - 1, ct);
return sb.ToString();
}, ct);
}
/// <summary>
/// Rende gli entry di una directory e ricorre nelle sottocartelle finche'
/// <paramref name="depthLeft"/> &gt; 0. Best-effort per i figli: gli errori
/// di lista delle sottocartelle sono annotati inline e non interrompono
/// l'intero albero (la root e' gestita a monte e propaga).
/// </summary>
private void AppendEntries(System.Text.StringBuilder sb, IReadOnlyList<FtpEntry> entries, string prefix, int depthLeft, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
for (int i = 0; i < entries.Count; i++)
{
var e = entries[i];
bool last = i == entries.Count - 1;
string connector = last ? "└─ " : "├─ "; // "└─ " / "├─ "
sb.AppendLine(string.Format("{0}{1}{2}{3}",
prefix, connector, e.Name,
e.IsDirectory ? "/" : (e.Size >= 0 ? string.Format(" ({0} B)", e.Size) : "")));
if (e.IsDirectory && depthLeft > 0)
{
string childPrefix = prefix + (last ? " " : "│ "); // "│ "
IReadOnlyList<FtpEntry> childEntries;
try { childEntries = _ftp.ListDirectory(e.FullPath); }
catch (Exception ex)
{
sb.AppendLine(string.Format("{0}!! <errore lista: {1}>", childPrefix, ex.Message));
continue;
}
AppendEntries(sb, childEntries, childPrefix, depthLeft - 1, ct);
}
}
}
public Task<CncResult<MachineSnapshot>> ReadAsync(CancellationToken ct = default(CancellationToken)) public Task<CncResult<MachineSnapshot>> ReadAsync(CancellationToken ct = default(CancellationToken))
{ {
return RunGuardedAsync(() => return RunGuardedAsync(() =>

View file

@ -15,6 +15,7 @@ namespace NcProgramManager
public string password = ""; public string password = "";
public string commentoProgramma = ""; public string commentoProgramma = "";
public string pathLocaleProgramma = ""; public string pathLocaleProgramma = "";
public string ftpDir = ""; // Mazak: remote FTP program directory (LISTA/probe)
public bool compatibilityMode = false; public bool compatibilityMode = false;
public bool hasToolOffsetData = false; public bool hasToolOffsetData = false;
public bool hasWorkZeroOffsetData = false; public bool hasWorkZeroOffsetData = false;

View file

@ -63,6 +63,7 @@
<Compile Include="Unit\HeidenhainMachineTests.cs" /> <Compile Include="Unit\HeidenhainMachineTests.cs" />
<Compile Include="Unit\FagorMachineTests.cs" /> <Compile Include="Unit\FagorMachineTests.cs" />
<Compile Include="Unit\MazakMachineTests.cs" /> <Compile Include="Unit\MazakMachineTests.cs" />
<Compile Include="Unit\FtpListParserTests.cs" />
<Compile Include="Integration\Stubs\FtpServerStub.cs" /> <Compile Include="Integration\Stubs\FtpServerStub.cs" />
<Compile Include="Integration\Stubs\Lsv2ServerStub.cs" /> <Compile Include="Integration\Stubs\Lsv2ServerStub.cs" />
<Compile Include="Integration\HardwareTestHelper.cs" /> <Compile Include="Integration\HardwareTestHelper.cs" />

View file

@ -0,0 +1,189 @@
using NUnit.Framework;
using NcProgramManager.Cnc.Mazak;
namespace NcProgramManager.Tests.Unit
{
[TestFixture]
public class FtpListParserTests
{
// NormalizeDir
[Test]
public void NormalizeDir_Empty_ReturnsRoot()
{
Assert.That(FtpListParser.NormalizeDir(""), Is.EqualTo("/"));
}
[Test]
public void NormalizeDir_Null_ReturnsRoot()
{
Assert.That(FtpListParser.NormalizeDir(null), Is.EqualTo("/"));
}
[Test]
public void NormalizeDir_NoSlashes_AddsBoth()
{
Assert.That(FtpListParser.NormalizeDir("PRG"), Is.EqualTo("/PRG/"));
}
[Test]
public void NormalizeDir_NoTrailingSlash_AddsTrailing()
{
Assert.That(FtpListParser.NormalizeDir("/a/b"), Is.EqualTo("/a/b/"));
}
[Test]
public void NormalizeDir_AlreadyNormalized_Unchanged()
{
Assert.That(FtpListParser.NormalizeDir("/a/b/"), Is.EqualTo("/a/b/"));
}
// Parse - DOS/IIS format
[Test]
public void Parse_DosDir_DirectoryEntry()
{
var e = FtpListParser.Parse("07-18-26 10:00AM <DIR> MYDIR", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.Name, Is.EqualTo("MYDIR"));
Assert.That(e.IsDirectory, Is.True);
Assert.That(e.Size, Is.EqualTo(-1));
Assert.That(e.FullPath, Is.EqualTo("/PRG/MYDIR"));
}
[Test]
public void Parse_DosFile_FileEntryWithSize()
{
var e = FtpListParser.Parse("07-18-26 10:00AM 1234 O1000.EIA", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.Name, Is.EqualTo("O1000.EIA"));
Assert.That(e.IsDirectory, Is.False);
Assert.That(e.Size, Is.EqualTo(1234));
}
// Parse - DOS/IIS format, T4 tolerances (24h time, 4-digit year, spaces)
[Test]
public void Parse_Dos24hTime_DirectoryEntry()
{
// 24h time, no AM/PM: _dosLine \s*(AM|PM)? optional absorbs this.
var e = FtpListParser.Parse("07-18-26 22:00 <DIR> NIGHTDIR", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.Name, Is.EqualTo("NIGHTDIR"));
Assert.That(e.IsDirectory, Is.True);
Assert.That(e.Size, Is.EqualTo(-1));
}
[Test]
public void Parse_Dos4DigitYear_FileEntryWithSize()
{
// 4-digit year: date group is \d{2}-\d{2}-\d{2,4}.
var e = FtpListParser.Parse("07-18-2026 10:00AM 2048 O4000.EIA", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.Name, Is.EqualTo("O4000.EIA"));
Assert.That(e.IsDirectory, Is.False);
Assert.That(e.Size, Is.EqualTo(2048));
}
[Test]
public void Parse_DosFileNameWithSpaces_KeepsSpaces()
{
// name group is .+ (greedy to $) -> internal spaces preserved.
var e = FtpListParser.Parse("07-18-26 10:00AM 128 MY PROG.EIA", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.Name, Is.EqualTo("MY PROG.EIA"));
Assert.That(e.IsDirectory, Is.False);
Assert.That(e.Size, Is.EqualTo(128));
}
// Parse - Unix format
[Test]
public void Parse_UnixFileNameWithSpaces_KeepsSpaces()
{
// Unix name group is .+ -> internal spaces preserved.
var e = FtpListParser.Parse("-rw-r--r-- 1 own grp 64 Jul 18 10:00 MY PROG", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.Name, Is.EqualTo("MY PROG"));
Assert.That(e.IsDirectory, Is.False);
Assert.That(e.Size, Is.EqualTo(64));
}
[Test]
public void Parse_UnixDir_DirectoryEntry()
{
var e = FtpListParser.Parse("drwxr-xr-x 1 own grp 4096 Jul 18 10:00 sub", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.IsDirectory, Is.True);
Assert.That(e.Name, Is.EqualTo("sub"));
}
[Test]
public void Parse_UnixFile_FileEntryWithSize()
{
var e = FtpListParser.Parse("-rw-r--r-- 1 own grp 512 Jul 18 10:00 O2000", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.IsDirectory, Is.False);
Assert.That(e.Size, Is.EqualTo(512));
Assert.That(e.Name, Is.EqualTo("O2000"));
}
// Parse - skip '.' and '..'
[Test]
public void Parse_DotDir_ReturnsNull()
{
var e = FtpListParser.Parse("07-18-26 10:00AM <DIR> .", "/PRG/");
Assert.That(e, Is.Null);
}
[Test]
public void Parse_DotDotDir_ReturnsNull()
{
var e = FtpListParser.Parse("07-18-26 10:00AM <DIR> ..", "/PRG/");
Assert.That(e, Is.Null);
}
// Parse - fallback (NLST bare name)
[Test]
public void Parse_BareName_FallbackFileEntry()
{
var e = FtpListParser.Parse("O3000", "/PRG/");
Assert.That(e, Is.Not.Null);
Assert.That(e.Name, Is.EqualTo("O3000"));
Assert.That(e.IsDirectory, Is.False);
Assert.That(e.Size, Is.EqualTo(-1));
}
// Parse - garbage/header multi-token line matches neither regex -> skip
[Test]
public void Parse_GarbageMultiToken_ReturnsNull()
{
// "total 8": DOS regex needs leading MM-DD-YY date (no); Unix perm group is
// [dl-][rwxsStT-]{9} and 't' is not in [dl-] (no). Trimmed line has internal
// whitespace -> T4 fallback returns null (not treated as bare NLST name).
Assert.That(FtpListParser.Parse("total 8", "/PRG/"), Is.Null);
}
// Parse - whitespace/empty
[Test]
public void Parse_Whitespace_ReturnsNull()
{
Assert.That(FtpListParser.Parse(" ", "/PRG/"), Is.Null);
}
}
}

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using NUnit.Framework; using NUnit.Framework;
using Moq; using Moq;
using NcProgramManager.Cnc; using NcProgramManager.Cnc;
@ -213,5 +214,166 @@ namespace NcProgramManager.Tests.Unit
Assert.That(result.Success, Is.False); 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);
}
} }
} }

View file

@ -123,6 +123,8 @@
<Compile Include="Cnc\Mitsubishi\MitsubishiFtpClient.cs" /> <Compile Include="Cnc\Mitsubishi\MitsubishiFtpClient.cs" />
<Compile Include="Cnc\Mitsubishi\MitsubishiMachine.cs" /> <Compile Include="Cnc\Mitsubishi\MitsubishiMachine.cs" />
<Compile Include="Cnc\Mazak\MazakConnectionConfig.cs" /> <Compile Include="Cnc\Mazak\MazakConnectionConfig.cs" />
<Compile Include="Cnc\Mazak\FtpEntry.cs" />
<Compile Include="Cnc\Mazak\FtpListParser.cs" />
<Compile Include="Cnc\Mazak\IMazakFtpClient.cs" /> <Compile Include="Cnc\Mazak\IMazakFtpClient.cs" />
<Compile Include="Cnc\Mazak\MazakFtpClient.cs" /> <Compile Include="Cnc\Mazak\MazakFtpClient.cs" />
<Compile Include="Cnc\Mazak\MazakMachine.cs" /> <Compile Include="Cnc\Mazak\MazakMachine.cs" />

View file

@ -51,7 +51,9 @@ namespace NcProgramManager
#endif #endif
bool needsIp = inputArgs.manufacturer != CncManufacturer.Fanuc || inputArgs.nodoHssb == -1; bool needsIp = inputArgs.manufacturer != CncManufacturer.Fanuc || inputArgs.nodoHssb == -1;
if (inputArgs.azione == "" || (needsIp && string.IsNullOrEmpty(inputArgs.ip)) || inputArgs.pathLocaleProgramma == "") // LISTA (probe FTP) non richiede un programma locale.
bool needsLocalProgram = !string.Equals(inputArgs.azione, "LISTA", StringComparison.OrdinalIgnoreCase);
if (inputArgs.azione == "" || (needsIp && string.IsNullOrEmpty(inputArgs.ip)) || (needsLocalProgram && inputArgs.pathLocaleProgramma == ""))
{ {
VediErrore(-102, inputArgs.debugMode); VediErrore(-102, inputArgs.debugMode);
return -102; return -102;
@ -74,6 +76,7 @@ namespace NcProgramManager
{ {
case "SCARICA": return Scarica(machine, inputArgs); case "SCARICA": return Scarica(machine, inputArgs);
case "INVIA": return Invia(machine, inputArgs); case "INVIA": return Invia(machine, inputArgs);
case "LISTA": return Lista(machine, inputArgs);
default: default:
VediErrore(-103, inputArgs.debugMode); VediErrore(-103, inputArgs.debugMode);
return -103; return -103;
@ -270,6 +273,59 @@ namespace NcProgramManager
} }
} }
static int Lista(ICncMachine macchina, InputArgs inputArgs)
{
_log.Info("LISTA FTP (probe)-->");
var mazak = macchina as NcProgramManager.Cnc.Mazak.MazakMachine;
if (mazak == null)
{
_log.Info("Azione LISTA supportata solo per manufacturer=mazak");
VediErrore(-103, inputArgs.debugMode);
return -103;
}
bool connected = false;
try
{
var connResult = macchina.ConnectAsync().GetAwaiter().GetResult();
if (!connResult.Success)
{
_log.Info("Macchina non connessa");
PrintErrors(connResult.Errors);
VediErrore(-201, inputArgs.debugMode);
return -201;
}
connected = true;
_log.Info("Directory FTP: {0}", string.IsNullOrEmpty(inputArgs.ftpDir) ? "/" : inputArgs.ftpDir);
var treeResult = mazak.BuildTreeAsync(inputArgs.ftpDir, 2).GetAwaiter().GetResult();
if (!treeResult.Success)
{
PrintErrors(treeResult.Errors);
VediErrore(-204, inputArgs.debugMode);
return -204;
}
foreach (var riga in treeResult.Value.Split('\n'))
_log.Info("{0}", riga.TrimEnd('\r'));
VediErrore(0, inputArgs.debugMode);
return 0;
}
catch (Exception ex)
{
_log.Error(ex, "Lista failed");
_log.Info("{0}", ex.Message);
VediErrore(-202, inputArgs.debugMode);
return -202;
}
finally
{
if (connected)
macchina.DisconnectAsync().GetAwaiter().GetResult();
}
}
static void PrintErrors(IReadOnlyList<CncError> errors) static void PrintErrors(IReadOnlyList<CncError> errors)
{ {
if (errors == null) return; if (errors == null) return;