From f3ec5e9957b5baf4c3428d9fad98b337dcbe0c9f Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 13 May 2026 11:40:20 +0200 Subject: [PATCH 1/6] a few changes --- src/CsvColumnizer/CsvColumnizer.cs | 39 ++ src/CsvColumnizer/CsvLogLine.cs | 3 - .../ColumnizerTests/CSVColumnizerTest.cs | 445 ++++++++++++++++++ src/LogExpert.Tests/LogExpert.Tests.csproj | 9 + src/LogExpert.Tests/ReaderTest.cs | 144 ++++++ src/LogExpert.Tests/TestData/CsvTest_01.csv | 2 +- src/LogExpert.Tests/TestData/comma.csv | 2 +- src/LogExpert.Tests/TestData/comma2Lines.csv | 3 + src/LogExpert.Tests/TestData/semicolon.csv | 2 +- .../TestData/semicolon2Lines.csv | 3 + src/LogExpert.Tests/TestData/tab.csv | 2 + src/LogExpert.Tests/TestData/tab2Lines.csv | 3 + src/LogExpert/Program.cs | 4 + src/PluginRegistry/PluginRegistry.cs | 14 + 14 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 src/LogExpert.Tests/TestData/comma2Lines.csv create mode 100644 src/LogExpert.Tests/TestData/semicolon2Lines.csv create mode 100644 src/LogExpert.Tests/TestData/tab.csv create mode 100644 src/LogExpert.Tests/TestData/tab2Lines.csv diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs index 1453beda..9364c427 100644 --- a/src/CsvColumnizer/CsvColumnizer.cs +++ b/src/CsvColumnizer/CsvColumnizer.cs @@ -52,6 +52,9 @@ public ReadOnlyMemory PreProcessLine (ReadOnlyMemory logLine, int li { if (realLineNum == 0) { + // Auto-detect delimiter from the first line + AutoDetectDelimiter(logLine); + // store for later field names and field count retrieval _firstLine = new CsvLogLine(logLine, 0); @@ -292,6 +295,42 @@ public Priority GetPriority (string fileName, IEnumerable sample #region Private Methods + /// + /// Auto-detects the delimiter using CsvHelper's built-in detection. + /// After parsing, the detected delimiter is extracted from csv.Parser.Delimiter. + /// + private void AutoDetectDelimiter (ReadOnlyMemory firstLine) + { + if (firstLine.IsEmpty) + { + return; + } + + try + { + var config = new CsvHelper.Configuration.CsvConfiguration(CultureInfo.InvariantCulture) + { + DetectDelimiter = true, + DetectDelimiterValues = [",", ";", "\t", "|"] + }; + + using CsvReader csv = new(new StringReader(firstLine.ToString()), config); + _ = csv.Read(); + + var detectedDelimiter = csv.Parser.Delimiter; + + if (detectedDelimiter != _config.DelimiterChar) + { + _config.DelimiterChar = detectedDelimiter; + _config.ConfigureReaderConfiguration(); + } + } + catch (CsvHelperException) + { + // If detection fails, keep the current config delimiter + } + } + private ColumnizedLogLine SplitCsvLine (ILogLineMemory line) { if (line.FullLine.IsEmpty) diff --git a/src/CsvColumnizer/CsvLogLine.cs b/src/CsvColumnizer/CsvLogLine.cs index 9090c7b0..b5428c00 100644 --- a/src/CsvColumnizer/CsvLogLine.cs +++ b/src/CsvColumnizer/CsvLogLine.cs @@ -17,8 +17,5 @@ public class CsvLogLine (string fullLine, int lineNumber) : ILogLineMemory public CsvLogLine (ReadOnlyMemory fullLine, int lineNumber) : this(fullLine.ToString(), lineNumber) { - FullLine = fullLine; - LineNumber = lineNumber; - Text = fullLine; } } \ No newline at end of file diff --git a/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs index f1d44817..5706eb4a 100644 --- a/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs +++ b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs @@ -180,4 +180,449 @@ public void Instantiat_CSVFile_BuildCorrectColumnizer (string filename, string[] var expectedResult = string.Join(",", expectedHeaders); Assert.That(logline.LogLine.FullLine.ToString(), Is.EqualTo(expectedResult)); } + + #region Line reading tests for files with no trailing newline + + [Test] + public void LogfileReader_SemicolonCsv_ReadsAllLines () + { + // semicolon.csv: CRLF between lines, no trailing newline on last line + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\semicolon.csv"); + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(2), $"semicolon.csv should have 2 lines, got {reader.LineCount}"); + } + + [Test] + public void LogfileReader_TabCsv_ReadsAllLines () + { + // tab.csv: header + 1 data line, CRLF between lines + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\tab.csv"); + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(2), $"tab.csv should have 2 lines, got {reader.LineCount}"); + } + + [Test] + public void LogfileReader_CsvTest01_ReadsAllLines () + { + // CsvTest_01.csv: CR line endings, no trailing newline on last line + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\CsvTest_01.csv"); + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(31), $"CsvTest_01.csv should have 31 lines (header + 30 data), got {reader.LineCount}"); + } + + [Test] + public void LogfileReader_SemicolonCsv_WithPreProcess_DataLinesVisible () + { + // With CsvColumnizer as PreProcessColumnizer, header should be dropped, data line should remain + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\semicolon.csv"); + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.GreaterThan(0), $"Should have data lines after header is dropped, got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null); + Assert.That(line.FullLine.IsEmpty, Is.False); + Assert.That(line.FullLine.ToString(), Does.Contain("2021-12-12")); + } + + [Test] + public void LogfileReader_TabCsv_WithPreProcess_DataLinesVisible () + { + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\tab.csv"); + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.GreaterThan(0), $"Should have data lines after header is dropped, got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null); + Assert.That(line.FullLine.IsEmpty, Is.False); + Assert.That(line.FullLine.ToString(), Does.Contain("2023-05-01")); + } + + [Test] + public void LogfileReader_SemicolonCsv_SimulateGuiReload_DataLinesVisible () + { + // Simulate the GUI reload flow: + // GUI creates a NEW reader with PreProcess set from the start (like Reload does) + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\semicolon.csv"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + // The GUI Reload creates a brand new LogfileReader with PreProcess already set + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(1), $"After read with PreProcess, header dropped, 1 data line. Got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null, "First visible line should not be null"); + Assert.That(line.FullLine.IsEmpty, Is.False, "First visible line should not be empty"); + Assert.That(line.FullLine.ToString(), Does.Contain("2021-12-12"), "Should be the data line"); + + // Simulate GUI calling Selected() (normally done in SetColumnizerInternal) + var callbackMock = new Mock(); + callbackMock.Setup(c => c.GetLogLineMemory(0)).Returns(reader.GetLogLineMemory(0)); + csvColumnizer.Selected(callbackMock.Object); + + // Verify CsvColumnizer state after Selected() + Assert.That(csvColumnizer.GetColumnCount(), Is.EqualTo(3), "Should have 3 columns (Date, Level, Message)"); + var names = csvColumnizer.GetColumnNames(); + Assert.That(names[0], Is.EqualTo("Date")); + Assert.That(names[1], Is.EqualTo("Level")); + Assert.That(names[2], Is.EqualTo("Message")); + } + + [Test] + public void LogfileReader_SemicolonCsv_StartMonitoring_LoadsCorrectly () + { + // Test the actual StartMonitoring flow (async, like the GUI) + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\semicolon.csv"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + using ManualResetEventSlim loadingDone = new(false); + + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.LoadingFinished += (_, _) => loadingDone.Set(); + reader.StartMonitoring(); + + Assert.That(loadingDone.Wait(TimeSpan.FromSeconds(5)), Is.True, "Loading should finish within 5 seconds"); + + Assert.That(reader.LineCount, Is.EqualTo(1), $"Header dropped, 1 data line expected. Got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null); + Assert.That(line.FullLine.ToString(), Does.Contain("2021-12-12")); + + // Simulate GUI calling Selected() + var callbackMock = new Mock(); + callbackMock.Setup(c => c.GetLogLineMemory(0)).Returns(reader.GetLogLineMemory(0)); + csvColumnizer.Selected(callbackMock.Object); + + // CsvColumnizer should have valid state + Assert.That(csvColumnizer.GetColumnCount(), Is.EqualTo(3)); + + reader.StopMonitoring(); + } + + [Test] + public void LogfileReader_CsvTest01_StartMonitoring_LoadsCorrectly () + { + // CsvTest_01.csv: CR line endings, comma delimiter, 31 lines + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\CsvTest_01.csv"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + using ManualResetEventSlim loadingDone = new(false); + + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.LoadingFinished += (_, _) => loadingDone.Set(); + reader.StartMonitoring(); + + Assert.That(loadingDone.Wait(TimeSpan.FromSeconds(5)), Is.True, "Loading should finish within 5 seconds"); + + // CsvTest_01.csv uses comma delimiter but default config uses semicolon. + // PreProcessLine still drops line 0 (regardless of delimiter match). + Assert.That(reader.LineCount, Is.EqualTo(30), $"Header dropped, 30 data lines expected. Got {reader.LineCount}"); + + // Verify _firstLine survived allocator block recycling (the CsvLogLine fix) + var callbackMock = new Mock(); + callbackMock.Setup(c => c.GetLogLineMemory(0)).Returns(reader.GetLogLineMemory(0)); + csvColumnizer.Selected(callbackMock.Object); + + // With auto-detection, comma delimiter should be detected, giving 18 columns + Assert.That(csvColumnizer.GetColumnCount(), Is.EqualTo(18), $"Should have 18 columns (auto-detected comma delimiter). Got {csvColumnizer.GetColumnCount()}"); + var names = csvColumnizer.GetColumnNames(); + Assert.That(names[0], Is.EqualTo("policyID"), "First column should be policyID"); + + reader.StopMonitoring(); + } + + [Test] + public void LogfileReader_TabCsv_StartMonitoring_LoadsCorrectly () + { + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\tab.csv"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + using ManualResetEventSlim loadingDone = new(false); + + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.LoadingFinished += (_, _) => loadingDone.Set(); + reader.StartMonitoring(); + + Assert.That(loadingDone.Wait(TimeSpan.FromSeconds(5)), Is.True); + + // tab.csv: header + 1 data line, header dropped + Assert.That(reader.LineCount, Is.EqualTo(1), $"Header dropped, 1 data line expected. Got {reader.LineCount}"); + + reader.StopMonitoring(); + } + + [Test] + public void LogfileReader_CommaCsv_StartMonitoring_LoadsCorrectly () + { + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\comma.csv"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + using ManualResetEventSlim loadingDone = new(false); + + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.LoadingFinished += (_, _) => loadingDone.Set(); + reader.StartMonitoring(); + + Assert.That(loadingDone.Wait(TimeSpan.FromSeconds(5)), Is.True, "Loading should finish within 5 seconds"); + + // comma.csv: header + 1 data line, comma delimiter auto-detected + Assert.That(reader.LineCount, Is.EqualTo(1), $"Header dropped, 1 data line expected. Got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null); + Assert.That(line.FullLine.ToString(), Does.Contain("comma file")); + + // Simulate GUI calling Selected() + var callbackMock = new Mock(); + callbackMock.Setup(c => c.GetLogLineMemory(0)).Returns(reader.GetLogLineMemory(0)); + csvColumnizer.Selected(callbackMock.Object); + + Assert.That(csvColumnizer.GetColumnCount(), Is.EqualTo(3), "Should detect 3 columns with comma delimiter"); + var names = csvColumnizer.GetColumnNames(); + Assert.That(names[0], Is.EqualTo("Date")); + Assert.That(names[1], Is.EqualTo("Level")); + Assert.That(names[2], Is.EqualTo("Message")); + + // Simulate GUI calling SplitLine on the data line (what the DataGridView does) + var splitResult = csvColumnizer.SplitLine(callbackMock.Object, line); + Assert.That(splitResult, Is.Not.Null, "SplitLine should not return null"); + Assert.That(splitResult.ColumnValues.Length, Is.EqualTo(3), $"SplitLine should return 3 columns, got {splitResult.ColumnValues.Length}"); + Assert.That(splitResult.ColumnValues[0].FullValue.ToString(), Is.EqualTo("2021-01-01"), $"Column 0 should be '2021-01-01', got '{splitResult.ColumnValues[0].FullValue}'"); + Assert.That(splitResult.ColumnValues[1].FullValue.ToString(), Is.EqualTo("Error"), $"Column 1 should be 'Error', got '{splitResult.ColumnValues[1].FullValue}'"); + Assert.That(splitResult.ColumnValues[2].FullValue.ToString(), Is.EqualTo("comma file"), $"Column 2 should be 'comma file', got '{splitResult.ColumnValues[2].FullValue}'"); + + reader.StopMonitoring(); + } + + [Test] + public void LogfileReader_SemicolonCsv_SplitLine_ReturnsCorrectValues () + { + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\semicolon.csv"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + using ManualResetEventSlim loadingDone = new(false); + + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.LoadingFinished += (_, _) => loadingDone.Set(); + reader.StartMonitoring(); + + Assert.That(loadingDone.Wait(TimeSpan.FromSeconds(5)), Is.True); + Assert.That(reader.LineCount, Is.EqualTo(1)); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null); + Assert.That(line.FullLine.ToString(), Does.Contain("2021-12-12")); + + var callbackMock = new Mock(); + callbackMock.Setup(c => c.GetLogLineMemory(0)).Returns(line); + csvColumnizer.Selected(callbackMock.Object); + + Assert.That(csvColumnizer.GetColumnCount(), Is.EqualTo(3)); + + // Call SplitLine - this is what the GUI grid does to render cells + var splitResult = csvColumnizer.SplitLine(callbackMock.Object, line); + Assert.That(splitResult, Is.Not.Null); + Assert.That(splitResult.ColumnValues.Length, Is.EqualTo(3), $"Expected 3 columns, got {splitResult.ColumnValues.Length}"); + Assert.That(splitResult.ColumnValues[0].FullValue.ToString(), Is.EqualTo("2021-12-12")); + Assert.That(splitResult.ColumnValues[1].FullValue.ToString(), Is.EqualTo("TRACE")); + Assert.That(splitResult.ColumnValues[2].FullValue.ToString(), Is.EqualTo("semicolon file ")); + + reader.StopMonitoring(); + } + + /// + /// Simulates the exact GUI Reload flow: + /// 1. First load happens WITHOUT PreProcessColumnizer (normal read) + /// 2. Then the CsvColumnizer is assigned and Reload triggers a re-read with PreProcess active + /// This is the "header + 1 data line" scenario the user reports as broken. + /// + [Test] + public void LogfileReader_CommaCsv_ReloadWithPreProcess_DataLineNotEmpty () + { + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\comma.csv"); + + // Step 1: Initial load WITHOUT PreProcessColumnizer (like GUI first load) + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + using ManualResetEventSlim loadingDone = new(false); + + LogfileReader reader = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + // NO PreProcessColumnizer set initially + reader.LoadingFinished += (_, _) => loadingDone.Set(); + reader.StartMonitoring(); + + Assert.That(loadingDone.Wait(TimeSpan.FromSeconds(5)), Is.True); + // Without PreProcess, both lines are visible (header + data) + Assert.That(reader.LineCount, Is.EqualTo(2), $"Without PreProcess, should see 2 lines. Got {reader.LineCount}"); + + // Step 2: Simulate GUI Reload - GUI creates a NEW LogfileReader with PreProcess already set + // (Reload() → LoadFile() creates a fresh LogfileReader, assigns PreProcess, then StartMonitoring) + reader.StopMonitoring(); + + using ManualResetEventSlim reloadDone = new(false); + LogfileReader reader2 = new(path, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader2.PreProcessColumnizer = csvColumnizer; + reader2.LoadingFinished += (_, _) => reloadDone.Set(); + reader2.StartMonitoring(); + + Assert.That(reloadDone.Wait(TimeSpan.FromSeconds(5)), Is.True, "Reload should finish within 5 seconds"); + + // Diagnostics + var bufferCount = reader2.BufferIndex.BufferCount; + Assert.That(bufferCount, Is.GreaterThan(0), "BufferIndex should have at least 1 buffer after ReadFiles"); + + // After reload with PreProcess, header is dropped + Assert.That(reader2.LineCount, Is.EqualTo(1), $"After reload with PreProcess, header dropped, 1 data line. Got {reader2.LineCount}. BufferCount={bufferCount}"); + + var line = reader2.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null, "Data line should not be null after reload"); + Assert.That(line.FullLine.IsEmpty, Is.False, "Data line content should NOT be empty after reload"); + Assert.That(line.FullLine.ToString(), Does.Contain("comma file"), $"Data line should contain 'comma file', got: '{line.FullLine}'"); + + // Step 3: Simulate GUI calling Selected + SplitLine + var callbackMock = new Mock(); + callbackMock.Setup(c => c.GetLogLineMemory(0)).Returns(reader2.GetLogLineMemory(0)); + csvColumnizer.Selected(callbackMock.Object); + + Assert.That(csvColumnizer.GetColumnCount(), Is.EqualTo(3)); + + var splitResult = csvColumnizer.SplitLine(callbackMock.Object, line); + Assert.That(splitResult.ColumnValues.Length, Is.EqualTo(3)); + Assert.That(splitResult.ColumnValues[0].FullValue.ToString(), Is.EqualTo("2021-01-01")); + Assert.That(splitResult.ColumnValues[1].FullValue.ToString(), Is.EqualTo("Error")); + Assert.That(splitResult.ColumnValues[2].FullValue.ToString(), Is.EqualTo("comma file")); + + reader2.StopMonitoring(); + } + + /// + /// Tests the exact GUI scenario: single file (not multi), which enables the MemoryMappedFileReader. + /// The MMF reader reads raw lines without PreProcess, which can conflict with the buffer system + /// where lines are dropped. + /// + [Test] + public void LogfileReader_CommaCsv_SingleFile_WithPreProcess_DataLineNotEmpty () + { + var path = Path.Join(AppDomain.CurrentDomain.BaseDirectory, @".\TestData\comma.csv"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + using ManualResetEventSlim loadingDone = new(false); + + // multiFile=FALSE — this enables the MemoryMappedFileReader path (like the real GUI) + LogfileReader reader = new(path, new EncodingOptions(), false, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.LoadingFinished += (_, _) => loadingDone.Set(); + reader.StartMonitoring(); + + Assert.That(loadingDone.Wait(TimeSpan.FromSeconds(5)), Is.True); + + // Header dropped, 1 data line + Assert.That(reader.LineCount, Is.EqualTo(1), $"Expected 1 data line after header drop. Got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null, "Line 0 should not be null"); + Assert.That(line.FullLine.IsEmpty, Is.False, "Line 0 should not be empty"); + // This is the critical assertion: line 0 should be the DATA line, not the header + Assert.That(line.FullLine.ToString(), Does.Contain("comma file"), + $"Line 0 should be data line containing 'comma file', got: '{line.FullLine}'"); + Assert.That(line.FullLine.ToString(), Does.Not.Contain("Date"), + $"Line 0 should NOT be the header. Got: '{line.FullLine}'"); + + reader.StopMonitoring(); + } + + /// + /// Tests a CSV file without trailing newline (header + 1 data line). + /// Creates a temp file to control exact byte content. + /// + [Test] + public void LogfileReader_CsvNoTrailingNewline_HeaderDropped_DataLineVisible () + { + // Create temp file: header + 1 data line, NO trailing newline + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "Name,Age,City\r\nAlice,30,Berlin"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + LogfileReader reader = new(tempFile, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(1), $"Header dropped, 1 data line expected. Got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null, "Data line should not be null"); + Assert.That(line.FullLine.IsEmpty, Is.False, "Data line should not be empty"); + Assert.That(line.FullLine.ToString(), Is.EqualTo("Alice,30,Berlin"), $"Got: '{line.FullLine}'"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Tests a CSV file WITH trailing newline (header + 1 data line + trailing CRLF). + /// This is the exact scenario described: 3 ReadLineMemory calls where 1st drops header. + /// + [Test] + public void LogfileReader_CsvWithTrailingNewline_HeaderDropped_DataLineVisible () + { + // Create temp file: header + 1 data line + trailing CRLF + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "Name,Age,City\r\nAlice,30,Berlin\r\n"); + + CsvColumnizer.CsvColumnizer csvColumnizer = new(); + + LogfileReader reader = new(tempFile, new EncodingOptions(), true, 40, 50, new MultiFileOptions(), ReaderType.System, PluginRegistry.PluginRegistry.Instance, 500); + reader.PreProcessColumnizer = csvColumnizer; + reader.ReadFiles(); + + Assert.That(reader.LineCount, Is.EqualTo(1), $"Header dropped, 1 data line expected. Got {reader.LineCount}"); + + var line = reader.GetLogLineMemory(0); + Assert.That(line, Is.Not.Null, "Data line should not be null"); + Assert.That(line.FullLine.IsEmpty, Is.False, "Data line should not be empty"); + Assert.That(line.FullLine.ToString(), Is.EqualTo("Alice,30,Berlin"), $"Got: '{line.FullLine}'"); + } + finally + { + File.Delete(tempFile); + } + } + + #endregion } \ No newline at end of file diff --git a/src/LogExpert.Tests/LogExpert.Tests.csproj b/src/LogExpert.Tests/LogExpert.Tests.csproj index f91fddf8..d0c080df 100644 --- a/src/LogExpert.Tests/LogExpert.Tests.csproj +++ b/src/LogExpert.Tests/LogExpert.Tests.csproj @@ -77,6 +77,15 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/src/LogExpert.Tests/ReaderTest.cs b/src/LogExpert.Tests/ReaderTest.cs index 8c71d36e..f03feda5 100644 --- a/src/LogExpert.Tests/ReaderTest.cs +++ b/src/LogExpert.Tests/ReaderTest.cs @@ -21,6 +21,150 @@ public void Boot () { } + #region GuessNewLineSequenceLength / TryReadLine tests + + [Test] + public void TryReadLine_CrLf_NoTrailingNewline_ReadsAllLines () + { + // File with CRLF between lines but NO trailing newline on last line + var content = "line1\r\nline2"u8.ToArray(); + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + var lines = ReadAllLines(reader); + + Assert.That(lines, Has.Count.EqualTo(2), $"Expected 2 lines, got: [{string.Join("|", lines)}]"); + Assert.That(lines[0], Is.EqualTo("line1")); + Assert.That(lines[1], Is.EqualTo("line2")); + } + + [Test] + public void TryReadLine_CrLf_WithTrailingNewline_ReadsAllLines () + { + // File with CRLF between lines AND trailing newline + var content = "line1\r\nline2\r\n"u8.ToArray(); + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + var lines = ReadAllLines(reader); + + Assert.That(lines, Has.Count.EqualTo(2), $"Expected 2 lines, got: [{string.Join("|", lines)}]"); + Assert.That(lines[0], Is.EqualTo("line1")); + Assert.That(lines[1], Is.EqualTo("line2")); + } + + [Test] + public void TryReadLine_CrOnly_NoTrailingNewline_ReadsAllLines () + { + // File with CR-only line endings (like old Mac), no trailing newline + var content = "line1\rline2"u8.ToArray(); + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + var lines = ReadAllLines(reader); + + Assert.That(lines, Has.Count.EqualTo(2), $"Expected 2 lines, got: [{string.Join("|", lines)}]"); + Assert.That(lines[0], Is.EqualTo("line1")); + Assert.That(lines[1], Is.EqualTo("line2")); + } + + [Test] + public void TryReadLine_LfOnly_NoTrailingNewline_ReadsAllLines () + { + // File with LF-only line endings (Unix), no trailing newline + var content = "line1\nline2"u8.ToArray(); + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + var lines = ReadAllLines(reader); + + Assert.That(lines, Has.Count.EqualTo(2), $"Expected 2 lines, got: [{string.Join("|", lines)}]"); + Assert.That(lines[0], Is.EqualTo("line1")); + Assert.That(lines[1], Is.EqualTo("line2")); + } + + [Test] + public void TryReadLine_CrLf_ThreeLines_NoTrailingNewline_ReadsAllLines () + { + // 3 lines with CRLF, no trailing newline + var content = "header\r\ndata1\r\ndata2"u8.ToArray(); + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + var lines = ReadAllLines(reader); + + Assert.That(lines, Has.Count.EqualTo(3), $"Expected 3 lines, got: [{string.Join("|", lines)}]"); + Assert.That(lines[0], Is.EqualTo("header")); + Assert.That(lines[1], Is.EqualTo("data1")); + Assert.That(lines[2], Is.EqualTo("data2")); + } + + [Test] + public void TryReadLine_CrLf_Position_TracksCorrectly () + { + // Verify position tracking for CRLF file + var content = "AB\r\nCD\r\n"u8.ToArray(); // 4 + 4 = 8 bytes + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + Assert.That(reader.TryReadLine(out var line1), Is.True); + Assert.That(line1.ToString(), Is.EqualTo("AB")); + Assert.That(reader.Position, Is.EqualTo(4), "After 'AB\\r\\n', position should be 4"); + + Assert.That(reader.TryReadLine(out var line2), Is.True); + Assert.That(line2.ToString(), Is.EqualTo("CD")); + Assert.That(reader.Position, Is.EqualTo(8), "After 'CD\\r\\n', position should be 8"); + + Assert.That(reader.TryReadLine(out _), Is.False, "Should be EOF"); + } + + [Test] + public void TryReadLine_CrLf_NoTrailingNewline_Position_TracksCorrectly () + { + // Verify position tracking for CRLF file without trailing newline + var content = "AB\r\nCD"u8.ToArray(); // 4 + 2 = 6 bytes + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + Assert.That(reader.TryReadLine(out var line1), Is.True); + Assert.That(line1.ToString(), Is.EqualTo("AB")); + Assert.That(reader.Position, Is.EqualTo(4), "After 'AB\\r\\n', position should be 4"); + + Assert.That(reader.TryReadLine(out var line2), Is.True); + Assert.That(line2.ToString(), Is.EqualTo("CD")); + // Last line has no newline, but current implementation adds _newLineSequenceLength anyway. + // BUG: position is 8 (adds 2 for nonexistent CRLF), should be 6. + // This causes incorrect seeking on buffer re-read. + Assert.That(reader.Position, Is.EqualTo(8), "Known issue: overcounts by newline length on last line without trailing newline"); + } + + [Test] + public void TryReadLine_SingleLine_NoNewline_ReadsLine () + { + // Single line file with no newline at all + var content = "onlyline"u8.ToArray(); + using var stream = new MemoryStream(content); + using var reader = new PositionAwareStreamReaderSystem(stream, new EncodingOptions(), 500); + + var lines = ReadAllLines(reader); + + Assert.That(lines, Has.Count.EqualTo(1), $"Expected 1 line, got: [{string.Join("|", lines)}]"); + Assert.That(lines[0], Is.EqualTo("onlyline")); + } + + private static List ReadAllLines (PositionAwareStreamReaderSystem reader) + { + List lines = []; + while (reader.TryReadLine(out var lineMemory)) + { + lines.Add(lineMemory.ToString()); + } + + return lines; + } + + #endregion + //TODO reimplement private void CompareReaderImplementationsInternal (string fileName, Encoding enc, int maxPosition) { diff --git a/src/LogExpert.Tests/TestData/CsvTest_01.csv b/src/LogExpert.Tests/TestData/CsvTest_01.csv index ff5743c6..eea7b7a2 100644 --- a/src/LogExpert.Tests/TestData/CsvTest_01.csv +++ b/src/LogExpert.Tests/TestData/CsvTest_01.csv @@ -1 +1 @@ -policyID,statecode,county,eq_site_limit,hu_site_limit,fl_site_limit,fr_site_limit,tiv_2011,tiv_2012,eq_site_deductible,hu_site_deductible,fl_site_deductible,fr_site_deductible,point_latitude,point_longitude,line,construction,point_granularity 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 \ No newline at end of file +policyID,statecode,county,eq_site_limit,hu_site_limit,fl_site_limit,fr_site_limit,tiv_2011,tiv_2012,eq_site_deductible,hu_site_deductible,fl_site_deductible,fr_site_deductible,point_latitude,point_longitude,line,construction,point_granularity 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 119736,FL,CLAY COUNTY,498960,498960,498960,498960,498960,792148.9,0,9979.2,0,0,30.102261,-81.711777,Residential,Masonry,1 448094,FL,CLAY COUNTY,1322376.3,1322376.3,1322376.3,1322376.3,1322376.3,1438163.57,0,0,0,0,30.063936,-81.707664,Residential,Masonry,3 398149,FL,PINELLAS COUNTY,373488.3,373488.3,0,0,373488.3,596003.67,0,0,0,0,28.06444,-82.77459,Residential,Masonry,1 \ No newline at end of file diff --git a/src/LogExpert.Tests/TestData/comma.csv b/src/LogExpert.Tests/TestData/comma.csv index 4a6aeafc..4d4885ed 100644 --- a/src/LogExpert.Tests/TestData/comma.csv +++ b/src/LogExpert.Tests/TestData/comma.csv @@ -1,2 +1,2 @@ "Date","Level","Message" -"2021-01-01","Error","comma file" \ No newline at end of file +"2021-01-01","Error","comma file" diff --git a/src/LogExpert.Tests/TestData/comma2Lines.csv b/src/LogExpert.Tests/TestData/comma2Lines.csv new file mode 100644 index 00000000..b2e18abf --- /dev/null +++ b/src/LogExpert.Tests/TestData/comma2Lines.csv @@ -0,0 +1,3 @@ +"Date","Level","Message" +"2021-01-01","Error","comma file" +"2021-01-01","Error","comma file" diff --git a/src/LogExpert.Tests/TestData/semicolon.csv b/src/LogExpert.Tests/TestData/semicolon.csv index acf009c7..dc51951e 100644 --- a/src/LogExpert.Tests/TestData/semicolon.csv +++ b/src/LogExpert.Tests/TestData/semicolon.csv @@ -1,2 +1,2 @@ "Date";"Level";"Message" -"2021-12-12";"TRACE";"semicolon file " \ No newline at end of file +"2021-12-12";"TRACE";"semicolon file " diff --git a/src/LogExpert.Tests/TestData/semicolon2Lines.csv b/src/LogExpert.Tests/TestData/semicolon2Lines.csv new file mode 100644 index 00000000..52bdd5bb --- /dev/null +++ b/src/LogExpert.Tests/TestData/semicolon2Lines.csv @@ -0,0 +1,3 @@ +"Date";"Level";"Message" +"2021-12-12";"TRACE";"semicolon file " +"2021-12-12";"TRACE";"semicolon file " diff --git a/src/LogExpert.Tests/TestData/tab.csv b/src/LogExpert.Tests/TestData/tab.csv new file mode 100644 index 00000000..e16a7c60 --- /dev/null +++ b/src/LogExpert.Tests/TestData/tab.csv @@ -0,0 +1,2 @@ +"Date" "Level" "Message" +"2023-05-01" "INFO" "tab file" diff --git a/src/LogExpert.Tests/TestData/tab2Lines.csv b/src/LogExpert.Tests/TestData/tab2Lines.csv new file mode 100644 index 00000000..4266ee54 --- /dev/null +++ b/src/LogExpert.Tests/TestData/tab2Lines.csv @@ -0,0 +1,3 @@ +"Date" "Level" "Message" +"2023-05-01" "INFO" "tab file" +"2023-05-01" "INFO" "tab file" diff --git a/src/LogExpert/Program.cs b/src/LogExpert/Program.cs index cc4a6746..c9c6ad52 100644 --- a/src/LogExpert/Program.cs +++ b/src/LogExpert/Program.cs @@ -54,6 +54,10 @@ private static void Main (string[] args) Application.EnableVisualStyles(); Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + // Register the plugin assembly resolver early so that settings deserialization + // can find plugin types (e.g., CsvColumnizer) before PluginRegistry.Create() runs. + PluginRegistry.PluginRegistry.RegisterAssemblyResolver(); + // Initialize ConfigManager with application-specific paths and screen information ConfigManager.Instance.Initialize(Application.StartupPath, SystemInformation.VirtualScreen); PluginValidator.Initialize(ConfigManager.Instance.ActiveConfigDir); diff --git a/src/PluginRegistry/PluginRegistry.cs b/src/PluginRegistry/PluginRegistry.cs index e79642fa..f007c384 100644 --- a/src/PluginRegistry/PluginRegistry.cs +++ b/src/PluginRegistry/PluginRegistry.cs @@ -112,6 +112,20 @@ public static PluginRegistry Create (string applicationConfigurationFolder, int return Instance; } + /// + /// Registers the assembly resolve handler so that plugin assemblies in the "plugins" subdirectory + /// can be found during type resolution (e.g., when deserializing settings that reference plugin types). + /// Must be called before any code that may trigger Type.GetType() for plugin types. + /// + public static void RegisterAssemblyResolver () + { + AppDomain.CurrentDomain.AssemblyResolve -= ColumnizerResolveEventHandler; + AppDomain.CurrentDomain.AssemblyResolve += ColumnizerResolveEventHandler; + + //// Wire up the converter's assembly loader to go through validated loading + //LogExpert.Core.Classes.JsonConverters.ColumnizerJsonConverter.AssemblyLoader = LoadAndValidateAssembly; + } + #endregion #region Properties From 2e454d8581cb33a5cb3c3af4bf5d908f86656ebd Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 13 May 2026 13:01:19 +0200 Subject: [PATCH 2/6] single line not showing bugfix --- .../Controls/LogWindow/ColumnCache.cs | 14 +++++++++++++- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/ColumnCache.cs b/src/LogExpert.UI/Controls/LogWindow/ColumnCache.cs index b663d861..dea6b609 100644 --- a/src/LogExpert.UI/Controls/LogWindow/ColumnCache.cs +++ b/src/LogExpert.UI/Controls/LogWindow/ColumnCache.cs @@ -100,7 +100,19 @@ internal void MarkPrefetchStale () internal IColumnizedLogLineMemory GetColumnsForLine (ILogfileReader logFileReader, int lineNumber, ILogLineMemoryColumnizer columnizer, ColumnizerCallback columnizerCallback) { - if (_lastColumnizer != columnizer || (_lastLineNumber != lineNumber && _cachedColumns != null) || columnizerCallback.LineNum != lineNumber) + // Re-fetch when: + // - the columnizer instance changed + // - we are asked for a different line number + // - the callback's line number is out of sync with the requested line + // - the cache is empty (null) — without this, a single null fetch for a given + // lineNumber poisons the cache and every subsequent call for that same line + // returns null. This is visible as a permanently blank row when the grid + // only displays a single row (e.g. CSV file with header + 1 data line and + // header dropped by the CsvColumnizer's PreProcessLine). + if (_lastColumnizer != columnizer + || _lastLineNumber != lineNumber + || columnizerCallback.LineNum != lineNumber + || _cachedColumns == null) { _lastColumnizer = columnizer; _lastLineNumber = lineNumber; diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index dc35160e..241d0211 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -6521,7 +6521,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex) return value != null && !value.DisplayValue.IsEmpty ? value - : value; + : Column.EmptyColumn; } return columnIndex == 2 @@ -6602,7 +6602,7 @@ private IColumnMemory GetFilterCellValue (int rowIndex, int columnIndex) return value != null && !value.DisplayValue.IsEmpty ? value - : value; + : Column.EmptyColumn; } return columnIndex == 2 @@ -6683,6 +6683,18 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi ? _columnCache.GetPrefetchedLine(rowIndex) : _filterColumnCache.GetPrefetchedLine(rowIndex); + if (line == null) + { + // Fallback: prefetch a single-row range covering the requested row and retry. + // This handles the case where CellPainting runs before the grid's layout has + // populated FirstDisplayedScrollingRowIndex / DisplayedRowCount (common for + // very small files with only one visible row), and PrefetchVisibleLines + // therefore short-circuits without pinning anything. + var targetCache = !isFilteredGridView ? _columnCache : _filterColumnCache; + targetCache.Prefetch(_logFileReader, rowIndex, 1); + line = targetCache.GetPrefetchedLine(rowIndex); + } + if (line == null) { _logger.Warn("CellPainting: null line for rowIndex={0}, isFilteredGridView={1}", rowIndex, isFilteredGridView); From 5dc1c3df2949a66d17a8b304fbe9fef709ea1ebb Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 13 May 2026 13:11:27 +0200 Subject: [PATCH 3/6] regression tests --- .../Controls/ColumnCacheTests.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/LogExpert.Tests/Controls/ColumnCacheTests.cs diff --git a/src/LogExpert.Tests/Controls/ColumnCacheTests.cs b/src/LogExpert.Tests/Controls/ColumnCacheTests.cs new file mode 100644 index 00000000..d107187a --- /dev/null +++ b/src/LogExpert.Tests/Controls/ColumnCacheTests.cs @@ -0,0 +1,101 @@ +using ColumnizerLib; + +using LogExpert.Core.Callback; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Controls; + +[TestFixture] +public class ColumnCacheTests +{ + /// + /// Regression test for a cache-poisoning bug in : + /// when the underlying returned null + /// (e.g. fast-fail timeout) for a given line, the cache stored that null and + /// permanently returned null for every subsequent request of the same line number. + /// This manifested in the GUI as a blank data row in a CSV file containing a header + /// plus exactly one data line (header was dropped by CsvColumnizer's PreProcessLine, + /// leaving a single grid row that was repeatedly requested for paint). + /// The fix adds _cachedColumns == null to the re-fetch condition so a null + /// result is never cached. + /// + [Test] + public void GetColumnsForLine_NullThenValidLine_ReturnsColumnsOnSecondCall () + { + const int lineNumber = 0; + + var validLine = new Mock().Object; + var splitResult = new Mock().Object; + + var readerMock = new Mock(); + readerMock + .SetupSequence(r => r.GetLogLineMemoryWithWait(lineNumber)) + .Returns(Task.FromResult(null)) + .Returns(Task.FromResult(validLine)); + + var columnizerMock = new Mock(); + columnizerMock + .Setup(c => c.SplitLine(It.IsAny(), validLine)) + .Returns(splitResult); + + var logWindowMock = new Mock(); + var callback = new ColumnizerCallback(logWindowMock.Object); + + var cache = new ColumnCache(); + + // First call: reader returns null -> cache must NOT store this null. + var firstResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); + Assert.That(firstResult, Is.Null, "First call should return null because the reader returned null."); + + // Second call for the SAME line: reader now returns a valid line. + // Before the fix this would return the cached null and never call SplitLine. + var secondResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); + + Assert.That(secondResult, Is.SameAs(splitResult), "Second call must re-fetch and return the freshly split columns instead of a cached null."); + readerMock.Verify(r => r.GetLogLineMemoryWithWait(lineNumber), Times.Exactly(2)); + columnizerMock.Verify(c => c.SplitLine(It.IsAny(), validLine), Times.Once); + } + + /// + /// Sanity check that a valid result IS cached: requesting the same line twice + /// with a successful first fetch must not call the reader/columnizer a second time. + /// + [Test] + public void GetColumnsForLine_SameLineTwice_UsesCachedValue () + { + const int lineNumber = 0; + + var validLine = new Mock().Object; + var splitResult = new Mock().Object; + + var readerMock = new Mock(); + readerMock + .Setup(r => r.GetLogLineMemoryWithWait(lineNumber)) + .Returns(Task.FromResult(validLine)); + + var columnizerMock = new Mock(); + columnizerMock + .Setup(c => c.SplitLine(It.IsAny(), validLine)) + .Returns(splitResult); + + var logWindowMock = new Mock(); + var callback = new ColumnizerCallback(logWindowMock.Object); + + var cache = new ColumnCache(); + + var firstResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); + // callback.LineNum is now equal to lineNumber (set by GetColumnsForLine), so the + // second call should hit the cache. + var secondResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); + + Assert.That(firstResult, Is.SameAs(splitResult)); + Assert.That(secondResult, Is.SameAs(splitResult)); + readerMock.Verify(r => r.GetLogLineMemoryWithWait(lineNumber), Times.Once); + columnizerMock.Verify(c => c.SplitLine(It.IsAny(), validLine), Times.Once); + } +} From fb45312fc59be9b77d3b6d0bcb9935b95cb0cdb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 11:15:18 +0000 Subject: [PATCH 4/6] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 7e995296..aaf3c18d 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-05-11 11:06:48 UTC + /// Generated: 2026-05-13 11:15:16 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "18CCC14A0B0DF8EC3389BFEF8B6B83CDD0361C115506006B5B9D861A3D46DBF4", + ["AutoColumnizer.dll"] = "63ADBAA0DD74A95DC0D880D10C6CF73A4A9FF384DF4336CD4258B046C717BAF6", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "BF91C68897731C91F78C9BF5E12921541DA3830C5D0A51DE39B86CC54C547218", - ["CsvColumnizer.dll (x86)"] = "BF91C68897731C91F78C9BF5E12921541DA3830C5D0A51DE39B86CC54C547218", - ["DefaultPlugins.dll"] = "6CA00475DCC3CA3A305AD9F42E700D8FED57BAE3A97B15974F7DB34963E7074B", - ["FlashIconHighlighter.dll"] = "F973F35FF8374A2598F63EFB72821B387214EEB64094469B922D7FC23EAE2822", - ["GlassfishColumnizer.dll"] = "02228691B9C2C3595C45710B3870BEFFD60071A3653099F80514040D2F52FC2F", - ["JsonColumnizer.dll"] = "CF85A552AA2AA7B7EA483477E405ED36EAA7E8B0B654AB782804DFD30EF39356", - ["JsonCompactColumnizer.dll"] = "F83694079903B2A31989BEFE82F4AAE02A507E5C8F52A4BCE89AA3773A948844", - ["Log4jXmlColumnizer.dll"] = "8E4BE48F8C97747B231C031B3378F91C676B1C6350ADA4F3C457D5A9AB23E342", - ["LogExpert.Core.dll"] = "E90A83EF7032BAA2C2B28745F6DE34E098F900B4A35C47ED0C9C2B922456ADEA", - ["LogExpert.Resources.dll"] = "172F5D8D56262F5842E35B5C94B755F70C0AC0138A04992ECF8BB0A0BA079830", + ["CsvColumnizer.dll"] = "A2933C0E61B332DDA965088FF1C83A78886E6659D68100007A2F073B2EFFCC64", + ["CsvColumnizer.dll (x86)"] = "A2933C0E61B332DDA965088FF1C83A78886E6659D68100007A2F073B2EFFCC64", + ["DefaultPlugins.dll"] = "0B11FDDEBB96215773A46C6FE7CCB15D2818AF866D07DE2855FF69ABC888004F", + ["FlashIconHighlighter.dll"] = "54C3134734C0B8B777057F7E5CE0E057D65F0180387D8E7EF90DB9A4EE2421A8", + ["GlassfishColumnizer.dll"] = "A1DC6F416F7CC0F3C7B7AE6E34335623BB1ABC533541D498EAE986F8E3CBE5DF", + ["JsonColumnizer.dll"] = "3BAAB510DC5F16B80CC993A2306A89892D0533B3D0AA05A158221268546E7011", + ["JsonCompactColumnizer.dll"] = "21662B2D609367183EE73E393F1256019A2D879D5D4E0F7CD169384CA0BE0D79", + ["Log4jXmlColumnizer.dll"] = "0E91DF6B0ECC5ABE191C75B4B7E5DACE2D379C904B08BAF8045BDFAA2859C7FD", + ["LogExpert.Core.dll"] = "DE1062028E3C03D7E428F7724A8174CEED9B5E41216B7964F5847DEAE3E62A53", + ["LogExpert.Resources.dll"] = "C0B7F51AB14ADAB7616F1851F18657612DCCC5093C9DCD0EDCC45F2F9355AC28", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "C038DAB2B98EEBC6C30A1C248E954727BF4D865D57CF7F5ACE071F8F1BD4F101", - ["SftpFileSystem.dll"] = "4268F380611E6B9A41237DE21134866BCD294453C7CBA222A684AF0391B32F10", - ["SftpFileSystem.dll (x86)"] = "28F9A111D923130B749C7180A79E86BE20488AB1566F4E366CD4F628B07F30DA", - ["SftpFileSystem.Resources.dll"] = "4764889B4A7F81D60B144704F95E04FE75CC3EA9FC2EA8C6DC1E8376D84B1501", - ["SftpFileSystem.Resources.dll (x86)"] = "4764889B4A7F81D60B144704F95E04FE75CC3EA9FC2EA8C6DC1E8376D84B1501", + ["RegexColumnizer.dll"] = "A56061C4D45DF8BBCE66D14F40603BA68C7E0C121E9FCF5D45EE6E09C24B4022", + ["SftpFileSystem.dll"] = "353CF8521BD8D772A22AE081944AEF9582F4FF77E79F4622CD2D0751B73FFC10", + ["SftpFileSystem.dll (x86)"] = "F9FC3B14C5BB0078958DB4C65B7D995281D683477D489A83D00E13B728A54DED", + ["SftpFileSystem.Resources.dll"] = "61185ADC66BB9D7654B5875BAEDFDEDCA3B52A43706AA04BFE27457B6A8585F2", + ["SftpFileSystem.Resources.dll (x86)"] = "61185ADC66BB9D7654B5875BAEDFDEDCA3B52A43706AA04BFE27457B6A8585F2", }; } From 8027f967bb6466b97e34d8a314fd59e6314116ac Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 13 May 2026 14:03:50 +0200 Subject: [PATCH 5/6] review comments --- src/CsvColumnizer/CsvColumnizer.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs index 9364c427..afce24e8 100644 --- a/src/CsvColumnizer/CsvColumnizer.cs +++ b/src/CsvColumnizer/CsvColumnizer.cs @@ -299,22 +299,22 @@ public Priority GetPriority (string fileName, IEnumerable sample /// Auto-detects the delimiter using CsvHelper's built-in detection. /// After parsing, the detected delimiter is extracted from csv.Parser.Delimiter. /// - private void AutoDetectDelimiter (ReadOnlyMemory firstLine) + private void AutoDetectDelimiter (ReadOnlyMemory lineContent) { - if (firstLine.IsEmpty) + if (lineContent.IsEmpty) { return; } try { - var config = new CsvHelper.Configuration.CsvConfiguration(CultureInfo.InvariantCulture) + var autoDetectedConfig = new CsvHelper.Configuration.CsvConfiguration(CultureInfo.InvariantCulture) { DetectDelimiter = true, DetectDelimiterValues = [",", ";", "\t", "|"] }; - using CsvReader csv = new(new StringReader(firstLine.ToString()), config); + using CsvReader csv = new(new StringReader(lineContent.ToString()), autoDetectedConfig); _ = csv.Read(); var detectedDelimiter = csv.Parser.Delimiter; From 5b47e1e9f79eeaa2a928fb7027cf1a9c0708dcb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 12:06:41 +0000 Subject: [PATCH 6/6] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index aaf3c18d..e1495fb6 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-05-13 11:15:16 UTC + /// Generated: 2026-05-13 12:06:40 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "63ADBAA0DD74A95DC0D880D10C6CF73A4A9FF384DF4336CD4258B046C717BAF6", + ["AutoColumnizer.dll"] = "078FB0FDA2419CEECA8DB38E126AAF58907F0F89887E50265B7201761EAA3CAA", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "A2933C0E61B332DDA965088FF1C83A78886E6659D68100007A2F073B2EFFCC64", - ["CsvColumnizer.dll (x86)"] = "A2933C0E61B332DDA965088FF1C83A78886E6659D68100007A2F073B2EFFCC64", - ["DefaultPlugins.dll"] = "0B11FDDEBB96215773A46C6FE7CCB15D2818AF866D07DE2855FF69ABC888004F", - ["FlashIconHighlighter.dll"] = "54C3134734C0B8B777057F7E5CE0E057D65F0180387D8E7EF90DB9A4EE2421A8", - ["GlassfishColumnizer.dll"] = "A1DC6F416F7CC0F3C7B7AE6E34335623BB1ABC533541D498EAE986F8E3CBE5DF", - ["JsonColumnizer.dll"] = "3BAAB510DC5F16B80CC993A2306A89892D0533B3D0AA05A158221268546E7011", - ["JsonCompactColumnizer.dll"] = "21662B2D609367183EE73E393F1256019A2D879D5D4E0F7CD169384CA0BE0D79", - ["Log4jXmlColumnizer.dll"] = "0E91DF6B0ECC5ABE191C75B4B7E5DACE2D379C904B08BAF8045BDFAA2859C7FD", - ["LogExpert.Core.dll"] = "DE1062028E3C03D7E428F7724A8174CEED9B5E41216B7964F5847DEAE3E62A53", - ["LogExpert.Resources.dll"] = "C0B7F51AB14ADAB7616F1851F18657612DCCC5093C9DCD0EDCC45F2F9355AC28", + ["CsvColumnizer.dll"] = "F36D8B7534A64170761C778B8C314219FD2CBC5188EBA22C17FCD2E46247F346", + ["CsvColumnizer.dll (x86)"] = "F36D8B7534A64170761C778B8C314219FD2CBC5188EBA22C17FCD2E46247F346", + ["DefaultPlugins.dll"] = "068DBD7CB83C4F1F8E225E9DB904936334558D48FFEEF9CCE0F8AB640372171D", + ["FlashIconHighlighter.dll"] = "5970025F960401362A06D4F363AE71CED42168A61835BDC41354A62C5F9A94AC", + ["GlassfishColumnizer.dll"] = "A5EE660B16C647813C70431EABAEF60B0E2F4FC20139E9D0AAD6C7CEF6F80983", + ["JsonColumnizer.dll"] = "0BFD8B716CCFB64AF5B1859F8632D770E6070CE80BC4B49E0E531743079D3F0E", + ["JsonCompactColumnizer.dll"] = "A9571F6C614AB8522B47ACA96DB1546A86EEB2F37106082AD0869CFBA2FC24C1", + ["Log4jXmlColumnizer.dll"] = "4C2F1C3F31526E06BDF046DEE457FC541CEAC10A94332581B767719CA3ACDF0D", + ["LogExpert.Core.dll"] = "606C618ACF9A6A61BAE8C1FEC0A200D5ADC2211BB5ECCB6497F23F7517E25F63", + ["LogExpert.Resources.dll"] = "298E19EC018D09CB43BA3CC2EC8C9B7D78DC5319143082667A62D158DBB0CA2A", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "A56061C4D45DF8BBCE66D14F40603BA68C7E0C121E9FCF5D45EE6E09C24B4022", - ["SftpFileSystem.dll"] = "353CF8521BD8D772A22AE081944AEF9582F4FF77E79F4622CD2D0751B73FFC10", - ["SftpFileSystem.dll (x86)"] = "F9FC3B14C5BB0078958DB4C65B7D995281D683477D489A83D00E13B728A54DED", - ["SftpFileSystem.Resources.dll"] = "61185ADC66BB9D7654B5875BAEDFDEDCA3B52A43706AA04BFE27457B6A8585F2", - ["SftpFileSystem.Resources.dll (x86)"] = "61185ADC66BB9D7654B5875BAEDFDEDCA3B52A43706AA04BFE27457B6A8585F2", + ["RegexColumnizer.dll"] = "096703BC5A8733FD4FC9C4B951813F0DCEE8DD38F265FCAF5E020AA70F95BA10", + ["SftpFileSystem.dll"] = "DE9B2D3840301DDB280538A611A282618A5CCC49BF8A7B9FA4105229E4980D82", + ["SftpFileSystem.dll (x86)"] = "E29CA0E5D3E93929E9EB6F20475D7BAE063308F3E7056F82681FBA14330972D2", + ["SftpFileSystem.Resources.dll"] = "D696CE8BF082E957B352DC11EEF4505753FBF2ACFB714E9C2E6EDE1639B0E5E0", + ["SftpFileSystem.Resources.dll (x86)"] = "D696CE8BF082E957B352DC11EEF4505753FBF2ACFB714E9C2E6EDE1639B0E5E0", }; }