From b3d74024986cb3288fafe4a85cce236373349240 Mon Sep 17 00:00:00 2001 From: dtrentin Date: Mon, 25 May 2026 20:27:18 +0200 Subject: [PATCH] feat(16-fagor): Fagor CNC FTP support + validator gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 16 (v0.6 milestone) — supporto Fagor CNC via FTP, riuso pattern Siemens/Mitsubishi (FtpWebRequest + ASCII). Modelli supportati: 8060/8065/8070 (FTP nativo), 8055 (con opzione Ethernet). Modelli 8025-8050 esclusi (solo RS232). Code: - Cnc/Fagor/* — FagorConnectionConfig, IFagorFtpClient, FagorFtpClient, FagorMachine. ProgramExtension configurabile (default .nc), ProgramDirectory /Users/Prg/. - Cnc/Models/CncManufacturer.cs — enum + Fagor. - Cnc/CncMachineFactory.cs — case Fagor, porta default 21. - Cnc/NcProgramValidator.cs — refactor IsRestrictiveDialect(): block-80/no-tab/no-lowercase/nested-parens/comment-32 ora applicati solo a Fanuc+Mitsubishi (Heidenhain/Siemens/Fagor esenti). Fagor name rules: max 24 char, no ñ (escape ñ per safety codepage), stem alphanumerico + spazi (estensione separata). - ArgParser.cs — case "fagor". - Program.cs — help text manufacturer/username/password. Tests: - FagorMachineTests — 12 unit (10 mock + 2 validator reale). - FagorMachineIntegrationTests — 5 stub-based (riusa FtpServerStub) + 1 hardware skip via FAGOR_TEST_IP env var. - NcProgramValidatorTests — 12 nuovi Fagor (8 + 4 extension coverage), 6 lock Siemens/Heidenhain exemption, 3 esistenti Siemens→Fanuc. - CncMachineFactoryTests — Create_Fagor_ReturnsFagorMachine. Docs: - README.md — sezione "Controller Fagor" italiano: modelli supportati/ non supportati, regole nome, env var test. Build: - *.csproj — 4 Compile Include main + 2 test. Co-Authored-By: Claude Opus 4.7 --- ArgParser.cs | 1 + Cnc/CncMachineFactory.cs | 11 + Cnc/Fagor/FagorConnectionConfig.cs | 18 ++ Cnc/Fagor/FagorFtpClient.cs | 71 ++++++ Cnc/Fagor/FagorMachine.cs | 218 ++++++++++++++++++ Cnc/Fagor/IFagorFtpClient.cs | 10 + Cnc/Models/CncManufacturer.cs | 2 +- Cnc/NcProgramValidator.cs | 78 +++++-- .../FagorMachineIntegrationTests.cs | 127 ++++++++++ .../NcProgramManager.Tests.csproj | 2 + .../Unit/CncMachineFactoryTests.cs | 13 ++ .../Unit/FagorMachineTests.cs | 212 +++++++++++++++++ .../Unit/NcProgramValidatorTests.cs | 170 +++++++++++++- NcProgramManager.csproj | 4 + Program.cs | 6 +- README.md | 54 ++++- 16 files changed, 965 insertions(+), 32 deletions(-) create mode 100644 Cnc/Fagor/FagorConnectionConfig.cs create mode 100644 Cnc/Fagor/FagorFtpClient.cs create mode 100644 Cnc/Fagor/FagorMachine.cs create mode 100644 Cnc/Fagor/IFagorFtpClient.cs create mode 100644 NcProgramManager.Tests/Integration/FagorMachineIntegrationTests.cs create mode 100644 NcProgramManager.Tests/Unit/FagorMachineTests.cs diff --git a/ArgParser.cs b/ArgParser.cs index 3c54303..0b26dce 100644 --- a/ArgParser.cs +++ b/ArgParser.cs @@ -31,6 +31,7 @@ namespace NcProgramManager case "heidenhain": inputArgs.manufacturer = CncManufacturer.Heidenhain; break; case "siemens": inputArgs.manufacturer = CncManufacturer.Siemens; break; case "mitsubishi": inputArgs.manufacturer = CncManufacturer.Mitsubishi; break; + case "fagor": inputArgs.manufacturer = CncManufacturer.Fagor; break; default: inputArgs.manufacturer = CncManufacturer.Fanuc; break; } } diff --git a/Cnc/CncMachineFactory.cs b/Cnc/CncMachineFactory.cs index ae12c39..f394404 100644 --- a/Cnc/CncMachineFactory.cs +++ b/Cnc/CncMachineFactory.cs @@ -1,4 +1,5 @@ using System; +using NcProgramManager.Cnc.Fagor; using NcProgramManager.Cnc.Fanuc; using NcProgramManager.Cnc.Heidenhain; using NcProgramManager.Cnc.Mitsubishi; @@ -44,6 +45,16 @@ namespace NcProgramManager.Cnc Password = args.password }); + case CncManufacturer.Fagor: + return new FagorMachine(new FagorConnectionConfig + { + Name = "Fagor", + IpAddress = args.ip, + Port = string.IsNullOrEmpty(args.porta) ? 21 : int.Parse(args.porta), + Username = args.username, + Password = args.password + }); + default: // Fanuc return new FanucMachine(new FanucConnectionConfig { diff --git a/Cnc/Fagor/FagorConnectionConfig.cs b/Cnc/Fagor/FagorConnectionConfig.cs new file mode 100644 index 0000000..5e5eebb --- /dev/null +++ b/Cnc/Fagor/FagorConnectionConfig.cs @@ -0,0 +1,18 @@ +using System; + +namespace NcProgramManager.Cnc.Fagor +{ + public sealed class FagorConnectionConfig + { + public string Name { get; set; } + public string IpAddress { get; set; } + public int Port { get; set; } = 21; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + /// Directory dei programmi sul server FTP Fagor. Default valido per 8060/8065/8070. + public string ProgramDirectory { get; set; } = "/Users/Prg/"; + /// Estensione applicata ai nomi programma senza estensione. Default `.nc` per Fagor 8060/8065/8070. + public string ProgramExtension { get; set; } = ".nc"; + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10); + } +} diff --git a/Cnc/Fagor/FagorFtpClient.cs b/Cnc/Fagor/FagorFtpClient.cs new file mode 100644 index 0000000..1b81bc7 --- /dev/null +++ b/Cnc/Fagor/FagorFtpClient.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Net; +using System.Text; + +namespace NcProgramManager.Cnc.Fagor +{ + /// Client FTP per trasferimento programmi Fagor CNC. + internal sealed class FagorFtpClient : IFagorFtpClient + { + private readonly FagorConnectionConfig _config; + + public FagorFtpClient(FagorConnectionConfig config) + { + _config = config ?? throw new ArgumentNullException("config"); + } + + /// Verifica raggiungibilità server FTP listando la directory programmi. + public bool Ping() + { + try + { + var req = CreateRequest(_config.ProgramDirectory, WebRequestMethods.Ftp.ListDirectory); + using (var resp = (FtpWebResponse)req.GetResponse()) + return resp.StatusCode == FtpStatusCode.DataAlreadyOpen + || resp.StatusCode == FtpStatusCode.OpeningData + || (int)resp.StatusCode / 100 == 2; + } + catch { return false; } + } + + /// Scarica file dal Fagor in stringa. + public string DownloadFile(string ncPath) + { + var req = CreateRequest(ncPath, WebRequestMethods.Ftp.DownloadFile); + using (var resp = (FtpWebResponse)req.GetResponse()) + using (var reader = new StreamReader(resp.GetResponseStream(), Encoding.ASCII)) + return reader.ReadToEnd(); + } + + /// Carica contenuto stringa su Fagor. + public void UploadFile(string ncPath, string content) + { + var req = CreateRequest(ncPath, WebRequestMethods.Ftp.UploadFile); + byte[] data = Encoding.ASCII.GetBytes(content); + req.ContentLength = data.Length; + using (var stream = req.GetRequestStream()) + stream.Write(data, 0, data.Length); + using ((FtpWebResponse)req.GetResponse()) { } + } + + public void DeleteFile(string ncPath) + { + var req = CreateRequest(ncPath, WebRequestMethods.Ftp.DeleteFile); + using ((FtpWebResponse)req.GetResponse()) { } + } + + private FtpWebRequest CreateRequest(string path, string method) + { + string uri = string.Format("ftp://{0}:{1}{2}", _config.IpAddress, _config.Port, path); + var req = (FtpWebRequest)WebRequest.Create(uri); + req.Method = method; + req.Credentials = new NetworkCredential(_config.Username, _config.Password); + req.Timeout = (int)_config.RequestTimeout.TotalMilliseconds; + req.KeepAlive = false; + req.UseBinary = false; + req.UsePassive = true; + return req; + } + } +} diff --git a/Cnc/Fagor/FagorMachine.cs b/Cnc/Fagor/FagorMachine.cs new file mode 100644 index 0000000..9ce0357 --- /dev/null +++ b/Cnc/Fagor/FagorMachine.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NLog; +using NcProgramManager.Cnc; +using NcProgramManager.Cnc.Models; + +namespace NcProgramManager.Cnc.Fagor +{ + /// + /// Fagor CNC machine — trasferimento programmi via FTP standard. + /// Supportato su Fagor 8060/8065/8070 con server FTP integrato e su 8055 con opzione Ethernet attiva. + /// + public sealed class FagorMachine : ICncMachine + { + private static readonly Logger _log = LogManager.GetCurrentClassLogger(); + + private readonly FagorConnectionConfig _config; + private readonly IFagorFtpClient _ftp; + private readonly INcProgramValidator _validator; + private readonly List _errors = new List(); + private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); + private ConnectionState _state = ConnectionState.Disconnected; + + public FagorMachine(FagorConnectionConfig config) + { + _config = config ?? throw new ArgumentNullException("config"); + _ftp = new FagorFtpClient(config); + _validator = new NcProgramValidator(CncManufacturer.Fagor); + } + + internal FagorMachine(FagorConnectionConfig config, IFagorFtpClient ftpClient, + INcProgramValidator validator = null) + { + _config = config ?? throw new ArgumentNullException("config"); + _ftp = ftpClient ?? throw new ArgumentNullException("ftpClient"); + _validator = validator ?? new NcProgramValidator(CncManufacturer.Fagor); + } + + public string Name { get { return _config.Name; } } + public ConnectionState State { get { return _state; } } + public event EventHandler StateChanged; + + public Task> ConnectAsync(CancellationToken ct = default(CancellationToken)) + { + return RunGuardedAsync(() => + { + SetState(ConnectionState.Connecting); + if (!_ftp.Ping()) + { + SetState(ConnectionState.Faulted); + _errors.Add(new CncError(0, "Connect", + string.Format("FTP server unreachable at {0}:{1}", _config.IpAddress, _config.Port))); + return false; + } + SetState(ConnectionState.Connected); + return true; + }, ct); + } + + public Task DisconnectAsync() + { + return RunGuardedAsync(() => + { + SetState(ConnectionState.Disconnected); + return true; + }, CancellationToken.None); + } + + public Task> ReadProgramAsync(string path, CancellationToken ct = default(CancellationToken)) + { + return RunGuardedAsync(() => + { + EnsureConnected(); + string ncPath = ResolvePath(path, null); + try { return _ftp.DownloadFile(ncPath); } + catch (Exception ex) + { + _errors.Add(new CncError(0, "ReadProgram", ex.Message)); + return null; + } + }, ct); + } + + public Task> WriteProgramAsync(string path, CncProgram program, CancellationToken ct = default(CancellationToken)) + { + return RunGuardedAsync(() => + { + EnsureConnected(); + if (program == null || string.IsNullOrEmpty(program.Content)) + { + _errors.Add(new CncError(0, "WriteProgram", "Content is empty")); + return false; + } + var vr = _validator.Validate(program.Name ?? path, program.Content); + if (!vr.Success) + { + string msg = string.Join("; ", vr.Errors + .Where(e => e.Severity == ValidationSeverity.Error) + .Select(e => e.Message)); + _errors.Add(new CncError(0, "Validator", msg)); + return false; + } + string ncPath = ResolvePath(path, program.Name); + try { _ftp.UploadFile(ncPath, program.Content); return true; } + catch (Exception ex) + { + _errors.Add(new CncError(0, "WriteProgram", ex.Message)); + return false; + } + }, ct); + } + + public Task> DeleteProgramAsync(string path, CancellationToken ct = default(CancellationToken)) + { + return RunGuardedAsync(() => + { + EnsureConnected(); + try { _ftp.DeleteFile(path); return true; } + catch (Exception ex) + { + _errors.Add(new CncError(0, "DeleteProgram", ex.Message)); + return false; + } + }, ct); + } + + public Task> ReadAsync(CancellationToken ct = default(CancellationToken)) + { + return RunGuardedAsync(() => + { + EnsureConnected(); + return new MachineSnapshot + { + CapturedAt = DateTime.Now, + Mode = MachineMode.Unknown, + Run = RunState.Unknown, + Motion = MotionState.Unknown, + Alarms = new List(), + OperatorMessages = new List() + }; + }, ct); + } + + public Task> ReadActiveProgramAsync(CancellationToken ct = default(CancellationToken)) + { + return Task.FromResult(CncResult.Fail("ReadActiveProgram", "Not supported via FTP")); + } + + public Task> SelectMainProgramAsync(string path, CancellationToken ct = default(CancellationToken)) + { + return Task.FromResult(CncResult.Fail("SelectMainProgram", "Not supported via FTP")); + } + + public void Dispose() + { + try { DisconnectAsync().GetAwaiter().GetResult(); } catch { /* cleanup — intentionally swallowed */ } + try { _gate.Dispose(); } catch { /* cleanup — intentionally swallowed */ } + } + + private async Task> RunGuardedAsync(Func body, CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + return await Task.Run(() => + { + _errors.Clear(); + try + { + T value = body(); + return _errors.Count > 0 + ? CncResult.Fail(_errors.ToArray()) + : CncResult.Ok(value); + } + catch (OperationCanceledException) { return CncResult.Cancelled(); } + catch (Exception ex) + { + _errors.Add(new CncError(0, "FagorMachine", ex.Message)); + return CncResult.Fail(_errors.ToArray()); + } + }, ct).ConfigureAwait(false); + } + finally { _gate.Release(); } + } + + private void EnsureConnected() + { + if (_state != ConnectionState.Connected) + throw new InvalidOperationException("FagorMachine is not connected."); + } + + private void SetState(ConnectionState next) + { + if (_state == next) return; + _state = next; + var h = StateChanged; + if (h != null) + { + try { h(this, next); } catch (Exception ex) { _log.Warn(ex, "StateChanged handler threw"); } + } + } + + /// Costruisce path FTP completo. Se name è fornito, append `_config.ProgramExtension` se manca estensione. + private string ResolvePath(string folder, string name) + { + if (string.IsNullOrEmpty(name)) + return folder.Contains("/") ? folder : _config.ProgramDirectory + folder + _config.ProgramExtension; + + string dir = folder ?? _config.ProgramDirectory; + if (!dir.EndsWith("/")) dir += "/"; + string ext = name.Contains(".") ? "" : _config.ProgramExtension; + return dir + name + ext; + } + } +} diff --git a/Cnc/Fagor/IFagorFtpClient.cs b/Cnc/Fagor/IFagorFtpClient.cs new file mode 100644 index 0000000..8a6e231 --- /dev/null +++ b/Cnc/Fagor/IFagorFtpClient.cs @@ -0,0 +1,10 @@ +namespace NcProgramManager.Cnc.Fagor +{ + internal interface IFagorFtpClient + { + bool Ping(); + string DownloadFile(string ncPath); + void UploadFile(string ncPath, string content); + void DeleteFile(string ncPath); + } +} diff --git a/Cnc/Models/CncManufacturer.cs b/Cnc/Models/CncManufacturer.cs index 9cbe135..c340863 100644 --- a/Cnc/Models/CncManufacturer.cs +++ b/Cnc/Models/CncManufacturer.cs @@ -1,4 +1,4 @@ namespace NcProgramManager.Cnc.Models { - public enum CncManufacturer { Fanuc, Heidenhain, Siemens, Mitsubishi } + public enum CncManufacturer { Fanuc, Heidenhain, Siemens, Mitsubishi, Fagor } } diff --git a/Cnc/NcProgramValidator.cs b/Cnc/NcProgramValidator.cs index f75e8fe..3df49c0 100644 --- a/Cnc/NcProgramValidator.cs +++ b/Cnc/NcProgramValidator.cs @@ -45,34 +45,39 @@ namespace NcProgramManager.Cnc errors.Add(new ValidationError(1, "First line must be O#### program number", ValidationSeverity.Error)); } + bool restrictive = IsRestrictiveDialect(); + for (int i = 0; i < lines.Length; i++) { int lineNum = i + 1; string line = lines[i]; - // Tab check - if (line.IndexOf('\t') >= 0) - errors.Add(new ValidationError(lineNum, "Tab character not allowed", ValidationSeverity.Error)); - - // Lowercase check - for (int ci = 0; ci < line.Length; ci++) + if (restrictive) { - if (char.IsLower(line[ci])) + // Tab check + if (line.IndexOf('\t') >= 0) + errors.Add(new ValidationError(lineNum, "Tab character not allowed", ValidationSeverity.Error)); + + // Lowercase check + for (int ci = 0; ci < line.Length; ci++) { - errors.Add(new ValidationError(lineNum, "Lowercase letters not allowed", ValidationSeverity.Error)); - break; + if (char.IsLower(line[ci])) + { + errors.Add(new ValidationError(lineNum, "Lowercase letters not allowed", ValidationSeverity.Error)); + break; + } } + + // Block length + if (line.Length > MaxBlockLength) + errors.Add(new ValidationError(lineNum, + string.Format("Block length {0} exceeds 80 chars", line.Length), + ValidationSeverity.Error)); + + // Nested parens + comment length + ValidateParensAndComments(line, lineNum, errors); } - // Block length - if (line.Length > MaxBlockLength) - errors.Add(new ValidationError(lineNum, - string.Format("Block length {0} exceeds 80 chars", line.Length), - ValidationSeverity.Error)); - - // Nested parens + comment length - ValidateParensAndComments(line, lineNum, errors); - // Forbidden chars per manufacturer ValidateForbiddenChars(line, lineNum, errors); } @@ -89,6 +94,12 @@ namespace NcProgramManager.Cnc // ── private helpers ─────────────────────────────────────────────────── + private bool IsRestrictiveDialect() + { + return _manufacturer == CncManufacturer.Fanuc + || _manufacturer == CncManufacturer.Mitsubishi; + } + private void ValidateName(string name, List errors) { if (string.IsNullOrEmpty(name)) return; @@ -116,6 +127,33 @@ namespace NcProgramManager.Cnc string.Format("Program name '{0}' must end with .H", name), ValidationSeverity.Error)); break; + + case CncManufacturer.Fagor: + // Estrai stem ed estensione (solo ultima estensione conta) + int dotIdx = name.LastIndexOf('.'); + string stem = dotIdx >= 0 ? name.Substring(0, dotIdx) : name; + // Lunghezza: regola applicata sul nome completo (incluso eventuale .ext) + if (name.Length > 24) + errors.Add(new ValidationError(0, + string.Format("Nome programma '{0}' supera 24 caratteri", name), + ValidationSeverity.Error)); + if (name.IndexOf('\u00f1') >= 0) + errors.Add(new ValidationError(0, + "Carattere '\u00f1' non ammesso nel nome programma Fagor", + ValidationSeverity.Error)); + // Stem deve contenere solo lettere/cifre/spazi + for (int i = 0; i < stem.Length; i++) + { + char c = stem[i]; + if (!(char.IsLetterOrDigit(c) || c == ' ')) + { + errors.Add(new ValidationError(0, + string.Format("Carattere '{0}' non ammesso nel nome programma Fagor (solo lettere/cifre/spazi)", c), + ValidationSeverity.Error)); + break; + } + } + break; } } @@ -199,6 +237,10 @@ namespace NcProgramManager.Cnc ValidationSeverity.Error)); } break; + + case CncManufacturer.Fagor: + // Fagor: nessuna restrizione su contenuto programma (ISO + linguaggio alto livello) + break; } } diff --git a/NcProgramManager.Tests/Integration/FagorMachineIntegrationTests.cs b/NcProgramManager.Tests/Integration/FagorMachineIntegrationTests.cs new file mode 100644 index 0000000..9b15d73 --- /dev/null +++ b/NcProgramManager.Tests/Integration/FagorMachineIntegrationTests.cs @@ -0,0 +1,127 @@ +using System; +using NUnit.Framework; +using NcProgramManager.Cnc.Models; +using NcProgramManager.Cnc.Fagor; +using NcProgramManager.Tests.Integration.Stubs; + +namespace NcProgramManager.Tests.Integration +{ + [TestFixture] + [Category("Integration")] + public class FagorMachineIntegrationTests + { + private FtpServerStub _stub; + private FagorMachine _machine; + private const string ProgramDir = "/Users/Prg/"; + + [SetUp] + public void SetUp() + { + _stub = new FtpServerStub(); + var config = new FagorConnectionConfig + { + Name = "FagorIntegration", + IpAddress = "127.0.0.1", + Port = _stub.Port, + Username = "user", + Password = "pass", + ProgramDirectory = ProgramDir, + ProgramExtension = ".nc", + RequestTimeout = TimeSpan.FromSeconds(5) + }; + _machine = new FagorMachine(config); + } + + [TearDown] + public void TearDown() + { + _machine?.Dispose(); + _stub?.Dispose(); + } + + [Test] + public void ConnectAsync_StubListens_ResultOk() + { + var result = _machine.ConnectAsync().GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.True); + Assert.That(_machine.State, Is.EqualTo(ConnectionState.Connected)); + } + + [Test] + public void WriteThenRead_ProgramRoundTrips() + { + _machine.ConnectAsync().GetAwaiter().GetResult(); + string content = "G0 X0 Y0\nM30"; + var prog = new CncProgram { Name = "PROG1", Content = content }; + + var writeResult = _machine.WriteProgramAsync(ProgramDir, prog).GetAwaiter().GetResult(); + Assert.That(writeResult.Success, Is.True); + + // ProgramExtension ".nc" applied by FagorMachine + string storedPath = ProgramDir + "PROG1.nc"; + var readResult = _machine.ReadProgramAsync(storedPath).GetAwaiter().GetResult(); + + Assert.That(readResult.Success, Is.True); + Assert.That(readResult.Value, Is.EqualTo(content)); + } + + [Test] + public void DeleteProgramAsync_FileExistsInStub_RemovedFromStub() + { + _machine.ConnectAsync().GetAwaiter().GetResult(); + string path = ProgramDir + "PROG2.nc"; + _stub.Files[path] = "SOME_CONTENT"; + + var result = _machine.DeleteProgramAsync(path).GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.True); + Assert.That(_stub.Files.ContainsKey(path), Is.False); + } + + [Test] + public void ReadProgramAsync_FileNotInStub_ResultFail() + { + _machine.ConnectAsync().GetAwaiter().GetResult(); + + var result = _machine.ReadProgramAsync(ProgramDir + "DOESNOTEXIST.nc").GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + } + + [Test] + public void WriteProgramAsync_InvalidName_ValidationFails() + { + _machine.ConnectAsync().GetAwaiter().GetResult(); + // Fagor name max 24 chars — 25 'A' triggers validator failure + var prog = new CncProgram { Name = new string('A', 25), Content = "G00 X10\nM30" }; + + var result = _machine.WriteProgramAsync(ProgramDir, prog).GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + } + + [Test] + [Category("Hardware")] + public void Hardware_ConnectAsync_RealMachine() + { + string ip = HardwareTestHelper.GetIpOrSkip("FAGOR_TEST_IP"); + var config = new FagorConnectionConfig + { + Name = "FagorHardware", + IpAddress = ip, + Port = 21, + Username = Environment.GetEnvironmentVariable("FAGOR_USER") ?? "user", + Password = Environment.GetEnvironmentVariable("FAGOR_PASS") ?? "pass", + ProgramDirectory = "/Users/Prg/", + ProgramExtension = ".nc", + RequestTimeout = TimeSpan.FromSeconds(10) + }; + using (var machine = new FagorMachine(config)) + { + var result = machine.ConnectAsync().GetAwaiter().GetResult(); + Assert.That(result.Success, Is.True, "ConnectAsync failed: " + result.Error); + } + } + } +} diff --git a/NcProgramManager.Tests/NcProgramManager.Tests.csproj b/NcProgramManager.Tests/NcProgramManager.Tests.csproj index 0180faf..b04e80f 100644 --- a/NcProgramManager.Tests/NcProgramManager.Tests.csproj +++ b/NcProgramManager.Tests/NcProgramManager.Tests.csproj @@ -61,12 +61,14 @@ + + diff --git a/NcProgramManager.Tests/Unit/CncMachineFactoryTests.cs b/NcProgramManager.Tests/Unit/CncMachineFactoryTests.cs index bec48c3..9849e6e 100644 --- a/NcProgramManager.Tests/Unit/CncMachineFactoryTests.cs +++ b/NcProgramManager.Tests/Unit/CncMachineFactoryTests.cs @@ -6,6 +6,7 @@ using NcProgramManager.Cnc.Heidenhain; using NcProgramManager.Cnc.Mitsubishi; using NcProgramManager.Cnc.Models; using NcProgramManager.Cnc.Siemens; +using NcProgramManager.Cnc.Fagor; namespace NcProgramManager.Tests.Unit { @@ -81,6 +82,18 @@ namespace NcProgramManager.Tests.Unit } } + [Test] + public void Create_Fagor_ReturnsFagorMachine() + { + var args = BaseArgs(); + args.manufacturer = CncManufacturer.Fagor; + + using (var m = CncMachineFactory.Create(args)) + { + Assert.That(m, Is.InstanceOf()); + } + } + [Test] public void Create_FanucHssbNode_TransportIsHssb() { diff --git a/NcProgramManager.Tests/Unit/FagorMachineTests.cs b/NcProgramManager.Tests/Unit/FagorMachineTests.cs new file mode 100644 index 0000000..121b578 --- /dev/null +++ b/NcProgramManager.Tests/Unit/FagorMachineTests.cs @@ -0,0 +1,212 @@ +using System; +using NUnit.Framework; +using Moq; +using NcProgramManager.Cnc; +using NcProgramManager.Cnc.Models; +using NcProgramManager.Cnc.Fagor; + +namespace NcProgramManager.Tests.Unit +{ + [TestFixture] + public class FagorMachineTests + { + private FagorConnectionConfig _config; + private Mock _ftpMock; + private Mock _validatorMock; + private FagorMachine _machine; + + [SetUp] + public void SetUp() + { + _config = new FagorConnectionConfig + { + Name = "TestFagor", + IpAddress = "192.168.1.2", + Port = 21, + ProgramDirectory = "/Users/Prg/", + ProgramExtension = ".nc" + }; + _ftpMock = new Mock(MockBehavior.Strict); + // Always-ok validator so existing write tests are not affected by validation rules + _validatorMock = new Mock(); + _validatorMock.Setup(v => v.Validate(It.IsAny(), It.IsAny())) + .Returns(ValidationResult.Ok()); + _machine = new FagorMachine(_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 + + [Test] + public void ReadProgramAsync_FullPath_CallsDownloadFileWithPath() + { + _ftpMock.Setup(f => f.Ping()).Returns(true); + _ftpMock.Setup(f => f.DownloadFile("/Users/Prg/PROG1.nc")).Returns("CONTENT"); + _machine.ConnectAsync().GetAwaiter().GetResult(); + + var result = _machine.ReadProgramAsync("/Users/Prg/PROG1.nc").GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.EqualTo("CONTENT")); + _ftpMock.Verify(f => f.DownloadFile("/Users/Prg/PROG1.nc"), Times.Once); + } + + [Test] + public void ReadProgramAsync_ShortName_ResolvesWithDirectoryAndExtension() + { + _ftpMock.Setup(f => f.Ping()).Returns(true); + _ftpMock.Setup(f => f.DownloadFile("/Users/Prg/PROG1.nc")).Returns("DATA"); + _machine.ConnectAsync().GetAwaiter().GetResult(); + + _machine.ReadProgramAsync("PROG1").GetAwaiter().GetResult(); + + _ftpMock.Verify(f => f.DownloadFile("/Users/Prg/PROG1.nc"), Times.Once); + } + + // WriteProgramAsync + + [Test] + public void WriteProgramAsync_ValidContent_CallsUploadFile() + { + _ftpMock.Setup(f => f.Ping()).Returns(true); + _ftpMock.Setup(f => f.UploadFile(It.IsAny(), "CONTENT")); + _machine.ConnectAsync().GetAwaiter().GetResult(); + + var prog = new CncProgram { Name = "PROG1", Content = "CONTENT" }; + var result = _machine.WriteProgramAsync("/Users/Prg/", prog).GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.True); + _ftpMock.Verify(f => f.UploadFile(It.IsAny(), "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 = "PROG1", Content = "" }; + var result = _machine.WriteProgramAsync("/Users/Prg/", prog).GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + _ftpMock.Verify(f => f.UploadFile(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void WriteProgramAsync_NullProgram_FailsWithoutCallingUploadFile() + { + _ftpMock.Setup(f => f.Ping()).Returns(true); + _machine.ConnectAsync().GetAwaiter().GetResult(); + + var result = _machine.WriteProgramAsync("/Users/Prg/", null).GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + _ftpMock.Verify(f => f.UploadFile(It.IsAny(), It.IsAny()), Times.Never); + } + + // DeleteProgramAsync + + [Test] + public void DeleteProgramAsync_ValidPath_CallsDeleteFile() + { + _ftpMock.Setup(f => f.Ping()).Returns(true); + _ftpMock.Setup(f => f.DeleteFile("/Users/Prg/PROG1.nc")); + _machine.ConnectAsync().GetAwaiter().GetResult(); + + var result = _machine.DeleteProgramAsync("/Users/Prg/PROG1.nc").GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.True); + _ftpMock.Verify(f => f.DeleteFile("/Users/Prg/PROG1.nc"), Times.Once); + } + + // ReadActiveProgramAsync + + [Test] + public void ReadActiveProgramAsync_ReturnsFailNotSupported() + { + var result = _machine.ReadActiveProgramAsync().GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + } + + // SelectMainProgramAsync + + [Test] + public void SelectMainProgramAsync_ReturnsFailNotSupported() + { + var result = _machine.SelectMainProgramAsync("/Users/Prg/PROG1.nc").GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + } + + // Validator-real tests (NcProgramValidator with CncManufacturer.Fagor) + + [Test] + public void WriteProgramAsync_NameTooLong_ValidationFails() + { + var realMachine = new FagorMachine(_config, _ftpMock.Object, + new NcProgramValidator(CncManufacturer.Fagor)); + try + { + _ftpMock.Setup(f => f.Ping()).Returns(true); + realMachine.ConnectAsync().GetAwaiter().GetResult(); + + var prog = new CncProgram { Name = new string('A', 25), Content = "G00 X10" }; + var result = realMachine.WriteProgramAsync("/Users/Prg/", prog).GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + _ftpMock.Verify(f => f.UploadFile(It.IsAny(), It.IsAny()), Times.Never); + } + finally { realMachine.Dispose(); } + } + + [Test] + public void WriteProgramAsync_NameContainsTilde_ValidationFails() + { + var realMachine = new FagorMachine(_config, _ftpMock.Object, + new NcProgramValidator(CncManufacturer.Fagor)); + try + { + _ftpMock.Setup(f => f.Ping()).Returns(true); + realMachine.ConnectAsync().GetAwaiter().GetResult(); + + var prog = new CncProgram { Name = "MAñANA", Content = "G00 X10" }; + var result = realMachine.WriteProgramAsync("/Users/Prg/", prog).GetAwaiter().GetResult(); + + Assert.That(result.Success, Is.False); + _ftpMock.Verify(f => f.UploadFile(It.IsAny(), It.IsAny()), Times.Never); + } + finally { realMachine.Dispose(); } + } + } +} diff --git a/NcProgramManager.Tests/Unit/NcProgramValidatorTests.cs b/NcProgramManager.Tests/Unit/NcProgramValidatorTests.cs index ac8c2f5..6c64bf2 100644 --- a/NcProgramManager.Tests/Unit/NcProgramValidatorTests.cs +++ b/NcProgramManager.Tests/Unit/NcProgramValidatorTests.cs @@ -32,8 +32,8 @@ namespace NcProgramManager.Tests.Unit [Test] public void Validate_LowercaseLetter_ReturnsError() { - var v = new NcProgramValidator(CncManufacturer.Siemens); - var r = v.Validate("PROG.MPF", "PROG.MPF\ng00 X10"); + var v = new NcProgramValidator(CncManufacturer.Fanuc); + var r = v.Validate("O1234", "O1234\ng00 X10"); Assert.That(r.Success, Is.False); Assert.That(HasErrorContaining(r, "Lowercase"), Is.True); } @@ -41,16 +41,16 @@ namespace NcProgramManager.Tests.Unit [Test] public void Validate_NoLowercase_NoLowercaseError() { - var v = new NcProgramValidator(CncManufacturer.Siemens); - var r = v.Validate("PROG.MPF", "G00 X10 Y20"); + var v = new NcProgramValidator(CncManufacturer.Fanuc); + var r = v.Validate("O1234", "O1234\nG00 X10 Y20"); Assert.That(HasErrorContaining(r, "Lowercase"), Is.False); } [Test] public void Validate_TabCharacter_ReturnsError() { - var v = new NcProgramValidator(CncManufacturer.Siemens); - var r = v.Validate("PROG.MPF", "G00\tX10"); + var v = new NcProgramValidator(CncManufacturer.Fanuc); + var r = v.Validate("O1234", "O1234\nG00\tX10"); Assert.That(r.Success, Is.False); Assert.That(HasErrorContaining(r, "Tab"), Is.True); } @@ -223,6 +223,32 @@ namespace NcProgramManager.Tests.Unit Assert.That(HasErrorContaining(r, "First line"), Is.False); } + [Test] + public void Validate_Siemens_LowercaseContent_NoLowercaseError() + { + // Dopo refactor: Siemens esente da check lowercase + var v = new NcProgramValidator(CncManufacturer.Siemens); + var r = v.Validate("PROG.MPF", "g00 x10 y20"); + Assert.That(HasErrorContaining(r, "Lowercase"), Is.False); + } + + [Test] + public void Validate_Siemens_TabInContent_NoTabError() + { + var v = new NcProgramValidator(CncManufacturer.Siemens); + var r = v.Validate("PROG.MPF", "G00\tX10"); + Assert.That(HasErrorContaining(r, "Tab"), Is.False); + } + + [Test] + public void Validate_Siemens_LongBlock_NoBlockLengthError() + { + var v = new NcProgramValidator(CncManufacturer.Siemens); + string longLine = new string('G', 120); + var r = v.Validate("PROG.MPF", longLine); + Assert.That(HasErrorContaining(r, "Block length"), Is.False); + } + // ── Heidenhain rules ────────────────────────────────────────────────── [Test] @@ -259,6 +285,138 @@ namespace NcProgramManager.Tests.Unit Assert.That(HasErrorContaining(r, "First line"), Is.False); } + [Test] + public void Validate_Heidenhain_LowercaseContent_NoLowercaseError() + { + var v = new NcProgramValidator(CncManufacturer.Heidenhain); + var r = v.Validate("PROG.H", "l x+10 y+20"); + Assert.That(HasErrorContaining(r, "Lowercase"), Is.False); + } + + [Test] + public void Validate_Heidenhain_TabInContent_NoTabError() + { + var v = new NcProgramValidator(CncManufacturer.Heidenhain); + var r = v.Validate("PROG.H", "L\tX+10"); + Assert.That(HasErrorContaining(r, "Tab"), Is.False); + } + + [Test] + public void Validate_Heidenhain_LongBlock_NoBlockLengthError() + { + var v = new NcProgramValidator(CncManufacturer.Heidenhain); + string longLine = "L X+" + new string('1', 100); + var r = v.Validate("PROG.H", longLine); + Assert.That(HasErrorContaining(r, "Block length"), Is.False); + } + + // ── Fagor rules ─────────────────────────────────────────────────────── + + [Test] + public void Validate_Fagor_ValidNameAndContent_Succeeds() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG1", "G00 X10 Y20\nM30"); + Assert.That(r.Success, Is.True); + } + + [Test] + public void Validate_Fagor_NameTooLong_ReturnsError() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate(new string('A', 25), "G00 X10"); + Assert.That(r.Success, Is.False); + Assert.That(HasErrorContaining(r, "24"), Is.True); + } + + [Test] + public void Validate_Fagor_NameContainsTilde_ReturnsError() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("MA\u00f1ANA", "G00 X10"); + Assert.That(r.Success, Is.False); + Assert.That(HasErrorContaining(r, "\u00f1"), Is.True); + } + + [Test] + public void Validate_Fagor_NameWithSymbol_ReturnsError() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG@1", "G00 X10"); + Assert.That(r.Success, Is.False); + } + + [Test] + public void Validate_Fagor_LongBlock_NoBlockLengthError() + { + // Fagor esentato da check block 80 + var v = new NcProgramValidator(CncManufacturer.Fagor); + string longLine = new string('G', 120); + var r = v.Validate("PROG1", longLine); + Assert.That(HasErrorContaining(r, "Block length"), Is.False); + Assert.That(HasErrorContaining(r, "80"), Is.False); + } + + [Test] + public void Validate_Fagor_Lowercase_NoLowercaseError() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG1", "g00 x10"); + Assert.That(HasErrorContaining(r, "Lowercase"), Is.False); + } + + [Test] + public void Validate_Fagor_Tab_NoTabError() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG1", "G00\tX10"); + Assert.That(HasErrorContaining(r, "Tab"), Is.False); + } + + [Test] + public void Validate_Fagor_NestedParens_NoNestedError() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG1", "G00 ((NESTED))"); + Assert.That(HasErrorContaining(r, "Nested"), Is.False); + } + + [Test] + public void Validate_Fagor_NameWithNcExtension_Succeeds() + { + // Regression: default ProgramExtension `.nc` non deve essere rifiutato + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG1.nc", "G00 X10"); + Assert.That(r.Success, Is.True); + } + + [Test] + public void Validate_Fagor_NameWithCustomExtension_Succeeds() + { + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG1.pim", "G00 X10"); + Assert.That(r.Success, Is.True); + } + + [Test] + public void Validate_Fagor_StemWithSymbol_ReturnsError() + { + // Symbol nello stem invariato come errore + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("PROG@1.nc", "G00 X10"); + Assert.That(r.Success, Is.False); + } + + [Test] + public void Validate_Fagor_NameContainsTildeEscaped_ReturnsError() + { + // Test con escape unicode esplicito \u00f1 per evitare codepage issues + var v = new NcProgramValidator(CncManufacturer.Fagor); + var r = v.Validate("MA\u00f1ANA", "G00 X10"); + Assert.That(r.Success, Is.False); + Assert.That(HasErrorContaining(r, "\u00f1"), Is.True); + } + // ── Helpers ─────────────────────────────────────────────────────────── private static bool HasErrorContaining(ValidationResult r, string fragment) diff --git a/NcProgramManager.csproj b/NcProgramManager.csproj index d54bba6..fb608d8 100755 --- a/NcProgramManager.csproj +++ b/NcProgramManager.csproj @@ -122,6 +122,10 @@ + + + + diff --git a/Program.cs b/Program.cs index eecc95a..9e246c7 100755 --- a/Program.cs +++ b/Program.cs @@ -308,7 +308,7 @@ namespace NcProgramManager _log.Info("-azione="); _log.Info(" l'{AZIONE} da eseguire."); _log.Info("-manufacturer="); - _log.Info(" Il produttore del CNC: fanuc (default), heidenhain, siemens, mitsubishi"); + _log.Info(" Il produttore del CNC: fanuc (default), heidenhain, siemens, mitsubishi, fagor"); _log.Info("-tipo="); _log.Info(" Il tipo di connessione utilizzato dalla macchina: 3 per ethernet\n 2 per HSSB (solo Fanuc, legacy)"); _log.Info("-ip="); @@ -318,9 +318,9 @@ namespace NcProgramManager _log.Info("-nodo="); _log.Info(" Dato per HSSB Nodo di connesione (solo Fanuc)"); _log.Info("-username="); - _log.Info(" Username per autenticazione (Heidenhain/Siemens)"); + _log.Info(" Username per autenticazione (Heidenhain/Siemens/Mitsubishi/Fagor)"); _log.Info("-password="); - _log.Info(" Password per autenticazione (Heidenhain/Siemens)"); + _log.Info(" Password per autenticazione (Heidenhain/Siemens/Mitsubishi/Fagor)"); _log.Info("-compatibility"); _log.Info(" Attivazione modalità compatibilità"); _log.Info("-commento="); diff --git a/README.md b/README.md index 1900432..697ea8e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NcProgramManager -Software per il trasferimento di programmi CNC da e verso controller di macchine utensili. Supporta Fanuc (FOCAS), Heidenhain (LSV2), Siemens Sinumerik (FTP) e Mitsubishi (FTP). Funziona completamente offline come applicazione console per Windows. +Software per il trasferimento di programmi CNC da e verso controller di macchine utensili. Supporta Fanuc (FOCAS), Heidenhain (LSV2), Siemens Sinumerik (FTP), Mitsubishi (FTP) e Fagor (FTP). Funziona completamente offline come applicazione console per Windows. ## Requisiti @@ -25,13 +25,13 @@ NcProgramManager.exe -manufacturer= -azione= -ip= -porta=