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:
parent
19da0e598e
commit
89487256d7
13 changed files with 641 additions and 2 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
24
Cnc/Mazak/FtpEntry.cs
Normal 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() : "?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
Cnc/Mazak/FtpListParser.cs
Normal file
91
Cnc/Mazak/FtpListParser.cs
Normal 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 ... <DIR>), 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 ... <DIR>),
|
||||||
|
/// 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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"/> > 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(() =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
189
NcProgramManager.Tests/Unit/FtpListParserTests.cs
Normal file
189
NcProgramManager.Tests/Unit/FtpListParserTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
58
Program.cs
58
Program.cs
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue