diff --git a/CONTEXT.md b/CONTEXT.md index ee1364669..f203b80b0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,6 +23,25 @@ meaning; do not redefine them locally. construction, since the bulk `HighlightBookmarkScanner` has no access to the side-effecting triggers. +## Marker Bar + +- **Marker Bar** — A compact overview beside a Log Window that shows where + highlights, bookmarks, Log Search hits, and Window Filter hits occur across + the loaded file. +- **Marker** — An indicator in the Marker Bar representing one or more matching + Log Lines. Markers are grouped by category; they do not create or replace + Bookmarks. +- **Marker Snapshot** — An immutable collection of highlight or Log Search + matches discovered for the Marker Bar, together with how many Log Lines + have been scanned and any discovery failure. +- **Bookmark** — A user- or auto-generated annotation attached to a specific + Log Line, carrying optional comment text and an overlay. A Bookmark can be + represented by a Marker in the bookmark category; the two terms are not synonyms. + +*Avoid*: "marker" when referring to the underlying Bookmark annotation; +bare "snapshot" when the distinction between **Marker Snapshot**, +**Columnizer Snapshot**, and **Session Snapshot** matters. + ## Audio alerts - **Audio Alert** — A sound played when a tail-only highlight match occurs. @@ -293,6 +312,9 @@ layer). line into columns. Each loaded log window has exactly one active columnizer at a time. The set of available columnizers is owned by `PluginRegistry`. +- **Columnizer Snapshot** — An independent Columnizer with the active + Columnizer's configuration and detected column layout, ready for Marker Bar + discovery. Its parsing state is separate from the active Columnizer's. - **Columnizer Mask Entry** (`ColumnizerMaskEntry`) — One user-configured row on the Settings → Columnizers tab. Pairs a **Mask**, a **Mask Type**, and a **Columnizer Name**. Stored in `Preferences.ColumnizerMaskList`. diff --git a/src/ColumnizerLib/IColumnizerSnapshotMemory.cs b/src/ColumnizerLib/IColumnizerSnapshotMemory.cs new file mode 100644 index 000000000..83662ec2a --- /dev/null +++ b/src/ColumnizerLib/IColumnizerSnapshotMemory.cs @@ -0,0 +1,19 @@ +namespace ColumnizerLib; + +/// +/// Optional capability for capturing a columnizer's current parsing state for background use. +/// +/// +/// The snapshot must be initialized with the current configuration and detected column layout, +/// without sharing mutable parsing state with the original columnizer. The Marker Bar captures +/// the snapshot on the UI thread and uses it on a worker thread without calling +/// . Capture must be quick and perform no file I/O. +/// +public interface IColumnizerSnapshotMemory +{ + /// + /// Creates an independent, initialized columnizer with the current configuration and detected layout. + /// + /// A columnizer ready to parse log lines on a worker thread. + ILogLineMemoryColumnizer CreateSnapshot (); +} \ No newline at end of file diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs index afce24e8b..e780fd2c7 100644 --- a/src/CsvColumnizer/CsvColumnizer.cs +++ b/src/CsvColumnizer/CsvColumnizer.cs @@ -17,7 +17,7 @@ namespace CsvColumnizer; /// The IPreProcessColumnizer is implemented to read field names from the very first line of the file. Then /// the line is dropped. So it's not seen by LogExpert. The field names will be used as column names. /// -public class CsvColumnizer : ILogLineMemoryColumnizer, IInitColumnizerMemory, IColumnizerConfiguratorMemory, IPreProcessColumnizerMemory, IColumnizerPriorityMemory +public class CsvColumnizer : ILogLineMemoryColumnizer, IInitColumnizerMemory, IColumnizerConfiguratorMemory, IPreProcessColumnizerMemory, IColumnizerPriorityMemory, IColumnizerSnapshotMemory { #region Fields @@ -220,6 +220,35 @@ public void DeSelected (ILogLineMemoryColumnizerCallback callback) // nothing to do } + public ILogLineMemoryColumnizer CreateSnapshot () + { + CsvColumnizerConfig config = new() + { + CommentChar = _config.CommentChar, + DelimiterChar = _config.DelimiterChar, + EscapeChar = _config.EscapeChar, + HasFieldNames = _config.HasFieldNames, + MinColumns = _config.MinColumns, + QuoteChar = _config.QuoteChar, + VersionBuild = _config.VersionBuild + }; + config.ConfigureReaderConfiguration(); + + CsvColumnizer clone = new() + { + _config = config, + _isValidCsv = _isValidCsv, + _firstLine = _firstLine == null ? null : new CsvLogLine(_firstLine.FullLine.ToString(), _firstLine.LineNumber) + }; + + foreach (var column in _columnList) + { + clone._columnList.Add(new CsvColumn(column.Name)); + } + + return clone; + } + public void Configure (ILogLineMemoryColumnizerCallback callback, string configDir) { var configPath = configDir + "\\" + CONFIGFILENAME; diff --git a/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs b/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs index ffd8f5ced..7e7765d21 100644 --- a/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs +++ b/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs @@ -11,7 +11,7 @@ [assembly: SupportedOSPlatform("windows")] namespace Log4jXmlColumnizer; -public class Log4jXmlColumnizer : ILogLineMemoryXmlColumnizer, IColumnizerConfiguratorMemory, IColumnizerPriorityMemory +public class Log4jXmlColumnizer : ILogLineMemoryXmlColumnizer, IColumnizerConfiguratorMemory, IColumnizerPriorityMemory, IColumnizerSnapshotMemory { #region Fields @@ -278,6 +278,24 @@ public void LoadConfig (string configDir) } } + public ILogLineMemoryColumnizer CreateSnapshot () + { + Log4jXmlColumnizer clone = new() + { + _config = new Log4jXmlColumnizerConfig(GetAllColumnNames()) + { + LocalTimestamps = _config.LocalTimestamps, + ColumnList = [.. _config.ColumnList.Select(entry => new Log4jColumnEntry(entry.ColumnName, entry.ColumnIndex, entry.MaxLen) + { + Visible = entry.Visible + })] + }, + _timeOffset = _timeOffset + }; + + return clone; + } + public Priority GetPriority (string fileName, IEnumerable samples) { ArgumentNullException.ThrowIfNull(fileName); diff --git a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs index 7432df739..a89db391a 100644 --- a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs +++ b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs @@ -85,6 +85,14 @@ public int GetBookmarkIndexForLine (int lineNum) } } + public int[] GetBookmarkLineNumbers () + { + lock (_bookmarkListLock) + { + return [.. BookmarkList.Keys]; + } + } + public Entities.Bookmark GetBookmarkForLine (int lineNum) { lock (_bookmarkListLock) @@ -148,19 +156,22 @@ public bool ConvertToManualBookmark (int lineNum) public void ShiftBookmarks (int offset) { - SortedList newBookmarkList = []; - - foreach (var bookmark in BookmarkList.Values) + lock (_bookmarkListLock) { - var line = bookmark.LineNum - offset; - if (line >= 0) + SortedList newBookmarkList = []; + + foreach (var bookmark in BookmarkList.Values) { - bookmark.LineNum = line; - newBookmarkList.Add(line, bookmark); + var line = bookmark.LineNum - offset; + if (line >= 0) + { + bookmark.LineNum = line; + newBookmarkList.Add(line, bookmark); + } } - } - BookmarkList = newBookmarkList; + BookmarkList = newBookmarkList; + } } public int FindPrevBookmarkIndex (int lineNum) diff --git a/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs b/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs index fffd3d3d8..05b658ac8 100644 --- a/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs +++ b/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs @@ -13,7 +13,7 @@ namespace LogExpert.Core.Classes.Columnizer; /// memory-efficient log line processing and columnizer prioritization, making it suitable for integration with log /// viewers or analysis tools that require flexible column extraction. /// -public class SquareBracketColumnizer : ILogLineMemoryColumnizer, IColumnizerPriorityMemory +public class SquareBracketColumnizer : ILogLineMemoryColumnizer, IColumnizerPriorityMemory, IColumnizerSnapshotMemory { #region ILogLineMemoryColumnizer implementation @@ -41,6 +41,19 @@ public SquareBracketColumnizer (int columnCount, bool isTimeExists) : this() } } + /// + /// Creates an independent copy of the detected column layout and time offset. + /// + public ILogLineMemoryColumnizer CreateSnapshot () + { + return new SquareBracketColumnizer + { + _columnCount = _columnCount, + _isTimeExists = _isTimeExists, + _timeOffset = _timeOffset + }; + } + /// /// Determines whether timeshift functionality is implemented. /// diff --git a/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs b/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs new file mode 100644 index 000000000..20f73dd79 --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs @@ -0,0 +1,57 @@ +namespace LogExpert.Core.Classes.Marker; + +/// A populated vertical pixel, including its logical range and navigation target. +public readonly record struct MarkerBucket (int Pixel, int FirstLine, int LastLine, int Count, int TargetLine, int ColorArgb) +{ + /// Aggregates a line-ordered index without sampling and resolves inherited foregrounds; duplicate lines count only once. + public static IReadOnlyList Aggregate (IEnumerable matches, int lineCount, int height, int defaultForegroundArgb) + { + ArgumentNullException.ThrowIfNull(matches); + if (lineCount <= 0 || height <= 0) + { + return []; + } + + var buckets = new MarkerBucket[height]; + var priorities = new int[height]; + Array.Fill(priorities, int.MaxValue); + var previousLine = -1; + foreach (var match in matches) + { + var line = match.LineNumber; + if (line < 0 || line >= lineCount) + { + continue; + } + + var pixel = lineCount == 1 ? 0 : (int)((long)line * (height - 1) / (lineCount - 1)); + var bucket = buckets[pixel]; + // Invert the floor mapping above: a bucket starts at ceil(pixel * (lines - 1) / (height - 1)) + // and ends immediately before the next bucket starts. The final pixel includes the last line. + var first = height == 1 ? 0 : (int)(((long)pixel * (lineCount - 1) + height - 2) / (height - 1)); + var last = height == 1 || pixel == height - 1 || lineCount == 1 ? lineCount - 1 + : (int)(((long)(pixel + 1) * (lineCount - 1) + height - 2) / (height - 1)) - 1; + var target = bucket.Count == 0 ? line : bucket.TargetLine; + // Double distances to compare against the midpoint without rounding half-line ties. + var distance = Math.Abs(2L * line - first - last); + var targetDistance = Math.Abs(2L * target - first - last); + if (distance < targetDistance || (distance == targetDistance && line < target)) + { + target = line; + } + + var color = bucket.ColorArgb; + if (match.Priority < priorities[pixel]) + { + priorities[pixel] = match.Priority; + color = match.ColorArgb ?? defaultForegroundArgb; + } + + buckets[pixel] = new MarkerBucket(pixel, first, last, + bucket.Count + (line == previousLine ? 0 : 1), target, color); + previousLine = line; + } + + return buckets.Where(bucket => bucket.Count > 0).ToArray(); + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs b/src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs new file mode 100644 index 000000000..2901d6b91 --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs @@ -0,0 +1,121 @@ +using System.Drawing; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Entities; + +namespace LogExpert.Core.Classes.Marker; + +/// Snapshot of the matching and visual portions of marker criteria. Never invokes triggers. +public sealed class MarkerCriteria +{ + private readonly HighlightEntry[] _entries; + private HighlightEntry? _search; + + public bool IsEmpty => _search == null && !_entries.Any(IsVisual); + + private MarkerCriteria (HighlightEntry[] entries) + { + _entries = entries; + } + + public static MarkerCriteria ForHighlights (IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + return new MarkerCriteria(entries.Select(entry => (HighlightEntry)entry.Clone()).ToArray()); + } + + public static MarkerCriteria ForSearch (SearchParams search, int colorArgb) + { + ArgumentNullException.ThrowIfNull(search); + return new MarkerCriteria([]) + { + _search = string.IsNullOrEmpty(search.SearchText) ? null : new HighlightEntry + { + SearchText = search.SearchText, + IsRegex = search.IsRegex, + IsCaseSensitive = search.IsCaseSensitive, + BackgroundColor = Color.FromArgb(colorArgb) + } + }; + } + + public MarkerLine? Match (int lineNumber, ILogLineMemory line, + Func>? getColumns = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(line); + cancellationToken.ThrowIfCancellationRequested(); + if (_search != null) + { + var matched = _search.IsRegex + ? _search.Regex.IsMatch(line.FullLine.Span) + : line.FullLine.Span.Contains(_search.SearchText, + _search.IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); + return matched ? new MarkerLine(lineNumber, _search.BackgroundColor.ToArgb()) : null; + } + + IReadOnlyList? columns = null; + for (var priority = 0; priority < _entries.Length; priority++) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = _entries[priority]; + if (!IsVisual(entry)) + { + continue; + } + + bool matched; + if (entry.IsWordMatch) + { + columns ??= getColumns?.Invoke(lineNumber, line) + ?? [new Column { FullValue = line.FullLine }]; + matched = columns.Any(column => HasVisibleWordMatch(entry, column)); + } + else + { + matched = HighlightEvaluator.IsMatch(entry, line); + } + + if (matched) + { + int? color = HasBackground(entry) ? entry.BackgroundColor.ToArgb() + : HasForeground(entry) ? entry.ForegroundColor.ToArgb() : null; + return new MarkerLine(lineNumber, color, priority); + } + } + + return null; + } + + private static bool IsVisual (HighlightEntry entry) + { + return !entry.IsSearchHit && (HasBackground(entry) + || HasForeground(entry) || entry.IsBold); + } + + private static bool HasBackground (HighlightEntry entry) + { + return (!entry.IsWordMatch || !entry.NoBackground) && entry.BackgroundColor.A > 0; + } + + private static bool HasForeground (HighlightEntry entry) + { + return entry.ForegroundColor.A > 0; + } + + private static bool HasVisibleWordMatch (HighlightEntry entry, ITextValueMemory column) + { + // Word highlighting uses Regex even for literal text, against each displayed column. + foreach (var match in entry.Regex.EnumerateMatches(column.Text.Span)) + { + if (match.Length > 0) + { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Marker/MarkerIndex.cs b/src/LogExpert.Core/Classes/Marker/MarkerIndex.cs new file mode 100644 index 000000000..23c7d910f --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerIndex.cs @@ -0,0 +1,206 @@ +using System.Globalization; + +using ColumnizerLib; + +using LogExpert.Core.Interfaces; + +namespace LogExpert.Core.Classes.Marker; + +/// Owns a cancellable background index for one matching source in one Log Window. +public sealed class MarkerIndex : IDisposable +{ + private const int READ_BATCH_SIZE = 256; + private const int MATCH_CHUNK_SIZE = 4096; + private readonly Lock _gate = new(); + private ILogfileReader? _reader; + private MarkerCriteria? _criteria; + private Func>? _getColumns; + private MarkerSnapshot _snapshot = new([], 0); + private CancellationTokenSource? _scanCts; + private Task? _work; + private int _generation; + private bool _disposed; + + public MarkerSnapshot Snapshot + { + get + { + lock (_gate) + { + return _snapshot; + } + } + } + + public bool IsScanning + { + get + { + lock (_gate) + { + return _work != null; + } + } + } + + public void Reset (ILogfileReader? reader, MarkerCriteria? criteria, + Func>? getColumns = null) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _generation++; + _scanCts?.Cancel(); + _scanCts = null; + _work = null; + _reader = reader; + _criteria = criteria; + _getColumns = getColumns; + _snapshot = new MarkerSnapshot([], 0); + } + } + + public Task UpdateAsync (int lineCount, bool lastLineChanged = false) + { + lock (_gate) + { + if (_disposed || _reader == null || _criteria == null) + { + return Task.CompletedTask; + } + + if (_work != null) + { + return _work; + } + + lineCount = Math.Max(0, lineCount); + if (lineCount < _snapshot.ScannedLineCount) + { + _snapshot = new MarkerSnapshot([], 0); + } + + if (_snapshot.Error != null || (!lastLineChanged && lineCount == _snapshot.ScannedLineCount)) + { + return Task.CompletedTask; + } + + if (_criteria.IsEmpty) + { + _snapshot = new MarkerSnapshot([], lineCount); + return Task.CompletedTask; + } + + var reader = _reader; + var criteria = _criteria; + var columns = _getColumns; + var previous = _snapshot; + var generation = _generation; + var cts = new CancellationTokenSource(); + _scanCts = cts; + _work = Task.Run(() => Scan(reader, criteria, columns, previous, lineCount, generation, cts)); + return _work; + } + } + + public void Dispose () + { + lock (_gate) + { + _disposed = true; + _generation++; + _scanCts?.Cancel(); + _scanCts = null; + _work = null; + _reader = null; + _criteria = null; + _getColumns = null; + _snapshot = new MarkerSnapshot([], 0); + } + } + + private void Scan (ILogfileReader reader, MarkerCriteria criteria, + Func>? columns, + MarkerSnapshot previous, int lineCount, int generation, CancellationTokenSource cancellationSource) + { + using var scanCancellation = cancellationSource; + var start = Math.Max(0, previous.ScannedLineCount - 1); + List chunks = []; + List pending = new(MATCH_CHUNK_SIZE); + var scanned = start; + Exception? error = null; + try + { + // Retain completed chunks; only the last line can change during an append. + foreach (var chunk in previous.Chunks) + { + if (chunk[^1].LineNumber < start) + { + chunks.Add(chunk); + } + else + { + pending.AddRange(chunk.TakeWhile(match => match.LineNumber < start)); + } + } + + for (var batchStart = start; batchStart < lineCount;) + { + scanCancellation.Token.ThrowIfCancellationRequested(); + var batchEnd = (int)Math.Min((long)batchStart + READ_BATCH_SIZE, lineCount); + // Pin before reading and release after this small batch, never for the whole file. + using var pin = (reader as IBufferPinning)?.PinRange(batchStart, batchEnd - 1); + for (var lineNumber = batchStart; lineNumber < batchEnd; lineNumber++) + { + scanCancellation.Token.ThrowIfCancellationRequested(); + var line = reader.GetLogLineMemory(lineNumber) + ?? throw new IOException(string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_LineUnavailable, lineNumber + 1)); + var match = criteria.Match(lineNumber, line, columns, scanCancellation.Token); + if (match.HasValue) + { + pending.Add(match.Value); + if (pending.Count >= MATCH_CHUNK_SIZE) + { + chunks.Add(pending.ToArray()); + pending.Clear(); + } + } + + scanned = lineNumber + 1; + } + + batchStart = batchEnd; + } + } + catch (OperationCanceledException) when (scanCancellation.IsCancellationRequested) + { + // Reset/disposal owns invalidation; the generation check below rejects this result. + } + catch (Exception exception) + { + // Background boundary: report regex, reader and columnizer failures as an outcome. + error = exception; + } + finally + { + if (pending.Count > 0) + { + chunks.Add(pending.ToArray()); + } + + lock (_gate) + { + if (!_disposed && generation == _generation) + { + _snapshot = new MarkerSnapshot(chunks.ToArray(), scanned, error); + _work = null; + _scanCts = null; + } + } + } + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Marker/MarkerLine.cs b/src/LogExpert.Core/Classes/Marker/MarkerLine.cs new file mode 100644 index 000000000..3b691dfd1 --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerLine.cs @@ -0,0 +1,5 @@ +namespace LogExpert.Core.Classes.Marker; + +/// One matching logical line. Lower priorities precede higher priorities in the Highlight Group. +/// The rule color, or null to inherit the foreground supplied during aggregation. +public readonly record struct MarkerLine (int LineNumber, int? ColorArgb, int Priority = 0); \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Marker/MarkerSnapshot.cs b/src/LogExpert.Core/Classes/Marker/MarkerSnapshot.cs new file mode 100644 index 000000000..0245120d2 --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerSnapshot.cs @@ -0,0 +1,20 @@ +namespace LogExpert.Core.Classes.Marker; + +/// Immutable scan result. Match arrays are shared between append-only revisions. +public sealed class MarkerSnapshot +{ + internal MarkerSnapshot (MarkerLine[][] chunks, int scannedLineCount, Exception? error = null) + { + Chunks = chunks; + ScannedLineCount = scannedLineCount; + Error = error; + } + + internal MarkerLine[][] Chunks { get; } + + public int ScannedLineCount { get; } + + public Exception? Error { get; } + + public IEnumerable Matches => Chunks.SelectMany(chunk => chunk); +} \ No newline at end of file diff --git a/src/LogExpert.Core/Config/Preferences.cs b/src/LogExpert.Core/Config/Preferences.cs index 8e70cc8a5..75a121367 100644 --- a/src/LogExpert.Core/Config/Preferences.cs +++ b/src/LogExpert.Core/Config/Preferences.cs @@ -156,6 +156,16 @@ public bool? DarkMode public bool ShowBubbles { get; set; } = true; + public bool ShowMarkerBar { get; set; } + + public bool ShowHighlightMarkers { get; set; } = true; + + public bool ShowBookmarkMarkers { get; set; } = true; + + public bool ShowSearchMarkers { get; set; } = true; + + public bool ShowFilterMarkers { get; set; } = true; + public bool ShowColumnFinder { get; set; } public Color ShowTailColor { get; set; } = Color.FromKnownColor(KnownColor.Blue); diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index c3ee90d1e..a97e02a26 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -408,6 +408,15 @@ public static string ColorComboBox_UI_ColorComboBox_Text_Custom { } } + /// + /// Looks up a localized string similar to Could not create a parsing snapshot for columnizer '{0}'.. + /// + public static string Columnizer_SnapshotUnavailable { + get { + return ResourceManager.GetString("Columnizer_SnapshotUnavailable", resourceCulture); + } + } + /// /// Looks up a localized string similar to Timestamp selector. /// @@ -4146,6 +4155,96 @@ public static string LogWindow_UI_WriteFilterToTab_NamePrefix_ForFilter { } } + /// + /// Looks up a localized string similar to Bookmarks. + /// + public static string MarkerBar_Bookmarks { + get { + return ResourceManager.GetString("MarkerBar_Bookmarks", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clear search. + /// + public static string MarkerBar_ClearSearch { + get { + return ResourceManager.GetString("MarkerBar_ClearSearch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discovering markers…. + /// + public static string MarkerBar_Discovering { + get { + return ResourceManager.GetString("MarkerBar_Discovering", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter hits. + /// + public static string MarkerBar_FilterHits { + get { + return ResourceManager.GetString("MarkerBar_FilterHits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Highlights. + /// + public static string MarkerBar_Highlights { + get { + return ResourceManager.GetString("MarkerBar_Highlights", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not read log line {0} while discovering markers.. + /// + public static string MarkerBar_LineUnavailable { + get { + return ResourceManager.GetString("MarkerBar_LineUnavailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Marker discovery failed: {0}. + /// + public static string MarkerBar_ScanFailed { + get { + return ResourceManager.GetString("MarkerBar_ScanFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search hits. + /// + public static string MarkerBar_SearchHits { + get { + return ResourceManager.GetString("MarkerBar_SearchHits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Marker bar. + /// + public static string MarkerBar_Title { + get { + return ResourceManager.GetString("MarkerBar_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0}: lines {1}–{2}, {3} matching lines. + /// + public static string MarkerBar_ToolTip { + get { + return ResourceManager.GetString("MarkerBar_ToolTip", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close existing tabs. /// @@ -5481,6 +5580,15 @@ public static string SettingsDialog_UI_CheckBox_checkBoxSaveSessions { } } + /// + /// Looks up a localized string similar to Show bookmark markers. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show Error Message?. /// @@ -5490,6 +5598,42 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowErrorMessageOnlyOneI } } + /// + /// Looks up a localized string similar to Show filter markers. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show highlight markers. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show marker bar. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show search markers. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers", resourceCulture); + } + } + /// /// Looks up a localized string similar to Allow only 1 Instance. /// @@ -6400,6 +6544,15 @@ public static string SettingsDialog_UI_TabPage_tabPageHighlightMask { } } + /// + /// Looks up a localized string similar to Marker bar. + /// + public static string SettingsDialog_UI_TabPage_tabPageMarkerBar { + get { + return ResourceManager.GetString("SettingsDialog_UI_TabPage_tabPageMarkerBar", resourceCulture); + } + } + /// /// Looks up a localized string similar to Memory/CPU. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index fc9f21325..ff1360843 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -2297,4 +2297,55 @@ LogExpert neu starten, um die Änderungen zu übernehmen? {0:N0}–{1:N0} von {2:N0} Treffern + + Markierungsleiste + + + Markierungsleiste anzeigen + + + Hervorhebungsmarkierungen anzeigen + + + Lesezeichenmarkierungen anzeigen + + + Suchtreffermarkierungen anzeigen + + + Filtertreffermarkierungen anzeigen + + + Für den Columnizer „{0}“ konnte kein Snapshot des Parsing-Zustands erstellt werden. + + + Die Protokollzeile {0} konnte beim Ermitteln der Markierungen nicht gelesen werden. + + + Hervorhebungen + + + Lesezeichen + + + Suchtreffer + + + Filtertreffer + + + Suche löschen + + + Markierungen werden ermittelt… + + + {0}: Zeilen {1}–{2}, {3} passende Zeilen + + + Die Ermittlung der Markierungen ist fehlgeschlagen: {0} + + + Markierungsleiste + diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 6398c8c6d..a275cbde2 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2331,4 +2331,37 @@ Restart LogExpert to apply changes? {0:N0}–{1:N0} of {2:N0} matches + + Marker bar + + + Show marker bar + + + Show highlight markers + + + Show bookmark markers + + + Show search markers + + + Show filter markers + + + Could not create a parsing snapshot for columnizer '{0}'. + + + Could not read log line {0} while discovering markers. + + Highlights + Bookmarks + Search hits + Filter hits + Clear search + Discovering markers… + {0}: lines {1}–{2}, {3} matching lines + Marker discovery failed: {0} + Marker bar diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 75efafc36..32e963ba2 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -2191,4 +2191,55 @@ YY[YY] = 年 显示第 {0:N0}–{1:N0} 个,共 {2:N0} 个匹配文件 + + 标记栏 + + + 显示标记栏 + + + 显示高亮标记 + + + 显示书签标记 + + + 显示搜索匹配标记 + + + 显示过滤匹配标记 + + + 无法为列分隔器 '{0}' 创建解析状态快照。 + + + 查找标记时无法读取日志行 {0}。 + + + 高亮 + + + 书签 + + + 搜索匹配 + + + 过滤匹配 + + + 清除搜索 + + + 正在查找标记… + + + {0}:第 {1}–{2} 行,{3} 个匹配行 + + + 标记查找失败:{0} + + + 标记栏 + \ No newline at end of file diff --git a/src/LogExpert.Tests/ConfigManagerTests/PreferencesMarkerBarTests.cs b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMarkerBarTests.cs new file mode 100644 index 000000000..4ce80d1cd --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMarkerBarTests.cs @@ -0,0 +1,58 @@ +using LogExpert.Core.Config; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests.ConfigManagerTests; + +[TestFixture] +public class PreferencesMarkerBarTests +{ + [Test] + public void Defaults_EnableMarkerBarOnDemand_AndShowAllMarkerKinds () + { + var preferences = new Preferences(); + + Assert.That(preferences.ShowMarkerBar, Is.False); + Assert.That(preferences.ShowHighlightMarkers, Is.True); + Assert.That(preferences.ShowBookmarkMarkers, Is.True); + Assert.That(preferences.ShowSearchMarkers, Is.True); + Assert.That(preferences.ShowFilterMarkers, Is.True); + } + + [Test] + public void RoundTrip_PreservesMarkerBarPreferences () + { + var original = new Preferences + { + ShowMarkerBar = true, + ShowHighlightMarkers = false, + ShowBookmarkMarkers = false, + ShowSearchMarkers = false, + ShowFilterMarkers = true + }; + + var restored = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(original)); + + Assert.That(restored, Is.Not.Null); + Assert.That(restored!.ShowMarkerBar, Is.True); + Assert.That(restored.ShowHighlightMarkers, Is.False); + Assert.That(restored.ShowBookmarkMarkers, Is.False); + Assert.That(restored.ShowSearchMarkers, Is.False); + Assert.That(restored.ShowFilterMarkers, Is.True); + } + + [Test] + public void Deserialize_LegacyJson_UsesMarkerBarDefaults () + { + var restored = JsonConvert.DeserializeObject("{\"MaxLineLength\":1000}"); + + Assert.That(restored, Is.Not.Null); + Assert.That(restored!.ShowMarkerBar, Is.False); + Assert.That(restored.ShowHighlightMarkers, Is.True); + Assert.That(restored.ShowBookmarkMarkers, Is.True); + Assert.That(restored.ShowSearchMarkers, Is.True); + Assert.That(restored.ShowFilterMarkers, Is.True); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/LogExpert.Tests.csproj b/src/LogExpert.Tests/LogExpert.Tests.csproj index addb6a7a1..1faf8be1e 100644 --- a/src/LogExpert.Tests/LogExpert.Tests.csproj +++ b/src/LogExpert.Tests/LogExpert.Tests.csproj @@ -19,6 +19,7 @@ + @@ -29,8 +30,6 @@ - - diff --git a/src/LogExpert.Tests/Marker/BookmarkMarkerTests.cs b/src/LogExpert.Tests/Marker/BookmarkMarkerTests.cs new file mode 100644 index 000000000..48e6ba4f6 --- /dev/null +++ b/src/LogExpert.Tests/Marker/BookmarkMarkerTests.cs @@ -0,0 +1,48 @@ +using LogExpert.Core.Classes.Bookmark; + +using NUnit.Framework; + +using BookmarkEntry = LogExpert.Core.Entities.Bookmark; + +namespace LogExpert.Tests.Marker; + +[TestFixture] +public class BookmarkMarkerTests +{ + [Test] + public void GetBookmarkLineNumbers_ReturnsImmutableSnapshotAcrossBookmarkChanges () + { + var provider = new BookmarkDataProvider(); + provider.SetBookmarks(new SortedList + { + { 10, new BookmarkEntry(10) }, + { 20, BookmarkEntry.CreateAutoGenerated(20, "auto", "hit") } + }); + + var snapshot = provider.GetBookmarkLineNumbers(); + + provider.AddBookmark(new BookmarkEntry(30)); + provider.RemoveBookmarkForLine(10); + _ = provider.AddBookmarks([new BookmarkEntry(40), BookmarkEntry.CreateAutoGenerated(50, "auto", "hit")]); + Assert.That(provider.GetBookmarkLineNumbers(), Is.EqualTo(new[] { 20, 30, 40, 50 })); + provider.SetBookmarks(new SortedList + { + { 60, new BookmarkEntry(60) }, + { 70, BookmarkEntry.CreateAutoGenerated(70, "auto", "hit") } + }); + provider.ClearAllBookmarks(); + Assert.That(provider.GetBookmarkLineNumbers(), Is.Empty); + provider.SetBookmarks(new SortedList + { + { 80, new BookmarkEntry(80) }, + { 90, BookmarkEntry.CreateAutoGenerated(90, "auto", "hit") } + }); + provider.ShiftBookmarks(10); + + Assert.Multiple(() => + { + Assert.That(provider.GetBookmarkLineNumbers(), Is.EqualTo(new[] { 70, 80 })); + Assert.That(snapshot, Is.EqualTo(new[] { 10, 20 })); + }); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Marker/MarkerBucketTests.cs b/src/LogExpert.Tests/Marker/MarkerBucketTests.cs new file mode 100644 index 000000000..b9eb79234 --- /dev/null +++ b/src/LogExpert.Tests/Marker/MarkerBucketTests.cs @@ -0,0 +1,95 @@ +using System.Drawing; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; + +using NUnit.Framework; + +namespace LogExpert.Tests.Marker; + +[TestFixture] +public class MarkerBucketTests +{ + [Test] + public void Aggregate_BoldOnlyHighlightResolvesForegroundBeforeRendering () + { + var criteria = MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", IsBold = true }]); + var match = criteria.Match(0, new LogLine("hit", 0)); + Assert.That(match, Is.Not.Null); + + var buckets = MarkerBucket.Aggregate([match!.Value], 1, 1, Color.Green.ToArgb()); + + Assert.That(buckets.Single().ColorArgb, Is.EqualTo(Color.Green.ToArgb())); + } + + [Test] + public void Aggregate_ChangedForegroundRecolorsOnlyInheritedMatches () + { + MarkerLine[] matches = [new(0, null), new(1, Color.Red.ToArgb())]; + + var light = MarkerBucket.Aggregate(matches, 2, 2, Color.Black.ToArgb()); + var dark = MarkerBucket.Aggregate(matches, 2, 2, Color.White.ToArgb()); + + Assert.Multiple(() => + { + Assert.That(light.Select(bucket => bucket.ColorArgb), Is.EqualTo(new[] { Color.Black.ToArgb(), Color.Red.ToArgb() })); + Assert.That(dark.Select(bucket => bucket.ColorArgb), Is.EqualTo(new[] { Color.White.ToArgb(), Color.Red.ToArgb() })); + }); + } + + [Test] + public void Aggregate_FirstAndLastLinesReachBothEndsOfATallBar () + { + var buckets = MarkerBucket.Aggregate([new MarkerLine(0, 10), new MarkerLine(2, 20)], 3, 400, Color.Black.ToArgb()); + Assert.That(buckets.Select(bucket => bucket.Pixel), Is.EqualTo(new[] { 0, 399 })); + } + [Test] + public void Aggregate_DenseMatchesPreserveIsolatedLinesAndChooseNearestMidpoint () + { + MarkerLine[] matches = [new(0, 10), new(6, 20, 2), new(9, 30, 1), new(99, 40)]; + + var buckets = MarkerBucket.Aggregate(matches, 100, 10, Color.Black.ToArgb()); + + Assert.That(buckets, Is.EqualTo(new MarkerBucket[] + { + new(0, 0, 10, 3, 6, 10), + new(9, 99, 99, 1, 99, 40) + })); + } + + [Test] + public void Aggregate_TieChoosesLowerLineAndHighestPriorityColor () + { + MarkerLine[] matches = [new(4, 10, 3), new(4, 10, 3), new(5, 20, 1)]; + + Assert.That(MarkerBucket.Aggregate(matches, 10, 1, Color.Black.ToArgb()), + Is.EqualTo(new MarkerBucket[] { new(0, 0, 9, 2, 4, 20) })); + } + + [TestCase(0, 10)] + [TestCase(10, 0)] + [TestCase(10, -1)] + public void Aggregate_EmptyFileOrNoPixels_HasNoClickTargets (int lineCount, int height) + { + Assert.That(MarkerBucket.Aggregate([new MarkerLine(0, 10)], lineCount, height, Color.Black.ToArgb()), Is.Empty); + } + + [Test] + public void Aggregate_SingleLineAndResizeKeepTheSameMatch () + { + MarkerLine[] matches = [new(0, 10)]; + Assert.That(MarkerBucket.Aggregate(matches, 1, 1, Color.Black.ToArgb()), + Is.EqualTo(new MarkerBucket[] { new(0, 0, 0, 1, 0, 10) })); + Assert.That(MarkerBucket.Aggregate(matches, 1, 300, Color.Black.ToArgb()), + Is.EqualTo(new MarkerBucket[] { new(0, 0, 0, 1, 0, 10) })); + } + + [Test] + public void Aggregate_HighLineNumbersDoNotOverflow () + { + Assert.That(MarkerBucket.Aggregate([new MarkerLine(int.MaxValue - 1, 10)], int.MaxValue, 2, Color.Black.ToArgb()), + Is.EqualTo(new MarkerBucket[] { new(1, 2147483646, 2147483646, 1, 2147483646, 10) })); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs new file mode 100644 index 000000000..2e297f43b --- /dev/null +++ b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs @@ -0,0 +1,66 @@ +using System.Drawing; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; +using LogExpert.Core.Entities; + +using NUnit.Framework; + +namespace LogExpert.Tests.Marker; + +[TestFixture] +public class MarkerCriteriaTests +{ + [TestCase(false, false)] + [TestCase(true, true)] + public void Highlights_OnlyWordRulesMatchDisplayedColumns (bool wordMatch, bool expected) + { + var criteria = MarkerCriteria.ForHighlights([ + new HighlightEntry { SearchText = "^error$", IsRegex = true, IsWordMatch = wordMatch, BackgroundColor = Color.Yellow } + ]); + var match = criteria.Match(0, new LogLine("{\"message\":\"error\"}", 0), (_, _) => [new LogLine("error", 0)]); + Assert.That(match.HasValue, Is.EqualTo(expected)); + } + + [TestCase(false, false, "ERROR", true)] + [TestCase(false, true, "ERROR", false)] + [TestCase(true, true, "^error$", true)] + [TestCase(false, false, "^error$", false)] + public void Search_UsesExecutedCriteriaSnapshot (bool regex, bool caseSensitive, string text, bool expected) + { + var search = new SearchParams { SearchText = text, IsRegex = regex, IsCaseSensitive = caseSensitive }; + var criteria = MarkerCriteria.ForSearch(search, Color.Blue.ToArgb()); + search.SearchText = "changed"; + Assert.That(criteria.Match(3, new LogLine("error", 3)).HasValue, Is.EqualTo(expected)); + } + + [Test] + public void Highlights_UseDisplayedWordColumnsAndExcludeSearchEntries () + { + var criteria = MarkerCriteria.ForHighlights([ + new HighlightEntry { SearchText = "raw", IsSearchHit = true, BackgroundColor = Color.Red }, + new HighlightEntry { SearchText = "display", IsWordMatch = true, ForegroundColor = Color.Green } + ]); + var result = criteria.Match(0, new LogLine("raw", 0), (_, _) => [new LogLine("display", 0)]); + Assert.That(result, Is.EqualTo(new MarkerLine(0, Color.Green.ToArgb(), 1))); + Assert.That(criteria.Match(0, new LogLine("raw", 0)), Is.Null); + } + + [Test] + public void Highlights_FirstVisualRuleWinsAndCriteriaAreSnapshotted () + { + var trigger = new HighlightEntry { SearchText = "error", IsSetBookmark = true, AlertOnHit = true }; + var word = new HighlightEntry { SearchText = "error", IsWordMatch = true, NoBackground = true, + ForegroundColor = Color.Red, BackgroundColor = Color.Yellow }; + var wholeLine = new HighlightEntry { SearchText = "error", BackgroundColor = Color.Blue }; + var criteria = MarkerCriteria.ForHighlights([trigger, word, wholeLine]); + word.SearchText = "changed"; + word.ForegroundColor = Color.Green; + + var match = criteria.Match(7, new LogLine("ERROR occurred", 7)); + + Assert.That(match, Is.EqualTo(new MarkerLine(7, Color.Red.ToArgb(), 1))); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Marker/MarkerIndexTests.cs b/src/LogExpert.Tests/Marker/MarkerIndexTests.cs new file mode 100644 index 000000000..e16c0ab05 --- /dev/null +++ b/src/LogExpert.Tests/Marker/MarkerIndexTests.cs @@ -0,0 +1,149 @@ +using System.Drawing; +using System.Text.RegularExpressions; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; +using LogExpert.Core.Interfaces; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Marker; + +[TestFixture] +public class MarkerIndexTests +{ + [Test] + public async Task Search_RegexTimeoutIsReportedAndStopsDiscovery () + { + using var index = new MarkerIndex(); + index.Reset(ReaderOf(new string('a', 50_000) + "!"), MarkerCriteria.ForSearch( + new Core.Entities.SearchParams { SearchText = "^(a+)+$", IsRegex = true }, Color.Blue.ToArgb())); + await index.UpdateAsync(1).ConfigureAwait(false); + Assert.That(index.Snapshot.Error, Is.InstanceOf()); + Assert.That(index.IsScanning, Is.False); + } + + [Test] + public async Task Update_AppendsMatchesWithoutReadingTheOldPrefixAgain () + { + List lines = ["hit", "miss", "hit"]; + List reads = []; + var reader = new Mock(); + _ = reader.Setup(source => source.GetLogLineMemory(It.IsAny())).Returns((int line) => + { + reads.Add(line); + return new LogLine(lines[line], line); + }); + using var index = new MarkerIndex(); + index.Reset(reader.Object, MarkerCriteria.ForHighlights( + [new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }])); + + await index.UpdateAsync(3).ConfigureAwait(false); + var first = index.Snapshot; + lines.Add("hit"); + reads.Clear(); + await index.UpdateAsync(4).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(first.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 0, 2 })); + Assert.That(index.Snapshot.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 0, 2, 3 })); + Assert.That(reads, Is.EqualTo(new[] { 2, 3 }), "Only the unfinished last line and appended lines need reevaluation."); + }); + } + + [Test] + public async Task Reset_AnOldBlockedScanCannotReplaceTheNewFilesMarkers () + { + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var oldReader = new Mock(); + _ = oldReader.Setup(reader => reader.GetLogLineMemory(0)).Returns(() => + { + entered.Set(); + _ = release.Wait(TimeSpan.FromSeconds(10)); + return new LogLine("hit", 0); + }); + using var index = new MarkerIndex(); + var criteria = MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }]); + index.Reset(oldReader.Object, criteria); + var oldScan = index.UpdateAsync(1); + try + { + Assert.That(entered.Wait(TimeSpan.FromSeconds(5)), Is.True); + index.Reset(ReaderOf("miss", "hit"), criteria); + await index.UpdateAsync(2).ConfigureAwait(false); + } + finally + { + release.Set(); + await oldScan.ConfigureAwait(false); + } + + Assert.That(index.Snapshot.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 1 })); + } + + [Test] + public async Task Update_TruncationRebuildsAndLastLineEditsReplaceOldMatches () + { + List lines = ["hit", "hit", "hit"]; + var reader = new Mock(); + _ = reader.Setup(source => source.GetLogLineMemory(It.IsAny())) + .Returns((int line) => new LogLine(lines[line], line)); + using var index = new MarkerIndex(); + index.Reset(reader.Object, MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }])); + await index.UpdateAsync(3).ConfigureAwait(false); + lines[0] = "miss"; + await index.UpdateAsync(1).ConfigureAwait(false); + Assert.That(index.Snapshot.Matches, Is.Empty); + + lines[0] = "hit"; + await index.UpdateAsync(1, lastLineChanged: true).ConfigureAwait(false); + Assert.That(index.Snapshot.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 0 })); + } + + [Test] + public async Task Update_InvalidRegexReportsFailureAndCanRecoverAfterCriteriaChange () + { + using var index = new MarkerIndex(); + var reader = ReaderOf("hit"); + index.Reset(reader, MarkerCriteria.ForHighlights( + [new HighlightEntry { SearchText = "[", IsRegex = true, BackgroundColor = Color.Red }])); + await index.UpdateAsync(1).ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(index.Snapshot.Error, Is.InstanceOf()); + Assert.That(index.IsScanning, Is.False); + Assert.That(index.Snapshot.Matches, Is.Empty); + }); + + index.Reset(reader, MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }])); + await index.UpdateAsync(1).ConfigureAwait(false); + Assert.That(index.Snapshot.Matches.Count(), Is.EqualTo(1)); + } + + [Test] + public async Task Reset_DisabledSourceAndDisposedIndexNeverReadTheFile () + { + var reader = new Mock(MockBehavior.Strict); + using var index = new MarkerIndex(); + index.Reset(reader.Object, null); + await index.UpdateAsync(100).ConfigureAwait(false); + index.Dispose(); + index.Reset(reader.Object, MarkerCriteria.ForHighlights([])); + await index.UpdateAsync(100).ConfigureAwait(false); + Assert.That(index.Snapshot.Matches, Is.Empty); + } + + private static ILogfileReader ReaderOf (params string[] lines) + { + var reader = new Mock(); + _ = reader.Setup(source => source.GetLogLineMemory(It.IsAny())) + .Returns((int line) => new LogLine(lines[line], line)); + return reader.Object; + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Marker/MarkerPerformanceTests.cs b/src/LogExpert.Tests/Marker/MarkerPerformanceTests.cs new file mode 100644 index 000000000..438299e0d --- /dev/null +++ b/src/LogExpert.Tests/Marker/MarkerPerformanceTests.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using System.Drawing; +using System.Text; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; +using LogExpert.Core.Entities; +using LogExpert.Core.EventArguments; +using LogExpert.Core.Interfaces; + +using NUnit.Framework; + +namespace LogExpert.Tests.Marker; + +[TestFixture] +public class MarkerPerformanceTests +{ + private const int INITIAL_LINE_COUNT = 500_000; + private const int APPENDED_LINE_COUNT = 2_000; + + [Test, Explicit("Opt-in marker indexing performance experiment")] + public async Task MarkerIndex_ScanAndAppend_DenseMatchesReportPerformanceAndCorrectness () + { + // This uses an in-memory reader seam to keep the experiment independent of file watcher timing and buffering. + var reader = new InMemoryBenchmarkReader(INITIAL_LINE_COUNT); + using var index = new MarkerIndex(); + index.Reset(reader, MarkerCriteria.ForHighlights( + [ + new HighlightEntry { SearchText = "^never-match-a$", IsRegex = true, BackgroundColor = System.Drawing.Color.Red }, + new HighlightEntry { SearchText = "^never-match-b$", IsRegex = true, BackgroundColor = System.Drawing.Color.Blue }, + new HighlightEntry { SearchText = "\\babsent\\b", IsRegex = true, BackgroundColor = System.Drawing.Color.Green }, + new HighlightEntry { SearchText = "COMMON-\\d+", IsRegex = true, BackgroundColor = System.Drawing.Color.Yellow } + ])); + + var scanMemoryBefore = GC.GetTotalMemory(true); + var scanAllocatedBefore = GC.GetTotalAllocatedBytes(true); + var scanTimer = Stopwatch.StartNew(); + await index.UpdateAsync(INITIAL_LINE_COUNT).ConfigureAwait(false); + scanTimer.Stop(); + var scanMemoryAfter = GC.GetTotalMemory(true); + var scanAllocatedAfter = GC.GetTotalAllocatedBytes(true); + + var initialSnapshot = index.Snapshot; + var initialMatches = initialSnapshot.Matches.ToArray(); + var initialBuckets = MarkerBucket.Aggregate(initialMatches, INITIAL_LINE_COUNT, 2_000, Color.Black.ToArgb()); + + reader.Append(APPENDED_LINE_COUNT); + var appendMemoryBefore = GC.GetTotalMemory(true); + var appendAllocatedBefore = GC.GetTotalAllocatedBytes(true); + var appendTimer = Stopwatch.StartNew(); + await index.UpdateAsync(reader.LineCount).ConfigureAwait(false); + appendTimer.Stop(); + var appendMemoryAfter = GC.GetTotalMemory(true); + var appendAllocatedAfter = GC.GetTotalAllocatedBytes(true); + + var appendedSnapshot = index.Snapshot; + var appendedMatches = appendedSnapshot.Matches.ToArray(); + var appendedBuckets = MarkerBucket.Aggregate(appendedMatches, reader.LineCount, 2_000, Color.Black.ToArgb()); + + Assert.Multiple(() => + { + Assert.That(initialSnapshot.Error, Is.Null); + Assert.That(initialSnapshot.ScannedLineCount, Is.EqualTo(INITIAL_LINE_COUNT)); + Assert.That(initialMatches, Has.Length.EqualTo(INITIAL_LINE_COUNT), "Every dense line must match."); + Assert.That(appendedSnapshot.Error, Is.Null); + Assert.That(appendedSnapshot.ScannedLineCount, Is.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT)); + Assert.That(appendedMatches, Has.Length.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT)); + Assert.That(appendedMatches[^1].LineNumber, Is.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT - 1)); + Assert.That(reader.LineCount, Is.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT)); + Assert.That(reader.FileSize, Is.GreaterThan(0)); + Assert.That(initialBuckets, Is.Not.Empty); + Assert.That(appendedBuckets, Is.Not.Empty); + Assert.That(initialBuckets.Sum(bucket => bucket.Count), Is.EqualTo(initialMatches.Length)); + Assert.That(appendedBuckets.Sum(bucket => bucket.Count), Is.EqualTo(appendedMatches.Length)); + }); + + await TestContext.Progress.WriteLineAsync( + $"Marker experiment: initial lines={INITIAL_LINE_COUNT}, appended={APPENDED_LINE_COUNT}, " + + $"initial bytes={reader.InitialFileSize}, final bytes={reader.FileSize}, " + + $"initial matches={initialMatches.Length}, final matches={appendedMatches.Length}, " + + $"initial buckets={initialBuckets.Count}, final buckets={appendedBuckets.Count}, " + + $"scan elapsed={scanTimer.Elapsed}, append elapsed={appendTimer.Elapsed}, " + + $"scan managed delta={scanMemoryAfter - scanMemoryBefore}, append managed delta={appendMemoryAfter - appendMemoryBefore}, " + + $"scan allocations={scanAllocatedAfter - scanAllocatedBefore}, append allocations={appendAllocatedAfter - appendAllocatedBefore}").ConfigureAwait(false); + } + + private sealed class InMemoryBenchmarkReader : ILogfileReader + { + private readonly List _lines; + + public InMemoryBenchmarkReader (int lineCount) + { + _lines = Enumerable.Range(0, lineCount).Select(CreateLine).ToList(); + InitialFileSize = FileSize; + } + + public event EventHandler FileSizeChanged + { + add { } + remove { } + } + + public event EventHandler LoadFile + { + add { } + remove { } + } + + public event EventHandler LoadingStarted + { + add { } + remove { } + } + + public event EventHandler LoadingFinished + { + add { } + remove { } + } + + public event EventHandler FileNotFound + { + add { } + remove { } + } + + public event EventHandler Respawned + { + add { } + remove { } + } + + public int LineCount => _lines.Count; + + public bool IsMultiFile => false; + + public Encoding CurrentEncoding => Encoding.UTF8; + + // The model reports UTF-8 content with a two-byte CRLF terminator per line. + public long FileSize => _lines.Sum(line => (long)CurrentEncoding.GetByteCount(line) + 2); + + public long InitialFileSize { get; } + + public ILogLineMemory GetLogLineMemory (int lineNum) + { + return lineNum >= 0 && lineNum < _lines.Count ? new LogLine(_lines[lineNum], lineNum) : null!; + } + + public Task GetLogLineMemoryWithWait (int lineNum) + { + return Task.FromResult(GetLogLineMemory(lineNum)); + } + + public ILogLineMemory[] GetLogLineMemories (int startLine, int count) + { + return Enumerable.Range(startLine, count) + .Select(GetLogLineMemory) + .Where(line => line != null) + .ToArray(); + } + + public void StartMonitoring () { } + + public void StopMonitoring () { } + + public void StopMonitoringAsync () { } + + public void DeleteAllContent () => _lines.Clear(); + + public void Dispose () { } + + public void Append (int count) + { + var start = _lines.Count; + _lines.AddRange(Enumerable.Range(start, count).Select(CreateLine)); + } + + private static string CreateLine (int lineNumber) => $"COMMON-{lineNumber:D6} dense marker"; + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs b/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs new file mode 100644 index 000000000..897079176 --- /dev/null +++ b/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs @@ -0,0 +1,169 @@ +using ColumnizerLib; + +using CsvColumnizer; + +using LogExpert.Core.Classes.Columnizer; +using LogExpert.Core.Entities; + +using Moq; + +using Newtonsoft.Json; + +using NUnit.Framework; + +using RegexColumnizer; + +namespace LogExpert.Tests.UI; + +[TestFixture] +public class MarkerColumnizerSnapshotTests +{ + [Test] + public void CsvSnapshot_SplitLinePreservesInitializedParseAfterOriginalConfigChanges () + { + var directory = CreateTempDirectory(); + try + { + File.WriteAllText(Path.Join(directory, "csvcolumnizer.json"), JsonConvert.SerializeObject(new + { + DelimiterChar = ",", + EscapeChar = "\"", + QuoteChar = "\"", + CommentChar = "#", + HasFieldNames = true, + MinColumns = 0 + })); + var original = new CsvColumnizer.CsvColumnizer(); + original.LoadConfig(directory); + _ = original.PreProcessLine("name,age".AsMemory(), 0, 0); + original.Selected(new Mock().Object); + + var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot(); + File.WriteAllText(Path.Join(directory, "csvcolumnizer.json"), JsonConvert.SerializeObject(new + { + DelimiterChar = ";", + EscapeChar = "\"", + QuoteChar = "\"", + CommentChar = "#", + HasFieldNames = true, + MinColumns = 0 + })); + original.LoadConfig(directory); + + var result = snapshot.SplitLine(null, new CsvLogLine("Alice,42", 1)); + + Assert.That(result.ColumnValues.Select(column => column.FullValue.ToString()), Is.EqualTo(["Alice", "42"])); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Test] + public void RegexSnapshot_SplitLinePreservesInitializedParseAfterOriginalConfigChanges () + { + var directory = CreateTempDirectory(); + try + { + WriteRegexConfig(directory, "(?[^:]+):(?.+)"); + var original = new Regex1Columnizer(); + original.LoadConfig(directory); + var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot(); + WriteRegexConfig(directory, "(?.+)"); + original.LoadConfig(directory); + + var result = snapshot.SplitLine(new Mock().Object, new LogLine("Alice:42", 1)); + + Assert.That(result.ColumnValues.Select(column => column.FullValue.ToString()), Is.EqualTo(["Alice", "42"])); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Test] + public void Log4jSnapshot_SplitLinePreservesInitializedParseAndTimeOffsetAfterOriginalConfigChanges () + { + var directory = CreateTempDirectory(); + try + { + WriteLog4jConfig(directory, true); + var original = new Log4jXmlColumnizer.Log4jXmlColumnizer(); + original.LoadConfig(directory); + original.SetTimeOffset(123); + var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot(); + WriteLog4jConfig(directory, false); + original.LoadConfig(directory); + + var line = new LogLine("1700000000000�INFO�Logger�Thread�Class�Method�File�12�Message", 1); + var result = snapshot.SplitLine(new Mock().Object, line); + + Assert.Multiple(() => + { + Assert.That(result.ColumnValues, Has.Length.EqualTo(9)); + Assert.That(result.ColumnValues[1].FullValue.ToString(), Is.EqualTo("INFO")); + Assert.That(result.ColumnValues[8].FullValue.ToString(), Is.EqualTo("Message")); + Assert.That(snapshot.GetTimeOffset(), Is.EqualTo(123)); + }); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Test] + public void SquareBracketSnapshot_PreservesDetectedLayoutAndTimeOffsetAfterOriginalChanges () + { + var line = new LogLine("2022-03-21 11:34:34.505[one][two][three][four][five][six]Message", 0); + var original = new SquareBracketColumnizer(); + _ = original.GetPriority("square.log", new ILogLineMemory[] { line }); + original.SetTimeOffset(123); + var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot(); + _ = original.GetPriority("other.log", new ILogLineMemory[] { new LogLine("[Other]Changed", 0) }); + original.SetTimeOffset(456); + + var result = snapshot.SplitLine(null, line); + + Assert.Multiple(() => + { + Assert.That(snapshot.GetColumnNames(), Is.EqualTo(new[] { "Date", "Time", "Level", "Source", "Source1", "Source2", "Source3", "Source4", "Message" })); + Assert.That(result.ColumnValues, Has.Length.EqualTo(9)); + Assert.That(result.ColumnValues[0].FullValue.ToString(), Is.EqualTo("2022-03-21")); + Assert.That(result.ColumnValues[1].FullValue.ToString(), Is.EqualTo("11:34:34.628")); + Assert.That(snapshot.GetTimeOffset(), Is.EqualTo(123)); + }); + } + + private static string CreateTempDirectory () + { + var directory = Path.Join(Path.GetTempPath(), "LogExpertMarkerColumnizerTests", Guid.NewGuid().ToString()); + _ = Directory.CreateDirectory(directory); + return directory; + } + + private static void WriteRegexConfig (string directory, string expression) + { + File.WriteAllText(Path.Join(directory, "Regex1Columnizer.json"), JsonConvert.SerializeObject(new + { + Expression = expression, + Name = "Regex1", + CustomName = "Regex1" + })); + } + + private static void WriteLog4jConfig (string directory, bool visible) + { + var names = new[] { "Timestamp", "Level", "Logger", "Thread", "Class", "Method", "File", "Line", "Message" }; + var columns = names + .Select((name, index) => new Log4jXmlColumnizer.Log4jColumnEntry(name, index, 0) { Visible = visible }); + File.WriteAllText(Path.Join(directory, "log4jxmlcolumnizer.json"), JsonConvert.SerializeObject(new + { + columnNames = names, + ColumnList = columns, + LocalTimestamps = true + })); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/CommandLine/LineNavigationTests.cs b/src/LogExpert.UI.Tests/CommandLine/LineNavigationTests.cs similarity index 99% rename from src/LogExpert.Tests/CommandLine/LineNavigationTests.cs rename to src/LogExpert.UI.Tests/CommandLine/LineNavigationTests.cs index ccf340e66..f57ec929f 100644 --- a/src/LogExpert.Tests/CommandLine/LineNavigationTests.cs +++ b/src/LogExpert.UI.Tests/CommandLine/LineNavigationTests.cs @@ -16,7 +16,7 @@ using NUnit.Framework; -namespace LogExpert.Tests.CommandLine; +namespace LogExpert.UI.Tests.CommandLine; [TestFixture] [Apartment(ApartmentState.STA)] diff --git a/src/LogExpert.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs b/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs similarity index 99% rename from src/LogExpert.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs rename to src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs index 9f28b24ef..bae722bb1 100644 --- a/src/LogExpert.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs +++ b/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs @@ -10,7 +10,7 @@ using NUnit.Framework; -namespace LogExpert.Tests.Controls; +namespace LogExpert.UI.Tests.Controls; [TestFixture] [Apartment(ApartmentState.STA)] diff --git a/src/LogExpert.Tests/Controls/LogWindowPluginRegistryTests.cs b/src/LogExpert.UI.Tests/Controls/LogWindowPluginRegistryTests.cs similarity index 98% rename from src/LogExpert.Tests/Controls/LogWindowPluginRegistryTests.cs rename to src/LogExpert.UI.Tests/Controls/LogWindowPluginRegistryTests.cs index da54921c7..32bdbe02a 100644 --- a/src/LogExpert.Tests/Controls/LogWindowPluginRegistryTests.cs +++ b/src/LogExpert.UI.Tests/Controls/LogWindowPluginRegistryTests.cs @@ -10,7 +10,7 @@ using NUnit.Framework; -namespace LogExpert.Tests.Controls; +namespace LogExpert.UI.Tests.Controls; [TestFixture] [Apartment(ApartmentState.STA)] diff --git a/src/LogExpert.Tests/Dialogs/FolderDropDialogTests.cs b/src/LogExpert.UI.Tests/Dialogs/FolderDropDialogTests.cs similarity index 99% rename from src/LogExpert.Tests/Dialogs/FolderDropDialogTests.cs rename to src/LogExpert.UI.Tests/Dialogs/FolderDropDialogTests.cs index c0c7b53ef..7d2edc8d2 100644 --- a/src/LogExpert.Tests/Dialogs/FolderDropDialogTests.cs +++ b/src/LogExpert.UI.Tests/Dialogs/FolderDropDialogTests.cs @@ -5,7 +5,7 @@ using NUnit.Framework; -namespace LogExpert.Tests.Dialogs; +namespace LogExpert.UI.Tests.Dialogs; [TestFixture] [Apartment(ApartmentState.STA)] diff --git a/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs b/src/LogExpert.UI.Tests/Dialogs/RegexHelperDialogTests.cs similarity index 98% rename from src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs rename to src/LogExpert.UI.Tests/Dialogs/RegexHelperDialogTests.cs index 5e9341bbf..fae20c469 100644 --- a/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs +++ b/src/LogExpert.UI.Tests/Dialogs/RegexHelperDialogTests.cs @@ -2,7 +2,7 @@ using NUnit.Framework; -namespace LogExpert.Tests.Dialogs; +namespace LogExpert.UI.Tests.Dialogs; /// /// Regression tests for the Regex Helper dialog's OK handler. Both combo boxes are diff --git a/src/LogExpert.Tests/Dialogs/SettingsDialogEncodingListTests.cs b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogEncodingListTests.cs similarity index 98% rename from src/LogExpert.Tests/Dialogs/SettingsDialogEncodingListTests.cs rename to src/LogExpert.UI.Tests/Dialogs/SettingsDialogEncodingListTests.cs index c01871722..e55731c63 100644 --- a/src/LogExpert.Tests/Dialogs/SettingsDialogEncodingListTests.cs +++ b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogEncodingListTests.cs @@ -5,7 +5,7 @@ using NUnit.Framework; -namespace LogExpert.Tests.Dialogs; +namespace LogExpert.UI.Tests.Dialogs; /// /// The Preferences encoding combo box, which is the only way to set Preferences.DefaultEncoding. diff --git a/src/LogExpert.UI.Tests/Dialogs/SettingsDialogMarkerBarTests.cs b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogMarkerBarTests.cs new file mode 100644 index 000000000..97f0a1345 --- /dev/null +++ b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogMarkerBarTests.cs @@ -0,0 +1,53 @@ +using LogExpert.Core.Config; +using LogExpert.Core.Interfaces; +using LogExpert.Dialogs; + +using Moq; + +using NUnit.Framework; + +using UIStrings = LogExpert.Resources; + +namespace LogExpert.UI.Tests.Dialogs; + +[TestFixture] +[Apartment(ApartmentState.STA)] +public class SettingsDialogMarkerBarTests +{ + [Test] + public void MarkerBarTab_LocalizesVisibleOptions_AndSavesCheckboxValues () + { + var preferences = new Preferences(); + var configManager = new Mock(); + _ = configManager.SetupGet(manager => manager.Settings).Returns(new Settings()); + + using var dialog = new SettingsDialog(preferences, null!, 0, configManager.Object); + var tab = dialog.Controls.Find("tabControlSettings", true).Single().Controls.Find("tabPageMarkerBar", true).Single(); + + Assert.Multiple(() => + { + Assert.That(tab.Text, Is.EqualTo(UIStrings.SettingsDialog_UI_TabPage_tabPageMarkerBar)); + Assert.That(tab.Controls.Find("checkBoxShowMarkerBar", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar)); + Assert.That(tab.Controls.Find("checkBoxShowHighlightMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers)); + Assert.That(tab.Controls.Find("checkBoxShowBookmarkMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers)); + Assert.That(tab.Controls.Find("checkBoxShowSearchMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers)); + Assert.That(tab.Controls.Find("checkBoxShowFilterMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers)); + }); + + ((CheckBox)tab.Controls.Find("checkBoxShowMarkerBar", true).Single()).Checked = true; + ((CheckBox)tab.Controls.Find("checkBoxShowHighlightMarkers", true).Single()).Checked = false; + ((CheckBox)tab.Controls.Find("checkBoxShowBookmarkMarkers", true).Single()).Checked = false; + ((CheckBox)tab.Controls.Find("checkBoxShowSearchMarkers", true).Single()).Checked = false; + ((CheckBox)tab.Controls.Find("checkBoxShowFilterMarkers", true).Single()).Checked = true; + dialog.SaveMarkerBarTab(); + + Assert.Multiple(() => + { + Assert.That(preferences.ShowMarkerBar, Is.True); + Assert.That(preferences.ShowHighlightMarkers, Is.False); + Assert.That(preferences.ShowBookmarkMarkers, Is.False); + Assert.That(preferences.ShowSearchMarkers, Is.False); + Assert.That(preferences.ShowFilterMarkers, Is.True); + }); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/Dialogs/SettingsDialogPortableModeTests.cs b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogPortableModeTests.cs similarity index 99% rename from src/LogExpert.Tests/Dialogs/SettingsDialogPortableModeTests.cs rename to src/LogExpert.UI.Tests/Dialogs/SettingsDialogPortableModeTests.cs index b23b8a8a4..4738cedb5 100644 --- a/src/LogExpert.Tests/Dialogs/SettingsDialogPortableModeTests.cs +++ b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogPortableModeTests.cs @@ -8,7 +8,7 @@ using UIStrings = LogExpert.Resources; -namespace LogExpert.Tests.Dialogs; +namespace LogExpert.UI.Tests.Dialogs; /// /// Regression tests for issue #658: populating the settings dialog from preferences set the diff --git a/src/LogExpert.Tests/Encodings/OfferedEncodingsTests.cs b/src/LogExpert.UI.Tests/Encodings/OfferedEncodingsTests.cs similarity index 99% rename from src/LogExpert.Tests/Encodings/OfferedEncodingsTests.cs rename to src/LogExpert.UI.Tests/Encodings/OfferedEncodingsTests.cs index a78fa74bf..ef7c7e571 100644 --- a/src/LogExpert.Tests/Encodings/OfferedEncodingsTests.cs +++ b/src/LogExpert.UI.Tests/Encodings/OfferedEncodingsTests.cs @@ -6,7 +6,7 @@ using NUnit.Framework; -namespace LogExpert.Tests.Encodings; +namespace LogExpert.UI.Tests.Encodings; /// /// is the one list of encodings a user can pick: the diff --git a/src/LogExpert.UI.Tests/LogExpert.UI.Tests.csproj b/src/LogExpert.UI.Tests/LogExpert.UI.Tests.csproj new file mode 100644 index 000000000..49febab0b --- /dev/null +++ b/src/LogExpert.UI.Tests/LogExpert.UI.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0-windows + true + + true + true + true + true + LogExpert.UI.Tests + LogExpert.UI.Tests + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/LogExpert.UI.Tests/MarkerBarTests.cs b/src/LogExpert.UI.Tests/MarkerBarTests.cs new file mode 100644 index 000000000..1009da4be --- /dev/null +++ b/src/LogExpert.UI.Tests/MarkerBarTests.cs @@ -0,0 +1,236 @@ +using System.Globalization; +using System.Reflection; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; +using LogExpert.UI.Controls.LogWindow; + +using NUnit.Framework; + +using Vanara.PInvoke; + +namespace LogExpert.UI.Tests; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public class MarkerBarTests +{ + [TestCase(96, 10, 2)] + [TestCase(144, 15, 3)] + [TestCase(192, 20, 4)] + public void Paint_DiscoveryIndicatorScalesToDeviceDpi (int dpi, int expectedWidth, int expectedHeight) + { + using var bar = CreateBar(80, 40); + bar.TopInset = 20; + bar.BackColor = Color.White; + bar.ForeColor = Color.Green; + bar.SetBuckets(new Dictionary>(), bar.BucketHeight, true); + // WinForms accepts a nonzero wParam on BEFOREPARENT specifically for DPI tests. + _ = User32.SendMessage(bar.Handle, User32.WindowMessage.WM_DPICHANGED_BEFOREPARENT, (nint)dpi, nint.Zero); + _ = User32.SendMessage(bar.Handle, User32.WindowMessage.WM_DPICHANGED_AFTERPARENT, nint.Zero, nint.Zero); + Assert.That(bar.DeviceDpi, Is.EqualTo(dpi)); + using var image = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(image, bar.ClientRectangle); + + var indicatorPixels = new List(); + for (var y = 0; y < bar.TopInset; y++) + { + for (var x = 0; x < bar.Width; x++) + { + if (image.GetPixel(x, y).ToArgb() == Color.Green.ToArgb()) + { + indicatorPixels.Add(new Point(x, y)); + } + } + } + + Assert.That(indicatorPixels, Is.Not.Empty); + var width = indicatorPixels.Max(point => point.X) - indicatorPixels.Min(point => point.X) + 1; + var height = indicatorPixels.Max(point => point.Y) - indicatorPixels.Min(point => point.Y) + 1; + Assert.Multiple(() => + { + // GDI ellipse rasterization may differ by one edge pixel at fractional scaling. + Assert.That(width, Is.EqualTo(expectedWidth).Within(1)); + Assert.That(height, Is.EqualTo(expectedHeight).Within(1)); + }); + } + + [Test] + public void Paint_BoldOnlyHighlightUsesTheCurrentForeground () + { + var criteria = MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", IsBold = true }]); + var match = criteria.Match(0, new LogLine("hit", 0)); + Assert.That(match, Is.Not.Null); + using var bar = CreateBar(40, 10); + bar.BackColor = Color.White; + bar.ForeColor = Color.Green; + bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = MarkerBucket.Aggregate([match!.Value], 1, 10, bar.ForeColor.ToArgb()) }, 10, false); + + using var image = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(image, bar.ClientRectangle); + + Assert.That(image.GetPixel(5, 0).ToArgb(), Is.EqualTo(Color.Green.ToArgb())); + } + + [Test] + public void Paint_RendersEachLaneBucketWithItsColor () + { + using var bar = CreateBar(40, 10); + bar.SetBuckets(new Dictionary> + { + [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 1, 12, Color.Red.ToArgb())], + [MarkerCategory.Bookmarks] = [new MarkerBucket(2, 20, 29, 1, 22, Color.Green.ToArgb())], + [MarkerCategory.Search] = [new MarkerBucket(2, 30, 39, 1, 32, Color.Blue.ToArgb())], + [MarkerCategory.Filter] = [new MarkerBucket(2, 40, 49, 1, 42, Color.Purple.ToArgb())] + }, 10, discovering: false); + + using var image = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(image, bar.ClientRectangle); + + Assert.Multiple(() => + { + Assert.That(image.GetPixel(5, 2).ToArgb(), Is.EqualTo(Color.Red.ToArgb())); + Assert.That(image.GetPixel(15, 2).ToArgb(), Is.EqualTo(Color.Green.ToArgb())); + Assert.That(image.GetPixel(25, 2).ToArgb(), Is.EqualTo(Color.Blue.ToArgb())); + Assert.That(image.GetPixel(35, 2).ToArgb(), Is.EqualTo(Color.Purple.ToArgb())); + }); + } + + [Test] + public void MouseUp_OnBucketRaisesTargetLine_AndEmptyClickDoesNothing () + { + using var bar = CreateBar(40, 10); + bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 3, 14, Color.Red.ToArgb())] }, 10, false); + var selected = new List(); + bar.LineSelected += (_, args) => selected.Add(args.Line); + + RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 5, 2, 0)); + RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 5, 3, 0)); + + Assert.That(selected, Is.EqualTo([14])); + } + + [Test] + public void MouseMove_TooltipIncludesCategoryRangeAndCount () + { + using var bar = CreateBar(40, 10); + bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 3, 14, Color.Red.ToArgb())] }, 10, false); + + RaiseMouse(bar, "OnMouseMove", new MouseEventArgs(MouseButtons.None, 0, 5, 2, 0)); + + var toolTip = (ToolTip)typeof(MarkerBar).GetField("_toolTip", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(bar)!; + var expected = string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ToolTip, Resources.MarkerBar_Highlights, 11, 20, 3); + Assert.That(toolTip.GetToolTip(bar), Is.EqualTo(expected)); + } + + [Test] + public void SmallOddWidth_AndResizeWithStaleHeight_DoNotSelectBuckets () + { + using var bar = CreateBar(5, 10); + bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 1, 14, Color.Red.ToArgb())] }, 10, false); + var selected = new List(); + bar.LineSelected += (_, args) => selected.Add(args.Line); + + RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 0, 2, 0)); + Assert.That(selected, Is.EqualTo([14])); + + bar.Height = 12; + RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 1, 2, 0)); + + Assert.That(selected, Is.EqualTo([14])); + } + + [Test] + public void SmallOddWidth_RendersEachLaneWithinItsVisibleColumn () + { + using var bar = CreateBar(5, 10); + bar.SetBuckets(new Dictionary> + { + [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 1, 10, Color.Red.ToArgb())], + [MarkerCategory.Bookmarks] = [new MarkerBucket(2, 20, 29, 1, 20, Color.Green.ToArgb())], + [MarkerCategory.Search] = [new MarkerBucket(2, 30, 39, 1, 30, Color.Blue.ToArgb())], + [MarkerCategory.Filter] = [new MarkerBucket(2, 40, 49, 1, 40, Color.Purple.ToArgb())] + }, 10, false); + + using var image = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(image, bar.ClientRectangle); + + Assert.Multiple(() => + { + Assert.That(image.GetPixel(0, 2).ToArgb(), Is.EqualTo(Color.Red.ToArgb())); + Assert.That(image.GetPixel(1, 2).ToArgb(), Is.EqualTo(Color.Green.ToArgb())); + Assert.That(image.GetPixel(2, 2).ToArgb(), Is.EqualTo(Color.Blue.ToArgb())); + Assert.That(image.GetPixel(3, 2).ToArgb(), Is.EqualTo(SystemColors.ControlDark.ToArgb())); + Assert.That(image.GetPixel(4, 2).ToArgb(), Is.EqualTo(Color.Purple.ToArgb())); + }); + } + + [TestCase("light", 255, 255, 255)] + [TestCase("dark", 32, 32, 32)] + public void Paint_UsesExplicitBackgroundForLightAndDarkModes (string _, int red, int green, int blue) + { + using var bar = CreateBar(40, 10); + bar.BackColor = Color.FromArgb(red, green, blue); + bar.ForeColor = Color.White; + bar.SetBuckets(new Dictionary>(), 10, false); + + using var image = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(image, bar.ClientRectangle); + + Assert.That(image.GetPixel(0, 0).ToArgb(), Is.EqualTo(bar.BackColor.ToArgb())); + } + + [TestCase(28, 400, 4, 8)] + [TestCase(42, 600, 6, 12)] + [TestCase(56, 800, 8, 16)] + public void ScaledGeometry_PaintsAndSelectsTheLastBucket (int width, int height, int topInset, int bottomInset) + { + using var bar = CreateBar(width, height); + bar.TopInset = topInset; + bar.BottomInset = bottomInset; + var bucketHeight = height - topInset - bottomInset; + var pixel = bucketHeight - 1; + bar.SetBuckets(new Dictionary> { [MarkerCategory.Filter] = [new MarkerBucket(pixel, 90, 99, 1, 95, Color.Orange.ToArgb())] }, bucketHeight, false); + var selected = new List(); + bar.LineSelected += (_, args) => selected.Add(args.Line); + + using var image = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(image, bar.ClientRectangle); + RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, width - 1, topInset + pixel, 0)); + + Assert.Multiple(() => + { + Assert.That(image.GetPixel(width - 1, topInset + pixel).ToArgb(), Is.EqualTo(Color.Orange.ToArgb())); + Assert.That(selected, Is.EqualTo([95])); + }); + } + + [Test] + public void DrawToBitmap_ProducesMarkerBarArtifact () + { + using var bar = CreateBar(80, 16); + bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(4, 20, 29, 2, 24, Color.Orange.ToArgb())] }, 16, false); + using var image = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(image, bar.ClientRectangle); + var path = Path.Join(TestContext.CurrentContext.WorkDirectory, "marker-bar.png"); + image.Save(path, System.Drawing.Imaging.ImageFormat.Png); + TestContext.AddTestAttachment(path); + Assert.That(File.Exists(path), Is.True); + } + + private static MarkerBar CreateBar (int width, int height) + { + var bar = new MarkerBar { Size = new Size(width, height) }; + _ = bar.Handle; + return bar; + } + + private static void RaiseMouse (MarkerBar bar, string methodName, MouseEventArgs args) + { + var method = typeof(Control).GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)!; + _ = method.Invoke(bar, [args]); + } +} \ No newline at end of file diff --git a/src/LogExpert.UI.Tests/MarkerWindowTests.cs b/src/LogExpert.UI.Tests/MarkerWindowTests.cs new file mode 100644 index 000000000..bc31009f6 --- /dev/null +++ b/src/LogExpert.UI.Tests/MarkerWindowTests.cs @@ -0,0 +1,543 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Runtime.Versioning; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Columnizer; +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; +using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Config; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogTabWindow; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Controls; + +using Moq; + +using NUnit.Framework; + +using Vanara.PInvoke; + +using WeifenLuo.WinFormsUI.Docking; + +namespace LogExpert.UI.Tests; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[NonParallelizable] +[SupportedOSPlatform("windows")] +public sealed class MarkerWindowTests : IDisposable +{ + private string _directory = null!; + private string _fileName = null!; + private Settings _settings = null!; + private Mock _config = null!; + private LogTabWindow? _window; + private Exception? _uiException; + private SystemColorMode _originalColorMode; + + [SetUp] + public void SetUp () + { + _originalColorMode = Application.ColorMode; + _uiException = null; + _directory = Path.Join(Path.GetTempPath(), "LogExpertMarkerTests", Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(_directory); + _fileName = Path.Join(_directory, "markers.log"); + File.WriteAllLines(_fileName, Enumerable.Range(0, 100).Select(line => line % 10 == 0 || line == 99 ? "ERROR" : "INFO")); + _settings = new Settings(); + _settings.Preferences.MultiFileOptions = new MultiFileOptions(); + _settings.Preferences.FollowTail = true; + _settings.Preferences.AskForClose = false; + _settings.Preferences.AutoPick = false; + _settings.Preferences.OpenLastFiles = false; + _settings.Preferences.SaveSessions = false; + _settings.Preferences.SaveLocation = SessionSaveLocation.SameDir; + _settings.Preferences.ShowMarkerBar = true; + _settings.Preferences.HighlightGroupList = [new HighlightGroup + { + GroupName = "markers", + HighlightEntryList = [new HighlightEntry { SearchText = "ERROR", BackgroundColor = Color.Red }] + }]; + _config = new Mock(); + _ = _config.Setup(config => config.Settings).Returns(_settings); + _ = _config.Setup(config => config.ActiveConfigDir).Returns(_directory); + _ = _config.Setup(config => config.ActiveSessionDir).Returns(_directory); + _ = PluginRegistry.PluginRegistry.Create(_directory, 50); + Application.ThreadException += OnUiException; + } + + [TearDown] + public void TearDown () + { + _settings.Preferences.SaveSessions = false; + try + { + if (_window != null) + { + _window.LogExpertProxy = null; + _window.Close(); + _window.Dispose(); + _window = null; + } + } + finally + { + Application.ThreadException -= OnUiException; + if (Application.ColorMode != _originalColorMode) + { + Application.SetColorMode(_originalColorMode); + } + } + + Directory.Delete(_directory, true); + } + + // The bar is 28 logical pixels wide: 100%, 150%, and 200% scaling have independent expected widths. + [TestCase(96, 28)] + [TestCase(144, 42)] + [TestCase(192, 56)] + // Synthetic DPI notifications must not leave state on the STA used by subsequent docking tests. + [RequiresThread(ApartmentState.STA)] + public void DockedDpiChange_ScalesMarkerBarWidth (int dpi, int expectedWidth) + { + var log = Open(); + var bar = Find(log, "markerBar"); + var controls = new List { _window! }; + for (var index = 0; index < controls.Count; index++) + { + controls.AddRange(controls[index].Controls.Cast().Where(control => control.IsHandleCreated)); + } + + // PMv2 delivers BEFOREPARENT bottom-up, changes the top-level form, then delivers AFTERPARENT top-down. + foreach (var control in controls.Skip(1).Reverse()) + { + _ = User32.SendMessage(control.Handle, User32.WindowMessage.WM_DPICHANGED_BEFOREPARENT, (nint)dpi, nint.Zero); + } + + var bounds = _window!.Bounds; + var suggestedBounds = new RECT(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom); + _ = User32.SendMessage(_window.Handle, User32.WindowMessage.WM_DPICHANGED, + (nint)(dpi | (dpi << 16)), ref suggestedBounds); + foreach (var control in controls.Skip(1)) + { + _ = User32.SendMessage(control.Handle, User32.WindowMessage.WM_DPICHANGED_AFTERPARENT, nint.Zero, nint.Zero); + } + + Assert.That(log.DeviceDpi, Is.EqualTo(dpi)); + Assert.That(bar.DeviceDpi, Is.EqualTo(dpi)); + bar.Parent!.PerformLayout(); + Assert.That(bar.Width, Is.EqualTo(expectedWidth)); + } + + [TestCase(SystemColorMode.Classic)] + [TestCase(SystemColorMode.Dark)] + public void Theme_BoldOnlyMarkersRenderTheLogWindowForeground (SystemColorMode colorMode) + { + Assert.That(Application.OpenForms, Is.Empty, "Theme checks must start without live windows from another test."); + Application.SetColorMode(colorMode); + Assert.That(Application.IsDarkModeEnabled, Is.EqualTo(colorMode == SystemColorMode.Dark)); + _settings.Preferences.HighlightGroupList[0].HighlightEntryList = + [new HighlightEntry { SearchText = "ERROR", IsBold = true }]; + var log = Open(); + var bar = Find(log, "markerBar"); + var grid = Find(log, "dataGridView"); + WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, true); + var expected = colorMode == SystemColorMode.Dark ? Color.White : Color.Black; + Assert.That(grid.ForeColor.ToArgb(), Is.EqualTo(expected.ToArgb())); + Assert.That(MarkerColor(bar, MarkerCategory.Highlights, 20, 100), Is.EqualTo(expected.ToArgb())); + + grid.ForeColor = Color.Yellow; + ApplyPreferences(log); + PumpUntil(() => MarkerColor(bar, MarkerCategory.Highlights, 20, 100) == Color.Yellow.ToArgb()); + } + + [Test] + public void Markers_NavigateStopTailAndRefreshRulesVisibilityAndBookmarks () + { + var log = Open(); + var bar = Find(log, "markerBar"); + WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, true); + log.ToggleBookmark(99); + WaitForMarker(bar, MarkerCategory.Bookmarks, 99, 100, true); + log.FollowTailChanged(true, false); + Click(bar, MarkerCategory.Bookmarks, 99, 100); + Assert.That(log.CurrentLineNum, Is.EqualTo(99)); + Assert.That(log.GatherSessionSnapshot().FollowTail, Is.False, "Clicking the last line must stop tail too."); + Click(bar, MarkerCategory.Highlights, 20, 100); + Assert.That(log.CurrentLineNum, Is.EqualTo(20)); + Assert.That(log.GatherSessionSnapshot().FirstDisplayedLine, Is.LessThanOrEqualTo(20)); + log.ToggleBookmark(99); + WaitForMarker(bar, MarkerCategory.Bookmarks, 99, 100, false); + + var rule = _settings.Preferences.HighlightGroupList[0].HighlightEntryList[0]; + rule.SearchText = "INFO"; + log.SetCurrentHighlightGroup("markers"); + WaitForMarker(bar, MarkerCategory.Highlights, 21, 100, true); + WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, false); + + var table = (TableLayoutPanel)bar.Parent!; + log.ForceColumnizer(new TimestampColumnizer()); + _settings.Preferences.ShowTimeSpread = true; + ApplyPreferences(log); + table.PerformLayout(); + var timeSpread = table.GetControlFromPosition(1, 1); + Assert.That(timeSpread, Is.Not.Null); + Assert.That(timeSpread!.Visible, Is.True); + Assert.That(bar.Visible, Is.True); + Assert.That(timeSpread.Bounds.IntersectsWith(bar.Bounds), Is.False); + var timeSpreadWidth = table.ColumnStyles[1].Width; + Assert.That(timeSpreadWidth, Is.GreaterThan(0)); + _settings.Preferences.ShowMarkerBar = false; + ApplyPreferences(log); + Assert.That(table.ColumnStyles[2].Width, Is.Zero); + Assert.That(table.ColumnStyles[1].Width, Is.EqualTo(timeSpreadWidth)); + _settings.Preferences.ShowMarkerBar = true; + ApplyPreferences(log); + WaitForMarker(bar, MarkerCategory.Highlights, 21, 100, true); + + _window!.Size = new Size(800, 600); + WaitForMarker(bar, MarkerCategory.Highlights, 21, 100, true); + using var image = new Bitmap(log.Width, log.Height); + log.DrawToBitmap(image, log.ClientRectangle); + var path = Path.Join(TestContext.CurrentContext.WorkDirectory, "marker-window.png"); + image.Save(path); + TestContext.AddTestAttachment(path); + } + + [Test] + public void HiddenLogWindow_ProcessesAppendedMarkersAfterRestore () + { + var log = Open(); + var bar = Find(log, "markerBar"); + WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, true); + + log.Hide(); + Assert.Multiple(() => + { + Assert.That(log.DockState, Is.EqualTo(DockState.Hidden)); + Assert.That(log.Visible, Is.False); + }); + + File.AppendAllText(_fileName, "ERROR\r\n"); + PumpUntil(() => log.GatherSessionSnapshot().LineCount == 101); + + log.Show(Find(_window!, "dockPanel")); + PumpUntil(() => log.Visible && log.DockState != DockState.Hidden); + WaitForMarker(bar, MarkerCategory.Highlights, 100, 101, true); + var expectedTooltip = string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ToolTip, + Resources.MarkerBar_Highlights, 101, 101, 1); + // The previous final-line marker occupies the same pixel until the batched refresh publishes. + PumpUntil(() => HoverMarker(bar, MarkerCategory.Highlights, 100, 101).StartsWith(expectedTooltip, StringComparison.Ordinal)); + Click(bar, MarkerCategory.Highlights, 100, 101); + Assert.That(log.CurrentLineNum, Is.EqualTo(100)); + } + + [Test] + public void SearchAndFilter_TrackExecutedCriteriaClearAndTailWithoutSpreadLines () + { + var log = Open(); + var bar = Find(log, "markerBar"); + _window!.SearchParams.SearchText = "ERROR"; + log.StartSearch(); + WaitForMarker(bar, MarkerCategory.Search, 20, 100, true); + ((ToolStripMenuItem)bar.ContextMenuStrip!.Items[0]).PerformClick(); + WaitForMarker(bar, MarkerCategory.Search, 20, 100, false); + _window.SearchParams.IsFindNext = true; + log.StartSearch(); + PumpFor(TimeSpan.FromMilliseconds(600)); + Assert.That(HasMarker(bar, MarkerCategory.Search, 20, 100), Is.False, "F3 must not restore a cleared marker search."); + _window.SearchParams.IsFindNext = false; + _window.SearchParams.SearchText = "INFO"; + log.StartSearch(); + WaitForMarker(bar, MarkerCategory.Search, 21, 100, true); + WaitForMarker(bar, MarkerCategory.Search, 20, 100, false); + + if (!Find