|
| 1 | +using ColumnizerLib; |
| 2 | + |
| 3 | +using LogExpert.Core.Callback; |
| 4 | +using LogExpert.Core.Interfaces; |
| 5 | +using LogExpert.UI.Controls.LogWindow; |
| 6 | + |
| 7 | +using Moq; |
| 8 | + |
| 9 | +using NUnit.Framework; |
| 10 | + |
| 11 | +namespace LogExpert.Tests.Controls; |
| 12 | + |
| 13 | +[TestFixture] |
| 14 | +public class ColumnCacheTests |
| 15 | +{ |
| 16 | + /// <summary> |
| 17 | + /// Regression test for a cache-poisoning bug in <see cref="ColumnCache.GetColumnsForLine"/>: |
| 18 | + /// when the underlying <see cref="ILogfileReader.GetLogLineMemoryWithWait"/> returned null |
| 19 | + /// (e.g. fast-fail timeout) for a given line, the cache stored that null and |
| 20 | + /// permanently returned null for every subsequent request of the same line number. |
| 21 | + /// This manifested in the GUI as a blank data row in a CSV file containing a header |
| 22 | + /// plus exactly one data line (header was dropped by CsvColumnizer's PreProcessLine, |
| 23 | + /// leaving a single grid row that was repeatedly requested for paint). |
| 24 | + /// The fix adds <c>_cachedColumns == null</c> to the re-fetch condition so a null |
| 25 | + /// result is never cached. |
| 26 | + /// </summary> |
| 27 | + [Test] |
| 28 | + public void GetColumnsForLine_NullThenValidLine_ReturnsColumnsOnSecondCall () |
| 29 | + { |
| 30 | + const int lineNumber = 0; |
| 31 | + |
| 32 | + var validLine = new Mock<ILogLineMemory>().Object; |
| 33 | + var splitResult = new Mock<IColumnizedLogLineMemory>().Object; |
| 34 | + |
| 35 | + var readerMock = new Mock<ILogfileReader>(); |
| 36 | + readerMock |
| 37 | + .SetupSequence(r => r.GetLogLineMemoryWithWait(lineNumber)) |
| 38 | + .Returns(Task.FromResult<ILogLineMemory>(null)) |
| 39 | + .Returns(Task.FromResult(validLine)); |
| 40 | + |
| 41 | + var columnizerMock = new Mock<ILogLineMemoryColumnizer>(); |
| 42 | + columnizerMock |
| 43 | + .Setup(c => c.SplitLine(It.IsAny<ILogLineMemoryColumnizerCallback>(), validLine)) |
| 44 | + .Returns(splitResult); |
| 45 | + |
| 46 | + var logWindowMock = new Mock<ILogWindow>(); |
| 47 | + var callback = new ColumnizerCallback(logWindowMock.Object); |
| 48 | + |
| 49 | + var cache = new ColumnCache(); |
| 50 | + |
| 51 | + // First call: reader returns null -> cache must NOT store this null. |
| 52 | + var firstResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); |
| 53 | + Assert.That(firstResult, Is.Null, "First call should return null because the reader returned null."); |
| 54 | + |
| 55 | + // Second call for the SAME line: reader now returns a valid line. |
| 56 | + // Before the fix this would return the cached null and never call SplitLine. |
| 57 | + var secondResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); |
| 58 | + |
| 59 | + Assert.That(secondResult, Is.SameAs(splitResult), "Second call must re-fetch and return the freshly split columns instead of a cached null."); |
| 60 | + readerMock.Verify(r => r.GetLogLineMemoryWithWait(lineNumber), Times.Exactly(2)); |
| 61 | + columnizerMock.Verify(c => c.SplitLine(It.IsAny<ILogLineMemoryColumnizerCallback>(), validLine), Times.Once); |
| 62 | + } |
| 63 | + |
| 64 | + /// <summary> |
| 65 | + /// Sanity check that a valid result IS cached: requesting the same line twice |
| 66 | + /// with a successful first fetch must not call the reader/columnizer a second time. |
| 67 | + /// </summary> |
| 68 | + [Test] |
| 69 | + public void GetColumnsForLine_SameLineTwice_UsesCachedValue () |
| 70 | + { |
| 71 | + const int lineNumber = 0; |
| 72 | + |
| 73 | + var validLine = new Mock<ILogLineMemory>().Object; |
| 74 | + var splitResult = new Mock<IColumnizedLogLineMemory>().Object; |
| 75 | + |
| 76 | + var readerMock = new Mock<ILogfileReader>(); |
| 77 | + readerMock |
| 78 | + .Setup(r => r.GetLogLineMemoryWithWait(lineNumber)) |
| 79 | + .Returns(Task.FromResult(validLine)); |
| 80 | + |
| 81 | + var columnizerMock = new Mock<ILogLineMemoryColumnizer>(); |
| 82 | + columnizerMock |
| 83 | + .Setup(c => c.SplitLine(It.IsAny<ILogLineMemoryColumnizerCallback>(), validLine)) |
| 84 | + .Returns(splitResult); |
| 85 | + |
| 86 | + var logWindowMock = new Mock<ILogWindow>(); |
| 87 | + var callback = new ColumnizerCallback(logWindowMock.Object); |
| 88 | + |
| 89 | + var cache = new ColumnCache(); |
| 90 | + |
| 91 | + var firstResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); |
| 92 | + // callback.LineNum is now equal to lineNumber (set by GetColumnsForLine), so the |
| 93 | + // second call should hit the cache. |
| 94 | + var secondResult = cache.GetColumnsForLine(readerMock.Object, lineNumber, columnizerMock.Object, callback); |
| 95 | + |
| 96 | + Assert.That(firstResult, Is.SameAs(splitResult)); |
| 97 | + Assert.That(secondResult, Is.SameAs(splitResult)); |
| 98 | + readerMock.Verify(r => r.GetLogLineMemoryWithWait(lineNumber), Times.Once); |
| 99 | + columnizerMock.Verify(c => c.SplitLine(It.IsAny<ILogLineMemoryColumnizerCallback>(), validLine), Times.Once); |
| 100 | + } |
| 101 | +} |
0 commit comments