diff --git a/src/Daqifi.Core.Tests/Device/ScpiResponseClassifierTests.cs b/src/Daqifi.Core.Tests/Device/ScpiResponseClassifierTests.cs index d86f9aaf..8558c4a2 100644 --- a/src/Daqifi.Core.Tests/Device/ScpiResponseClassifierTests.cs +++ b/src/Daqifi.Core.Tests/Device/ScpiResponseClassifierTests.cs @@ -29,5 +29,31 @@ public void IsScpiErrorLine_DoesNotMatchNonScpiText(string line) { Assert.False(ScpiResponseClassifier.IsScpiErrorLine(line)); } + + [Theory] + [InlineData("**ERROR: -200, \"Execution error\"", -200)] + [InlineData("**ERROR -200, \"Execution error\"", -200)] + [InlineData("**ERROR\t-200, \"Execution error\"", -200)] + [InlineData("ERROR: -113, \"Undefined header\"", -113)] + [InlineData("ERROR -113", -113)] // no trailing comma + [InlineData(" ERROR: -200, \"x\" \r\n", -200)] // leading/trailing whitespace + CRLF + [InlineData("ERROR: 42, \"positive\"", 42)] // positive code + public void TryExtractErrorCode_ExtractsCode_AcrossDelimiterVariants(string line, int expected) + { + Assert.True(ScpiResponseClassifier.TryExtractErrorCode(line, out var code)); + Assert.Equal(expected, code); + } + + [Theory] + [InlineData("Error !! No SD Card Detected")] // ERROR token but non-numeric follow + [InlineData("error_log.bin")] // filename + [InlineData("Errors.txt")] // filename + [InlineData("OK")] // not an error line + [InlineData("")] + public void TryExtractErrorCode_ReturnsFalseAndZero_ForNonNumericOrNonError(string line) + { + Assert.False(ScpiResponseClassifier.TryExtractErrorCode(line, out var code)); + Assert.Equal(0, code); + } } } diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 24187f20..2e4c4874 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -1347,7 +1347,7 @@ public async Task GetSdCardStorageAsync(CancellationToken can // query at all — typically because it predates the version that introduced it — so // it gets the typed feature-gating exception instead of a generic operation error. if (lastScpiError != null - && TryParseScpiErrorCode(lastScpiError, out var scpiErrorCode) + && ScpiResponseClassifier.TryExtractErrorCode(lastScpiError, out var scpiErrorCode) && scpiErrorCode == ScpiErrorCodeUndefinedHeader) { throw new FeatureNotSupportedException( @@ -1846,41 +1846,6 @@ private static bool IsScpiErrorLine(string line) return ScpiResponseClassifier.IsScpiErrorLine(line); } - /// - /// Parses the numeric error code out of a SCPI error line matched by - /// — e.g. **ERROR: -113, "Undefined header", - /// ERROR: -113,"Undefined header", or a space/tab-delimited variant like - /// **ERROR -113, "Undefined header". The delimiter between the ERROR/ - /// **ERROR token and the code may be :, space, or tab — matching the - /// delimiters accepts — and the code is the text - /// up to the following comma (if any). - /// - private static bool TryParseScpiErrorCode(string line, out int code) - { - code = 0; - var trimmed = line.TrimStart(); - - string afterToken; - if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) - { - afterToken = trimmed[7..]; - } - else if (trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) - { - afterToken = trimmed[5..]; - } - else - { - return false; - } - - afterToken = afterToken.TrimStart(':', ' ', '\t'); - - var commaIndex = afterToken.IndexOf(','); - var codeSpan = (commaIndex >= 0 ? afterToken[..commaIndex] : afterToken).Trim(); - return int.TryParse(codeSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out code); - } - // Permissive: any line that looks like a device error or status message, // including firmware text such as "Error !! ...". Used to recognize that // the parser would yield no result, without polluting LastScpiError with @@ -1998,7 +1963,7 @@ private static void ValidateSdCardFileName(string fileName) // so the caller's retry loop can react (kick LAN:APPLY) instead of just // waiting out a blind delay. var errorLine = lines.LastOrDefault(IsScpiErrorLine); - if (errorLine != null && TryParseScpiErrorCode(errorLine, out var errorCode) && errorCode == -200) + if (errorLine != null && ScpiResponseClassifier.TryExtractErrorCode(errorLine, out var errorCode) && errorCode == -200) { throw new LanNotInitializedException(errorLine.Trim()); } diff --git a/src/Daqifi.Core/Device/ScpiResponseClassifier.cs b/src/Daqifi.Core/Device/ScpiResponseClassifier.cs index dd20ea6b..1edfcd3b 100644 --- a/src/Daqifi.Core/Device/ScpiResponseClassifier.cs +++ b/src/Daqifi.Core/Device/ScpiResponseClassifier.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; #nullable enable @@ -50,6 +51,54 @@ internal static bool IsScpiErrorLine(string line) || MatchesStrictScpiErrorPrefix(trimmed, "ERROR"); } + /// + /// Extracts the numeric error code from a SCPI error line — e.g. -200 from + /// **ERROR: -200,"Execution error", ERROR -113,"Undefined header", or + /// **ERROR\t-113,.... The delimiter between the ERROR/**ERROR token and + /// the code may be :, space, or tab — the same set the line matchers above accept, kept + /// here so the accepted delimiters can't drift between detection and extraction. The code is + /// the text up to the following comma (if any). + /// + /// The candidate error line. + /// The parsed error code when the method returns true; otherwise 0. + /// true if a numeric error code was extracted; otherwise false. + internal static bool TryExtractErrorCode(string line, out int code) + { + code = 0; + var trimmed = line.TrimStart(); + + string afterToken; + if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) + { + afterToken = trimmed[7..]; + } + else if (trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) + { + afterToken = trimmed[5..]; + } + else + { + return false; + } + + afterToken = afterToken.TrimStart(TokenDelimiters); + + var commaIndex = afterToken.IndexOf(','); + var codeSpan = (commaIndex >= 0 ? afterToken[..commaIndex] : afterToken).Trim(); + return int.TryParse(codeSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out code); + } + + /// + /// The delimiters accepted between the ERROR/**ERROR token and the error code. + /// Single-sourced here: the line matchers test membership via , and + /// trims with this array directly — both draw from this one set, + /// so detection and extraction can't drift. + /// + private static readonly char[] TokenDelimiters = { ':', ' ', '\t' }; + + /// Returns true if is one of the accepted . + private static bool IsTokenDelimiter(char c) => Array.IndexOf(TokenDelimiters, c) >= 0; + private static bool MatchesErrorPrefix(string trimmed, string prefix) { if (trimmed.Length < prefix.Length @@ -58,7 +107,7 @@ private static bool MatchesErrorPrefix(string trimmed, string prefix) if (trimmed.Length == prefix.Length) return true; var next = trimmed[prefix.Length]; - if (next == ':' || next == ' ' || next == '\t') + if (IsTokenDelimiter(next)) return true; // Single '!' is ambiguous (could be a filename like "error!log.bin"). // Require '!!' so we still catch firmware "Error!!" status text but @@ -79,7 +128,8 @@ private static bool MatchesStrictScpiErrorPrefix(string trimmed, string prefix) var next = trimmed[prefix.Length]; if (next == ':') return true; - if (next != ' ' && next != '\t') + // ':' already returned above, so this rejects anything that isn't a space/tab delimiter. + if (!IsTokenDelimiter(next)) return false; // A space/tab delimiter alone is ambiguous — firmware status text like