From ba69052f779a625fa90f5e5df2df2ae30744d53d Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 16 Sep 2026 15:52:38 +0200 Subject: [PATCH 01/17] Add configurable log overview marker bar (#27) --- docs/implementation/issue-27-marker-bar.md | 70 +++ src/CsvColumnizer/CsvColumnizer.cs | 31 +- src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs | 20 +- .../Classes/Bookmark/BookmarkDataProvider.cs | 29 +- .../Classes/Marker/MarkerBucket.cs | 54 +++ .../Classes/Marker/MarkerCriteria.cs | 118 ++++++ .../Classes/Marker/MarkerIndex.cs | 205 +++++++++ .../Classes/Marker/MarkerLine.cs | 4 + .../Classes/Marker/MarkerSnapshot.cs | 20 + src/LogExpert.Core/Config/Preferences.cs | 10 + .../Marker/BookmarkMarkerTests.cs | 45 ++ .../Marker/MarkerBucketTests.cs | 61 +++ .../Marker/MarkerCriteriaTests.cs | 63 +++ .../Marker/MarkerIndexTests.cs | 147 +++++++ .../Marker/MarkerPerformanceTests.cs | 179 ++++++++ src/LogExpert.Resources/Resources.Designer.cs | 135 ++++++ src/LogExpert.Resources/Resources.resx | 27 ++ .../PreferencesMarkerBarTests.cs | 58 +++ .../Dialogs/SettingsDialogMarkerBarTests.cs | 53 +++ src/LogExpert.Tests/LogExpert.Tests.csproj | 1 + src/LogExpert.Tests/UI/MarkerBarTests.cs | 174 ++++++++ .../UI/MarkerColumnizerSnapshotTests.cs | 145 +++++++ src/LogExpert.Tests/UI/MarkerWindowTests.cs | 401 ++++++++++++++++++ .../Controls/LogWindow/LogWindow.MarkerBar.cs | 383 +++++++++++++++++ .../Controls/LogWindow/LogWindow.cs | 73 +++- .../Controls/LogWindow/MarkerBar.cs | 177 ++++++++ .../Dialogs/SettingsDialog.MarkerBar.cs | 70 +++ src/LogExpert.UI/Dialogs/SettingsDialog.cs | 5 +- src/RegexColumnizer/RegexColumnizer.cs | 18 +- 29 files changed, 2755 insertions(+), 21 deletions(-) create mode 100644 docs/implementation/issue-27-marker-bar.md create mode 100644 src/LogExpert.Core/Classes/Marker/MarkerBucket.cs create mode 100644 src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs create mode 100644 src/LogExpert.Core/Classes/Marker/MarkerIndex.cs create mode 100644 src/LogExpert.Core/Classes/Marker/MarkerLine.cs create mode 100644 src/LogExpert.Core/Classes/Marker/MarkerSnapshot.cs create mode 100644 src/LogExpert.Persister.Tests/Marker/BookmarkMarkerTests.cs create mode 100644 src/LogExpert.Persister.Tests/Marker/MarkerBucketTests.cs create mode 100644 src/LogExpert.Persister.Tests/Marker/MarkerCriteriaTests.cs create mode 100644 src/LogExpert.Persister.Tests/Marker/MarkerIndexTests.cs create mode 100644 src/LogExpert.Persister.Tests/Marker/MarkerPerformanceTests.cs create mode 100644 src/LogExpert.Tests/ConfigManagerTests/PreferencesMarkerBarTests.cs create mode 100644 src/LogExpert.Tests/Dialogs/SettingsDialogMarkerBarTests.cs create mode 100644 src/LogExpert.Tests/UI/MarkerBarTests.cs create mode 100644 src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs create mode 100644 src/LogExpert.Tests/UI/MarkerWindowTests.cs create mode 100644 src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs create mode 100644 src/LogExpert.UI/Controls/LogWindow/MarkerBar.cs create mode 100644 src/LogExpert.UI/Dialogs/SettingsDialog.MarkerBar.cs diff --git a/docs/implementation/issue-27-marker-bar.md b/docs/implementation/issue-27-marker-bar.md new file mode 100644 index 000000000..8d0b89a4c --- /dev/null +++ b/docs/implementation/issue-27-marker-bar.md @@ -0,0 +1,70 @@ +# Issue 27: log marker bar + +Issue: https://github.com/LogExperts/LogExpert/issues/27 + +Enable **Settings → Marker bar → Show marker bar**. The four fixed lanes, from left to right, represent highlights, bookmarks, Log Search hits, and Window Filter hits. Each source has its own saved visibility setting. The bar is initially hidden. Right-click it and choose **Clear search** to remove that window's search markers; an unchanged F3 search leaves them cleared. + +Markers use logical lines, including for files without timestamps. A click selects and reveals the matching line nearest the pixel bucket's logical midpoint, with lower-line tie breaking, and stops follow-tail. Tooltips report the category, one-based line range, and matching-line count. Time Spread retains its separate layout column and behavior. + +## Implementation and review evidence + +- `MarkerCriteria` snapshots visual rules/search criteria and performs pure matching. Whole-line rules inspect raw text and displayed columns; word rules inspect displayed columns. Temporary search highlights and trigger-only rules are excluded. No marker code calls highlight triggers. +- `MarkerIndex` scans on a worker, pins small reader batches, retains immutable match chunks, and rereads only the former final line plus appended content. Reset/close cancels scans; obsolete generations cannot replace current results. Regex failures become reported outcomes. +- `MarkerBucket` aggregates every matching line. Counts exclude duplicate lines, colors follow group priority, endpoints reach the top/bottom, and resizing consumes cached matches. +- `LogWindow.MarkerBar` batches updates with a 250 ms UI timer and aggregates pixels in the background. It consumes bookmark snapshots and actual filter hit lists, including partial filter results. A short lifecycle lock couples frame snapshots to their generation and protects publication against rollover/reload and criteria changes. +- Column discovery uses an independent parser. CSV, Regex, and Log4j implement `ICloneable` to preserve their current configuration/runtime state; other columnizers use the existing factory and initialization callback. Custom stateful columnizers can implement `ICloneable` to preserve state beyond saved configuration. Timestamp offsets are captured and changes invalidate markers. +- The standards review checked repository instructions, `.editorconfig`, neighboring code, resource localization, Core/UI separation, disposal, and the absence of per-control auto-scaling. An unrelated generated resource change was reverted. +- The issue review identified displayed-column matching, parser isolation, atomic frame generation, and timeshift invalidation defects. All four were corrected and re-reviewed; regression tests cover the matching and parser behavior. + +## Standards + +No remaining substantive Standards findings. The unrelated PluginTrust resource change was restored. The final review checked CSV, Regex, and Log4j snapshots, marker integration, the test project reference, and public configuration-based snapshot tests. No actionable smell-baseline findings remained. A final test-harness finding was corrected by registering the temporary UI exception handler after successful setup and detaching it in a cleanup finally block. Changes follow existing Core/UI boundaries and repository conventions. + +## Spec + +No blocking Spec findings or scope creep remained after correcting the four review defects. Independent columnizers preserve configured parsing state; generation checks protect publication; matching includes displayed columns; timestamp shifts invalidate highlights. Setup failures use existing logging and status reporting. + +One non-blocking observation remains: bookmark/filter data changes use the nominal 250 ms batched refresh. A removed marker can remain clickable until that refresh because navigation validates its file/criteria generation, not every data revision. + +Final review totals: Standards 0 remaining findings; Spec 0 blocking findings and 1 minor batching observation. Both final reviews were static; runtime evidence is reported separately below. + +## Validation + +- Required clean Nuke build passed with zero errors. The clean build reported 252 warnings; subsequent test-await cleanup removed the new CA2007 warnings. Remaining marker-specific warnings are the intentional exception boundaries (CA1031) and a CSV test-data literal (CA1303); no analyzer settings were changed or suppressed. +- Required Nuke full-suite run was attempted. Four projects completed successfully: Persister/Core 128, PluginRegistry 340, RegexColumnizer 63, and ColumnizerLib 7 tests. The main test host was interrupted after an unresponsive UI run; the marker fixture correction described below prevents modal exception dialogs in its tests. +- Final main-suite rerun excluding `ClipboardHelperTests`: **1,196 passed, 7 skipped, 0 failed** (4.12 minutes). Clipboard tests had already crashed the original Development baseline with `ClipboardLock` unable to open the clipboard; they remain an unverified environmental dependency for this change. Across the five completed project runs, **1,734 tests passed**. +- An earlier main-suite rerun had one failure in `Startup_SavedPositionAndTail_ExplicitTargetWins(True)` (expected line 2, observed 3). Both focused cases passed on the feature branch and original Development; all 14 baseline navigation-fixture cases passed, and the final main-suite rerun passed the case. No navigation logic was changed to address that transient failure. +- Marker coverage comprises **23 Core tests and 25 UI/settings/columnizer tests**, plus two passing explicit performance experiments. The final focused rerun also covers the exception-handler cleanup added after the main run. + +Validation logs are local TEMP artifacts: `logexpert-27-clean-compile.log`, `logexpert-27-full-tests.log`, `logexpert-27-main-final.log`, `logexpert-27-ui-complete.log`, `logexpert-27-index-performance-final.log`, and `logexpert-27-real-performance-isolated.log`. + +Reproduction commands (Windows, .NET 10): + +```powershell +./build.ps1 --target Clean Compile +./build.ps1 --target Test +# Baseline clipboard fixture requires a usable desktop clipboard +dotnet test src/LogExpert.Tests/LogExpert.Tests.csproj --no-build --filter 'FullyQualifiedName!~ClipboardHelperTests' --blame-hang-timeout 2m --blame-hang-dump-type none + +dotnet test src/LogExpert.Persister.Tests/LogExpert.Persister.Tests.csproj --no-build --filter FullyQualifiedName~LogExpert.Persister.Tests.Marker +dotnet test src/LogExpert.Tests/LogExpert.Tests.csproj --no-build --filter 'FullyQualifiedName~LogExpert.Tests.UI.Marker|FullyQualifiedName~PreferencesMarkerBarTests|FullyQualifiedName~SettingsDialogMarkerBarTests' + +# Explicit performance experiments +dotnet test src/LogExpert.Persister.Tests/LogExpert.Persister.Tests.csproj --no-build --filter FullyQualifiedName~MarkerIndex_ScanAndAppend_DenseMatchesReportPerformanceAndCorrectness --logger 'console;verbosity=detailed' +dotnet test src/LogExpert.Tests/LogExpert.Tests.csproj --no-build --filter FullyQualifiedName~DenseFile_ReportsDiscoveryAppendAndUiResponsiveness --logger 'console;verbosity=detailed' +``` + +The UI fixtures use real files and a WinForms message loop. They cover navigation/tail stopping, all four lanes, filter spread exclusion, restoration/truncation, inactive windows with independent searches, resizing, Time Spread coexistence, parser isolation, and timestamp shifts. Rendering tests check light/dark backgrounds and 100%, 150%, and 200% scaled geometry. They do not substitute for testing native theme changes and dragging the application between physical monitors with different DPI settings. + +## Performance measurements + +Measured on Windows 11 (10.0.22631), .NET SDK 10.0.106, Debug, 14 logical processors; native test-window DPI was 96. These are single local runs, with other validation work running concurrently. + +| Experiment | Initial discovery | Append | Retained managed-memory delta | +| --- | ---: | ---: | ---: | +| Real watched file, 500,000 lines / 28,000,000 bytes, four regex rules, every line matching | 3,939 ms | 2,000 lines / 48,000 bytes: 737 ms including watcher and UI refresh | 6,098,936 bytes after discovery | +| In-memory reader, 500,000 lines / modeled 14,000,000 UTF-8 bytes, same four-rule shape | 1,870.5 ms | 2,000 lines: 9.43 ms | 6,061,952 bytes after discovery; 28,472 bytes after append | + +The real file finished at 502,000 lines and 28,048,000 bytes. During discovery, the test serviced 206 WinForms message-loop pumps; the longest `Application.DoEvents` call took 56.1 ms. This measures UI message processing, not end-to-end input latency or every frame interval. The in-memory test verified all 502,000 matches and all bucket counts (2,000 pixel buckets); cumulative allocations were 266,137,160 bytes for discovery and 1,124,704 bytes for append. Retained memory is measured with forced GC and includes runtime/reader caching, not only the marker index. + +A prior benchmark attempt entered a WinForms exception dialog in DockPanel document-icon painting (`Icon.Handle` / `VS2013DockPaneStrip.DrawTab_Document`), while the marker worker was idle. The marker fixture now disables shell document icons and captures UI exceptions as test failures instead of opening modal dialogs. Production icon behavior is unchanged; the measured control remains the real marker bar in a real Log Window. diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs index afce24e8b..a4b10714b 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, ICloneable { #region Fields @@ -220,6 +220,35 @@ public void DeSelected (ILogLineMemoryColumnizerCallback callback) // nothing to do } + public object Clone () + { + 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..16b7dfb2a 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, ICloneable { #region Fields @@ -278,6 +278,24 @@ public void LoadConfig (string configDir) } } + public object Clone () + { + 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/Marker/MarkerBucket.cs b/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs new file mode 100644 index 000000000..7cde1e6be --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs @@ -0,0 +1,54 @@ +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; duplicate lines count only once. + public static IReadOnlyList Aggregate (IEnumerable matches, int lineCount, int height) + { + 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]; + 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; + 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; + } + + 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..6127ff6b8 --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs @@ -0,0 +1,118 @@ +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]; + var background = (!entry.IsWordMatch || !entry.NoBackground) && entry.BackgroundColor.A > 0; + 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) + { + columns ??= getColumns?.Invoke(lineNumber, line) + ?? [new Column { FullValue = line.FullLine }]; + matched = columns.Any(column => HighlightEvaluator.IsMatch(entry, column)); + } + } + + if (matched) + { + var color = background ? entry.BackgroundColor + : entry.ForegroundColor.A > 0 ? entry.ForegroundColor : Color.Gray; + return new MarkerLine(lineNumber, color.ToArgb(), priority); + } + } + + return null; + } + + private static bool IsVisual (HighlightEntry entry) + { + return !entry.IsSearchHit && (((!entry.IsWordMatch || !entry.NoBackground) && entry.BackgroundColor.A > 0) + || entry.ForegroundColor.A > 0 || entry.IsBold); + } + + 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..158a32c0c --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerIndex.cs @@ -0,0 +1,205 @@ +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 cts) + { + 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;) + { + cts.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++) + { + cts.Token.ThrowIfCancellationRequested(); + var line = reader.GetLogLineMemory(lineNumber) + ?? throw new IOException(); + var match = criteria.Match(lineNumber, line, columns, cts.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 (cts.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; + } + } + + cts.Dispose(); + } + } +} \ 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..ee00a6270 --- /dev/null +++ b/src/LogExpert.Core/Classes/Marker/MarkerLine.cs @@ -0,0 +1,4 @@ +namespace LogExpert.Core.Classes.Marker; + +/// One matching logical line. Lower priorities precede higher priorities in the Highlight Group. +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.Persister.Tests/Marker/BookmarkMarkerTests.cs b/src/LogExpert.Persister.Tests/Marker/BookmarkMarkerTests.cs new file mode 100644 index 000000000..de385e61f --- /dev/null +++ b/src/LogExpert.Persister.Tests/Marker/BookmarkMarkerTests.cs @@ -0,0 +1,45 @@ +using LogExpert.Core.Classes.Bookmark; +using LogExpert.Core.Entities; + +namespace LogExpert.Persister.Tests.Marker; + +[TestFixture] +public class BookmarkMarkerTests +{ + [Test] + public void GetBookmarkLineNumbers_ReturnsImmutableSnapshotAcrossBookmarkChanges () + { + var provider = new BookmarkDataProvider(); + provider.SetBookmarks(new SortedList + { + { 10, new Bookmark(10) }, + { 20, Bookmark.CreateAutoGenerated(20, "auto", "hit") } + }); + + var snapshot = provider.GetBookmarkLineNumbers(); + + provider.AddBookmark(new Bookmark(30)); + provider.RemoveBookmarkForLine(10); + _ = provider.AddBookmarks([new Bookmark(40), Bookmark.CreateAutoGenerated(50, "auto", "hit")]); + Assert.That(provider.GetBookmarkLineNumbers(), Is.EqualTo(new[] { 20, 30, 40, 50 })); + provider.SetBookmarks(new SortedList + { + { 60, new Bookmark(60) }, + { 70, Bookmark.CreateAutoGenerated(70, "auto", "hit") } + }); + provider.ClearAllBookmarks(); + Assert.That(provider.GetBookmarkLineNumbers(), Is.Empty); + provider.SetBookmarks(new SortedList + { + { 80, new Bookmark(80) }, + { 90, Bookmark.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.Persister.Tests/Marker/MarkerBucketTests.cs b/src/LogExpert.Persister.Tests/Marker/MarkerBucketTests.cs new file mode 100644 index 000000000..1766d4f5b --- /dev/null +++ b/src/LogExpert.Persister.Tests/Marker/MarkerBucketTests.cs @@ -0,0 +1,61 @@ +using LogExpert.Core.Classes.Marker; + +namespace LogExpert.Persister.Tests.Marker; + +[TestFixture] +public class MarkerBucketTests +{ + [Test] + public void Aggregate_FirstAndLastLinesReachBothEndsOfATallBar () + { + var buckets = MarkerBucket.Aggregate([new MarkerLine(0, 10), new MarkerLine(2, 20)], 3, 400); + 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); + + 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), + 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), Is.Empty); + } + + [Test] + public void Aggregate_SingleLineAndResizeKeepTheSameMatch () + { + MarkerLine[] matches = [new(0, 10)]; + Assert.That(MarkerBucket.Aggregate(matches, 1, 1), + Is.EqualTo(new MarkerBucket[] { new(0, 0, 0, 1, 0, 10) })); + Assert.That(MarkerBucket.Aggregate(matches, 1, 300), + 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), + Is.EqualTo(new MarkerBucket[] { new(1, 2147483646, 2147483646, 1, 2147483646, 10) })); + } +} \ No newline at end of file diff --git a/src/LogExpert.Persister.Tests/Marker/MarkerCriteriaTests.cs b/src/LogExpert.Persister.Tests/Marker/MarkerCriteriaTests.cs new file mode 100644 index 000000000..dd2263b46 --- /dev/null +++ b/src/LogExpert.Persister.Tests/Marker/MarkerCriteriaTests.cs @@ -0,0 +1,63 @@ +using System.Drawing; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; +using LogExpert.Core.Entities; + +namespace LogExpert.Persister.Tests.Marker; + +[TestFixture] +public class MarkerCriteriaTests +{ + [TestCase(false)] + [TestCase(true)] + public void Highlights_MatchDisplayedColumnsForBothRuleKinds (bool wordMatch) + { + 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, Is.EqualTo(new MarkerLine(0, Color.Yellow.ToArgb()))); + } + [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.Persister.Tests/Marker/MarkerIndexTests.cs b/src/LogExpert.Persister.Tests/Marker/MarkerIndexTests.cs new file mode 100644 index 000000000..2f085a462 --- /dev/null +++ b/src/LogExpert.Persister.Tests/Marker/MarkerIndexTests.cs @@ -0,0 +1,147 @@ +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; + +namespace LogExpert.Persister.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.Persister.Tests/Marker/MarkerPerformanceTests.cs b/src/LogExpert.Persister.Tests/Marker/MarkerPerformanceTests.cs new file mode 100644 index 000000000..9c82d9588 --- /dev/null +++ b/src/LogExpert.Persister.Tests/Marker/MarkerPerformanceTests.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; +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; + +namespace LogExpert.Persister.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); + + 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); + + 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.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index c3ee90d1e..5e0208340 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -4146,6 +4146,87 @@ 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 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 +5562,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 +5580,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 +6526,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.resx b/src/LogExpert.Resources/Resources.resx index 6398c8c6d..40c6068ac 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2331,4 +2331,31 @@ 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 + + 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.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/Dialogs/SettingsDialogMarkerBarTests.cs b/src/LogExpert.Tests/Dialogs/SettingsDialogMarkerBarTests.cs new file mode 100644 index 000000000..74c8877e8 --- /dev/null +++ b/src/LogExpert.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.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/LogExpert.Tests.csproj b/src/LogExpert.Tests/LogExpert.Tests.csproj index addb6a7a1..a95118deb 100644 --- a/src/LogExpert.Tests/LogExpert.Tests.csproj +++ b/src/LogExpert.Tests/LogExpert.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/src/LogExpert.Tests/UI/MarkerBarTests.cs b/src/LogExpert.Tests/UI/MarkerBarTests.cs new file mode 100644 index 000000000..7f4f0a59e --- /dev/null +++ b/src/LogExpert.Tests/UI/MarkerBarTests.cs @@ -0,0 +1,174 @@ +using System.Globalization; +using System.Reflection; + +using LogExpert.Core.Classes.Marker; +using LogExpert.UI.Controls.LogWindow; + +using NUnit.Framework; + +namespace LogExpert.Tests.UI; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public class MarkerBarTests +{ + [Test] + public void Paint_RendersEachLaneBucketWithItsColor () + { + using var bar = CreateBar(40, 10); + bar.SetBuckets( + [ + [new MarkerBucket(2, 10, 19, 1, 12, Color.Red.ToArgb())], + [new MarkerBucket(2, 20, 29, 1, 22, Color.Green.ToArgb())], + [new MarkerBucket(2, 30, 39, 1, 32, Color.Blue.ToArgb())], + [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 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 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 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 MarkerBucket(2, 10, 19, 1, 10, Color.Red.ToArgb())], + [new MarkerBucket(2, 20, 29, 1, 20, Color.Green.ToArgb())], + [new MarkerBucket(2, 30, 39, 1, 30, Color.Blue.ToArgb())], + [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([[], [], [], []], 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 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 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.Combine(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.Tests/UI/MarkerColumnizerSnapshotTests.cs b/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs new file mode 100644 index 000000000..2d0dc131b --- /dev/null +++ b/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs @@ -0,0 +1,145 @@ +using ColumnizerLib; + +using CsvColumnizer; + +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 CsvClone_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 clone = (CsvColumnizer.CsvColumnizer)((ICloneable)original).Clone(); + File.WriteAllText(Path.Join(directory, "csvcolumnizer.json"), JsonConvert.SerializeObject(new + { + DelimiterChar = ";", + EscapeChar = "\"", + QuoteChar = "\"", + CommentChar = "#", + HasFieldNames = true, + MinColumns = 0 + })); + original.LoadConfig(directory); + + var result = clone.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 RegexClone_SplitLinePreservesInitializedParseAfterOriginalConfigChanges () + { + var directory = CreateTempDirectory(); + try + { + WriteRegexConfig(directory, "(?[^:]+):(?.+)"); + var original = new Regex1Columnizer(); + original.LoadConfig(directory); + var clone = (BaseRegexColumnizer)((ICloneable)original).Clone(); + WriteRegexConfig(directory, "(?.+)"); + original.LoadConfig(directory); + + var result = clone.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 Log4jClone_SplitLinePreservesInitializedParseAndTimeOffsetAfterOriginalConfigChanges () + { + var directory = CreateTempDirectory(); + try + { + WriteLog4jConfig(directory, true); + var original = new Log4jXmlColumnizer.Log4jXmlColumnizer(); + original.LoadConfig(directory); + original.SetTimeOffset(123); + var clone = (Log4jXmlColumnizer.Log4jXmlColumnizer)((ICloneable)original).Clone(); + WriteLog4jConfig(directory, false); + original.LoadConfig(directory); + + var line = new LogLine("1700000000000�INFO�Logger�Thread�Class�Method�File�12�Message", 1); + var result = clone.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(clone.GetTimeOffset(), Is.EqualTo(123)); + }); + } + finally + { + Directory.Delete(directory, true); + } + } + + 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/UI/MarkerWindowTests.cs b/src/LogExpert.Tests/UI/MarkerWindowTests.cs new file mode 100644 index 000000000..9e2a02d28 --- /dev/null +++ b/src/LogExpert.Tests/UI/MarkerWindowTests.cs @@ -0,0 +1,401 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Runtime.Versioning; + +using LogExpert.Core.Classes.Columnizer; +using LogExpert.Core.Classes.Highlight; +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; + +namespace LogExpert.Tests.UI; + +[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; + + [SetUp] + public void SetUp () + { + _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; + } + + Directory.Delete(_directory, true); + } + + [Test] + public void Markers_NavigateStopTailAndRefreshRulesVisibilityAndBookmarks () + { + var log = Open(); + var bar = Find(log, "markerBar"); + WaitForMarker(bar, 0, 20, 100, true); + log.ToggleBookmark(99); + WaitForMarker(bar, 1, 99, 100, true); + log.FollowTailChanged(true, false); + Click(bar, 1, 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, 0, 20, 100); + Assert.That(log.CurrentLineNum, Is.EqualTo(20)); + Assert.That(log.GatherSessionSnapshot().FirstDisplayedLine, Is.LessThanOrEqualTo(20)); + log.ToggleBookmark(99); + WaitForMarker(bar, 1, 99, 100, false); + + var rule = _settings.Preferences.HighlightGroupList[0].HighlightEntryList[0]; + rule.SearchText = "INFO"; + log.SetCurrentHighlightGroup("markers"); + WaitForMarker(bar, 0, 21, 100, true); + WaitForMarker(bar, 0, 20, 100, false); + + var table = (TableLayoutPanel)bar.Parent!; + log.ForceColumnizer(new TimestampColumnizer()); + _settings.Preferences.ShowTimeSpread = true; + ApplyPreferences(log); + 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, 0, 21, 100, true); + + _window!.Size = new Size(800, 600); + WaitForMarker(bar, 0, 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 SearchAndFilter_TrackExecutedCriteriaClearAndTailWithoutSpreadLines () + { + var log = Open(); + var bar = Find(log, "markerBar"); + _window!.SearchParams.SearchText = "ERROR"; + log.StartSearch(); + WaitForMarker(bar, 2, 20, 100, true); + ((ToolStripMenuItem)bar.ContextMenuStrip!.Items[0]).PerformClick(); + WaitForMarker(bar, 2, 20, 100, false); + _window.SearchParams.IsFindNext = true; + log.StartSearch(); + PumpFor(TimeSpan.FromMilliseconds(600)); + Assert.That(HasMarker(bar, 2, 20, 100), Is.False, "F3 must not restore a cleared marker search."); + _window.SearchParams.IsFindNext = false; + _window.SearchParams.SearchText = "INFO"; + log.StartSearch(); + WaitForMarker(bar, 2, 21, 100, true); + WaitForMarker(bar, 2, 20, 100, false); + + if (!Find