diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs index 1453beda..afce24e8 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 lineContent) + { + if (lineContent.IsEmpty) + { + return; + } + + try + { + var autoDetectedConfig = new CsvHelper.Configuration.CsvConfiguration(CultureInfo.InvariantCulture) + { + DetectDelimiter = true, + DetectDelimiterValues = [",", ";", "\t", "|"] + }; + + using CsvReader csv = new(new StringReader(lineContent.ToString()), autoDetectedConfig); + _ = 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/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); + } +} 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.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); 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/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 7e995296..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-11 11:06:48 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"] = "18CCC14A0B0DF8EC3389BFEF8B6B83CDD0361C115506006B5B9D861A3D46DBF4", + ["AutoColumnizer.dll"] = "078FB0FDA2419CEECA8DB38E126AAF58907F0F89887E50265B7201761EAA3CAA", ["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"] = "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"] = "C038DAB2B98EEBC6C30A1C248E954727BF4D865D57CF7F5ACE071F8F1BD4F101", - ["SftpFileSystem.dll"] = "4268F380611E6B9A41237DE21134866BCD294453C7CBA222A684AF0391B32F10", - ["SftpFileSystem.dll (x86)"] = "28F9A111D923130B749C7180A79E86BE20488AB1566F4E366CD4F628B07F30DA", - ["SftpFileSystem.Resources.dll"] = "4764889B4A7F81D60B144704F95E04FE75CC3EA9FC2EA8C6DC1E8376D84B1501", - ["SftpFileSystem.Resources.dll (x86)"] = "4764889B4A7F81D60B144704F95E04FE75CC3EA9FC2EA8C6DC1E8376D84B1501", + ["RegexColumnizer.dll"] = "096703BC5A8733FD4FC9C4B951813F0DCEE8DD38F265FCAF5E020AA70F95BA10", + ["SftpFileSystem.dll"] = "DE9B2D3840301DDB280538A611A282618A5CCC49BF8A7B9FA4105229E4980D82", + ["SftpFileSystem.dll (x86)"] = "E29CA0E5D3E93929E9EB6F20475D7BAE063308F3E7056F82681FBA14330972D2", + ["SftpFileSystem.Resources.dll"] = "D696CE8BF082E957B352DC11EEF4505753FBF2ACFB714E9C2E6EDE1639B0E5E0", + ["SftpFileSystem.Resources.dll (x86)"] = "D696CE8BF082E957B352DC11EEF4505753FBF2ACFB714E9C2E6EDE1639B0E5E0", }; } 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