From 6211da7797135bf1b2fcf4752d2f325f87a227bd Mon Sep 17 00:00:00 2001 From: Pr0metheus2 Date: Mon, 19 Oct 2026 11:02:23 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Fix=20MultiFile=20=E2=80=9CMax=20days?= =?UTF-8?q?=E2=80=9D=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added the file’s UTC last-write timestamp to ILogFileInfo. - Implemented timestamp retrieval for: - Local files - SFTP files - Applied the Max days limit when MultiFile companion files are discovered. - The file explicitly opened by the user is always retained. - MaxDayTry = 0 preserves the previous unlimited behavior. - Filesystem plugins that cannot provide a timestamp retain the previous behavior and their files are not excluded. --- src/ColumnizerLib/ILogFileInfo.cs | 8 ++- .../Classes/Log/RolloverFilenameHandler.cs | 18 +++-- .../Entities/MultifileOptions.cs | 6 +- src/LogExpert.Tests/LogExpert.Tests.csproj | 3 + src/LogExpert.Tests/RolloverHandlerTest.cs | 56 +++++++++++++++- .../LogfileReaderMultiFileFlagTests.cs | 67 ++++++++++++++++++- src/LogExpert.Tests/TestData/app.log.2 | 1 + src/PluginRegistry/FileSystem/LogFileInfo.cs | 11 ++- src/SftpFileSystemx64/SftpLogFileInfo.cs | 22 +++++- 9 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 src/LogExpert.Tests/TestData/app.log.2 diff --git a/src/ColumnizerLib/ILogFileInfo.cs b/src/ColumnizerLib/ILogFileInfo.cs index 6a10b530c..b2d3052e5 100644 --- a/src/ColumnizerLib/ILogFileInfo.cs +++ b/src/ColumnizerLib/ILogFileInfo.cs @@ -51,6 +51,12 @@ public interface ILogFileInfo /// bool FileExists { get; } + /// + /// Gets the time when the file was last written, in UTC, or when the file system plugin + /// cannot provide it. + /// + DateTime? LastWriteTimeUtc => null; + /// /// The interval (in ms) LogExpert should check for file changes. The property is checked by LogExpert repeatedly in the loop which /// checks for file changes. So you can adjust the poll interval as needed. E.g. you can lower the interval when many changes occur and @@ -81,4 +87,4 @@ public interface ILogFileInfo bool FileHasChanged(); #endregion -} \ No newline at end of file +} diff --git a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs index 1c4fe52c9..3b16aab49 100644 --- a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs +++ b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs @@ -69,7 +69,7 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry) _filenameBuilder.Index += 1; fileName = _filenameBuilder.BuildFileName(); filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine - if (FileExists(filePath, pluginRegistry)) + if (FileExistsWithinAgeLimit(filePath, pluginRegistry)) { _ = fileList.AddFirst(filePath); found = true; @@ -87,7 +87,7 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry) _filenameBuilder.DecrementDate(); fileName = _filenameBuilder.BuildFileName(); filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine - if (FileExists(filePath, pluginRegistry)) + if (FileExistsWithinAgeLimit(filePath, pluginRegistry)) { _ = fileList.AddFirst(filePath); found = true; @@ -106,7 +106,7 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry) #region Private Methods - private bool FileExists(string filePath, IPluginRegistry pluginRegistry) + private bool FileExistsWithinAgeLimit(string filePath, IPluginRegistry pluginRegistry) { var fs = pluginRegistry.FindFileSystemForUri(filePath); @@ -116,8 +116,16 @@ private bool FileExists(string filePath, IPluginRegistry pluginRegistry) } var info = fs.GetLogfileInfo(filePath); - return info is not null && info.FileExists; + if (info is null || !info.FileExists) + { + return false; + } + + // Zero keeps the historical unlimited behavior. Files without timestamp support are retained. + return _options.MaxDayTry <= 0 + || info.LastWriteTimeUtc is not { } lastWriteTimeUtc + || lastWriteTimeUtc >= DateTime.UtcNow.AddDays(-_options.MaxDayTry); } #endregion -} \ No newline at end of file +} diff --git a/src/LogExpert.Core/Entities/MultifileOptions.cs b/src/LogExpert.Core/Entities/MultifileOptions.cs index b9ab6d782..a1b0c16d3 100644 --- a/src/LogExpert.Core/Entities/MultifileOptions.cs +++ b/src/LogExpert.Core/Entities/MultifileOptions.cs @@ -5,9 +5,13 @@ public class MultiFileOptions { #region Properties + /// + /// Gets or sets the maximum age, in days, of companion files loaded into a MultiFile. For date-based patterns, + /// this also limits how many missing calendar days are searched. Zero disables the file-age limit. + /// public int MaxDayTry { get; set; } = 3; public string FormatPattern { get; set; } = "*$J(.)"; #endregion -} \ No newline at end of file +} diff --git a/src/LogExpert.Tests/LogExpert.Tests.csproj b/src/LogExpert.Tests/LogExpert.Tests.csproj index 1f64b7ffe..b78e92ee0 100644 --- a/src/LogExpert.Tests/LogExpert.Tests.csproj +++ b/src/LogExpert.Tests/LogExpert.Tests.csproj @@ -111,6 +111,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/LogExpert.Tests/RolloverHandlerTest.cs b/src/LogExpert.Tests/RolloverHandlerTest.cs index 1f5e13b17..20f683172 100644 --- a/src/LogExpert.Tests/RolloverHandlerTest.cs +++ b/src/LogExpert.Tests/RolloverHandlerTest.cs @@ -91,4 +91,58 @@ public void TestFilenameListWithDate (string format, int retries) Cleanup(); } -} \ No newline at end of file + + [Test] + [TestCase("*$J(.)", "app.log.1", "app.log.2")] + [TestCase("*$J(.).log", "app.1.log", "app.2.log")] + public void GetNameList_ExcludesIndexedFilesOlderThanMaxDays ( + string formatPattern, + string recentFileName, + string oldFileName) + { + var directory = Directory.CreateDirectory(TEST_DIR_NAME); + TestDirectory = directory; + var activeFile = CreateFile(directory, "app.log"); + var recentFile = CreateFile(directory, recentFileName); + var oldFile = CreateFile(directory, oldFileName); + File.SetLastWriteTimeUtc(activeFile, DateTime.UtcNow); + File.SetLastWriteTimeUtc(recentFile, DateTime.UtcNow.AddDays(-2)); + File.SetLastWriteTimeUtc(oldFile, DateTime.UtcNow.AddDays(-4)); + + MultiFileOptions options = new() + { + FormatPattern = formatPattern, + MaxDayTry = 3 + }; + ILogFileInfo info = new LogFileInfo(new Uri(activeFile)); + RolloverFilenameHandler handler = new(info, options); + + var fileList = handler.GetNameList(PluginRegistry.PluginRegistry.Instance); + + Assert.That(fileList, Is.EqualTo(new[] { recentFile, activeFile })); + Cleanup(); + } + + [Test] + public void GetNameList_WithZeroMaxDays_DoesNotFilterByAge () + { + var directory = Directory.CreateDirectory(TEST_DIR_NAME); + TestDirectory = directory; + var activeFile = CreateFile(directory, "app.log"); + var oldFile = CreateFile(directory, "app.log.1"); + File.SetLastWriteTimeUtc(oldFile, DateTime.UtcNow.AddYears(-1)); + + MultiFileOptions options = new() + { + FormatPattern = "*$J(.)", + MaxDayTry = 0 + }; + ILogFileInfo info = new LogFileInfo(new Uri(activeFile)); + RolloverFilenameHandler handler = new(info, options); + + var fileList = handler.GetNameList(PluginRegistry.PluginRegistry.Instance); + + Assert.That(fileList, Is.EqualTo(new[] { oldFile, activeFile })); + Cleanup(); + } +} diff --git a/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs b/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs index 00e36f731..c1a681f4f 100644 --- a/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs +++ b/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs @@ -26,6 +26,8 @@ public void SetUp () File.Copy(Path.Join(_testDataDirectory, "app.log"), _logFile); File.Copy(Path.Join(_testDataDirectory, "app.log.1"), _logFile + ".1"); + File.SetLastWriteTimeUtc(_logFile, DateTime.UtcNow); + File.SetLastWriteTimeUtc(_logFile + ".1", DateTime.UtcNow); _ = PluginRegistry.PluginRegistry.Create(_testDirectory, 500); } @@ -68,12 +70,39 @@ public void SingleFileCtor_MultiFileTrue_ExpandsRollover () }); } + [Test] + public void SingleFileCtor_MultiFileTrue_ExcludesIndexAfterExtensionFilesOlderThanMaxDays () + { + var oldRolloverFile = _logFile + ".2"; + File.Copy(Path.Combine(_testDataDirectory, "app.log.2"), oldRolloverFile); + var now = DateTime.UtcNow; + File.SetLastWriteTimeUtc(_logFile, now); + File.SetLastWriteTimeUtc(_logFile + ".1", now.AddDays(-2)); + File.SetLastWriteTimeUtc(oldRolloverFile, now.AddDays(-4)); + MultiFileOptions options = new() + { + FormatPattern = "*$J(.)", + MaxDayTry = 3 + }; + using var reader = CreateSingleFileReader(multiFile: true, options); + + reader.ReadFiles(); + + Assert.Multiple(() => + { + Assert.That(reader.IsMultiFile, Is.True); + Assert.That(reader.GetLogFileInfoList().Select(file => Path.GetFileName(file.FullName)), + Is.EqualTo(new[] { "app.log.1", "app.log" })); + }); + } + [Test] public void SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension () { var options = new MultiFileOptions { FormatPattern = "*$J(.).log" }; - File.Copy(Path.Combine(_testDataDirectory, "app.1.log"), Path.Combine(_testDirectory, "app.1.log")); - File.Copy(Path.Combine(_testDataDirectory, "app.2.log"), Path.Combine(_testDirectory, "app.2.log")); + var (firstRolloverFile, secondRolloverFile) = CopyIndexBeforeExtensionFiles(); + File.SetLastWriteTimeUtc(firstRolloverFile, DateTime.UtcNow); + File.SetLastWriteTimeUtc(secondRolloverFile, DateTime.UtcNow); using var reader = CreateSingleFileReader(multiFile: true, options); reader.ReadFiles(); @@ -86,6 +115,31 @@ public void SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension () }); } + [Test] + public void SingleFileCtor_MultiFileTrue_ExcludesIndexBeforeExtensionFilesOlderThanMaxDays () + { + var options = new MultiFileOptions + { + FormatPattern = "*$J(.).log", + MaxDayTry = 3 + }; + var (firstRolloverFile, secondRolloverFile) = CopyIndexBeforeExtensionFiles(); + var now = DateTime.UtcNow; + File.SetLastWriteTimeUtc(_logFile, now); + File.SetLastWriteTimeUtc(firstRolloverFile, now.AddDays(-2)); + File.SetLastWriteTimeUtc(secondRolloverFile, now.AddDays(-4)); + using var reader = CreateSingleFileReader(multiFile: true, options); + + reader.ReadFiles(); + + Assert.Multiple(() => + { + Assert.That(reader.IsMultiFile, Is.True); + Assert.That(reader.GetLogFileInfoList().Select(file => Path.GetFileName(file.FullName)), + Is.EqualTo(new[] { "app.1.log", "app.log" })); + }); + } + [Test] public void MultiFileCtor_AlwaysMultiFile () { @@ -124,4 +178,13 @@ private LogfileReader CreateSingleFileReader (bool multiFile, MultiFileOptions? progressReporter: NullProgressReporter.Instance); } + private (string FirstRolloverFile, string SecondRolloverFile) CopyIndexBeforeExtensionFiles () + { + var firstRolloverFile = Path.Combine(_testDirectory, "app.1.log"); + var secondRolloverFile = Path.Combine(_testDirectory, "app.2.log"); + File.Copy(Path.Combine(_testDataDirectory, "app.1.log"), firstRolloverFile); + File.Copy(Path.Combine(_testDataDirectory, "app.2.log"), secondRolloverFile); + return (firstRolloverFile, secondRolloverFile); + } + } diff --git a/src/LogExpert.Tests/TestData/app.log.2 b/src/LogExpert.Tests/TestData/app.log.2 new file mode 100644 index 000000000..501086237 --- /dev/null +++ b/src/LogExpert.Tests/TestData/app.log.2 @@ -0,0 +1 @@ +app.log.2 diff --git a/src/PluginRegistry/FileSystem/LogFileInfo.cs b/src/PluginRegistry/FileSystem/LogFileInfo.cs index bfcdbe003..cd6727753 100644 --- a/src/PluginRegistry/FileSystem/LogFileInfo.cs +++ b/src/PluginRegistry/FileSystem/LogFileInfo.cs @@ -88,6 +88,15 @@ public bool FileExists } } + public DateTime? LastWriteTimeUtc + { + get + { + fInfo.Refresh(); + return fInfo.Exists ? fInfo.LastWriteTimeUtc : null; + } + } + //TODO this should be set from outside once public int PollInterval => PluginRegistry.PollingInterval; @@ -174,4 +183,4 @@ public override string ToString () } #endregion -} \ No newline at end of file +} diff --git a/src/SftpFileSystemx64/SftpLogFileInfo.cs b/src/SftpFileSystemx64/SftpLogFileInfo.cs index 6cf5cca7c..585913645 100644 --- a/src/SftpFileSystemx64/SftpLogFileInfo.cs +++ b/src/SftpFileSystemx64/SftpLogFileInfo.cs @@ -235,6 +235,26 @@ public string FileName public string FullName => Uri.ToString(); + public DateTime? LastWriteTimeUtc + { + get + { + try + { + var file = (SftpFile)_sftp.Get(_remoteFileName); + return file.Attributes.LastWriteTimeUtc; + } + catch (Exception e) when (e is SshException or + SftpPathNotFoundException or + SftpPermissionDeniedException or + ArgumentNullException) + { + _logger.LogError(e.Message); + return null; + } + } + } + public long Length { get @@ -305,4 +325,4 @@ public Stream OpenStream () } #endregion -} \ No newline at end of file +} From 963aaa52ec7986e191185a1129a1597a8e04516a Mon Sep 17 00:00:00 2001 From: Pr0metheus2 Date: Mon, 19 Oct 2026 11:15:42 +0200 Subject: [PATCH 2/3] Hint added EN, DE, CNs --- src/LogExpert.Resources/Resources.Designer.cs | 9 +++++++++ src/LogExpert.Resources/Resources.de.resx | 3 +++ src/LogExpert.Resources/Resources.resx | 3 +++ src/LogExpert.Resources/Resources.zh-CN.resx | 5 ++++- .../Dialogs/MultiFileMaskDialog.Designer.cs | 14 +++++++++++++- src/LogExpert.UI/Dialogs/MultiFileMaskDialog.cs | 3 ++- 6 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index c9a2efa58..c5cb10175 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -4442,6 +4442,15 @@ public static string MultiFileMaskDialog_UI_Label_MaxDays { return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDays", resourceCulture); } } + + /// + /// Looks up a localized string similar to 0 = Unlimited number of days. + /// + public static string MultiFileMaskDialog_UI_Label_MaxDaysHint { + get { + return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDaysHint", resourceCulture); + } + } /// /// Looks up a localized string similar to MultiFile settings for:. diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 2ef41f1cb..882157a39 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -1679,6 +1679,9 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Max Tage: + + 0 = Unbegrenzte Tage + Muster syntax: diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 79a319f5b..7891f537b 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -1742,6 +1742,9 @@ Checked tools will appear in the icon bar. All other tools are available in the Max days: + + 0 = Unlimited number of days + Pattern syntax: diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index d8026b569..cf4bd7c05 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -1548,6 +1548,9 @@ 最大天数: + + 0 = 天数不受限制 + 模式语法: @@ -2261,4 +2264,4 @@ YY[YY] = 年 无法应用隐藏规则,显示所有行:{0} - \ No newline at end of file + diff --git a/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.Designer.cs b/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.Designer.cs index 2d95ab396..ecd580420 100644 --- a/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.Designer.cs +++ b/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.Designer.cs @@ -33,6 +33,7 @@ private void InitializeComponent() this.labelFileName = new System.Windows.Forms.Label(); this.labelFileNamePattern = new System.Windows.Forms.Label(); this.upDownMaxDays = new System.Windows.Forms.NumericUpDown(); + this.labelMaxDaysHint = new System.Windows.Forms.Label(); this.fileNamePatternTextBox = new System.Windows.Forms.TextBox(); this.labelMaxDays = new System.Windows.Forms.Label(); this.Settings = new System.Windows.Forms.GroupBox(); @@ -86,7 +87,16 @@ private void InitializeComponent() 0, 0, 0}); - // + // + // labelMaxDaysHint + // + this.labelMaxDaysHint.AutoSize = true; + this.labelMaxDaysHint.Location = new System.Drawing.Point(146, 57); + this.labelMaxDaysHint.Name = "labelMaxDaysHint"; + this.labelMaxDaysHint.Size = new System.Drawing.Size(202, 20); + this.labelMaxDaysHint.TabIndex = 6; + this.labelMaxDaysHint.Text = "0 = Unlimited number of days"; + // // fileNamePatternTextBox // this.fileNamePatternTextBox.Location = new System.Drawing.Point(149, 25); @@ -110,6 +120,7 @@ private void InitializeComponent() this.Settings.Controls.Add(this.labelFileNamePattern); this.Settings.Controls.Add(this.labelMaxDays); this.Settings.Controls.Add(this.upDownMaxDays); + this.Settings.Controls.Add(this.labelMaxDaysHint); this.Settings.Controls.Add(this.fileNamePatternTextBox); this.Settings.Location = new System.Drawing.Point(15, 39); this.Settings.Name = "Settings"; @@ -182,6 +193,7 @@ private void InitializeComponent() private System.Windows.Forms.Label labelFileName; private System.Windows.Forms.Label labelFileNamePattern; private System.Windows.Forms.NumericUpDown upDownMaxDays; + private System.Windows.Forms.Label labelMaxDaysHint; private System.Windows.Forms.TextBox fileNamePatternTextBox; private System.Windows.Forms.Label labelMaxDays; private System.Windows.Forms.GroupBox Settings; diff --git a/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.cs b/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.cs index ef81346a5..054e2a2d6 100644 --- a/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.cs +++ b/src/LogExpert.UI/Dialogs/MultiFileMaskDialog.cs @@ -34,6 +34,7 @@ private void ApplyResources () labelMultiSettingsFor.Text = Resources.MultiFileMaskDialog_UI_Label_SettingsFor; labelFileNamePattern.Text = Resources.MultiFileMaskDialog_UI_Label_FileNamePattern; labelMaxDays.Text = Resources.MultiFileMaskDialog_UI_Label_MaxDays; + labelMaxDaysHint.Text = Resources.MultiFileMaskDialog_UI_Label_MaxDaysHint; syntaxHelpLabel.Text = Resources.MultiFileMaskDialog_UI_Label_SyntaxHelp; buttonOk.Text = Resources.LogExpert_Common_UI_Button_OK; buttonCancel.Text = Resources.LogExpert_Common_UI_Button_Cancel; @@ -66,4 +67,4 @@ private void OnMultiFileMaskDialogLoad (object sender, EventArgs e) } #endregion -} \ No newline at end of file +} From a7dd5f9fe0034f6003306b809d3681a740f0c7f5 Mon Sep 17 00:00:00 2001 From: Pr0metheus2 Date: Mon, 19 Oct 2026 14:56:41 +0200 Subject: [PATCH 3/3] Fixed update as per request --- src/ColumnizerLib/ColumnizerLib.csproj | 1 + src/ColumnizerLib/ILogFileInfo.cs | 2 +- .../Classes/Log/RolloverFilenameHandler.cs | 47 ++++- .../Classes/Persister/PersistenceData.cs | 2 + .../Classes/Persister/PersisterXML.cs | 12 ++ .../Classes/Persister/SessionFileComposer.cs | 2 + .../Classes/Persister/SessionSnapshot.cs | 2 + .../Entities/MultifileOptions.cs | 10 +- .../PersisterTests.cs | 3 + .../PersisterXmlMalformedTests.cs | 1 + .../SessionFileComposerTests.cs | 26 +++ src/LogExpert.Resources/Resources.Designer.cs | 63 +++++- src/LogExpert.Resources/Resources.de.resx | 33 ++- src/LogExpert.Resources/Resources.resx | 33 ++- src/LogExpert.Resources/Resources.zh-CN.resx | 33 ++- .../PreferencesMultiFileOptionsTests.cs | 48 +++++ src/LogExpert.Tests/LogExpert.Tests.csproj | 3 - .../RolloverFilenameHandlerAgeTests.cs | 194 ++++++++++++++++++ src/LogExpert.Tests/RolloverHandlerTest.cs | 56 +---- .../LogfileReaderMultiFileFlagTests.cs | 67 +----- src/LogExpert.Tests/TestData/app.log.2 | 1 - .../Dialogs/MultiFileAgeSettingsTests.cs | 107 ++++++++++ .../Controls/LogWindow/LogWindow.cs | 10 +- .../Dialogs/MultiFileMaskDialog.Designer.cs | 113 ++++++---- .../Dialogs/MultiFileMaskDialog.cs | 18 +- .../Dialogs/SettingsDialog.Designer.cs | 69 +++++-- src/LogExpert.UI/Dialogs/SettingsDialog.cs | 6 +- src/PluginRegistry/FileSystem/LogFileInfo.cs | 2 +- src/SftpFileSystemx64/SftpLogFileInfo.cs | 63 +++--- 29 files changed, 759 insertions(+), 268 deletions(-) create mode 100644 src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs create mode 100644 src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs delete mode 100644 src/LogExpert.Tests/TestData/app.log.2 create mode 100644 src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs diff --git a/src/ColumnizerLib/ColumnizerLib.csproj b/src/ColumnizerLib/ColumnizerLib.csproj index df508ab62..54252f118 100644 --- a/src/ColumnizerLib/ColumnizerLib.csproj +++ b/src/ColumnizerLib/ColumnizerLib.csproj @@ -8,6 +8,7 @@ ColumnizerLib LogExpert.ColumnizerLib + 1.22.0 diff --git a/src/ColumnizerLib/ILogFileInfo.cs b/src/ColumnizerLib/ILogFileInfo.cs index b2d3052e5..ed5a36309 100644 --- a/src/ColumnizerLib/ILogFileInfo.cs +++ b/src/ColumnizerLib/ILogFileInfo.cs @@ -87,4 +87,4 @@ public interface ILogFileInfo bool FileHasChanged(); #endregion -} +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs index 3b16aab49..a7cefec28 100644 --- a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs +++ b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs @@ -21,6 +21,7 @@ public class RolloverFilenameHandler private readonly RolloverFilenameBuilder _filenameBuilder; private readonly ILogFileInfo _logFileInfo; private readonly MultiFileOptions _options; + private readonly TimeProvider _timeProvider; #endregion @@ -31,10 +32,12 @@ public class RolloverFilenameHandler /// /// The complete path of the logfile /// Multifile option (e.g. format pattern) - public RolloverFilenameHandler(ILogFileInfo logFileInfo, MultiFileOptions options) + /// Time source used to calculate the file-age cutoff. + public RolloverFilenameHandler(ILogFileInfo logFileInfo, MultiFileOptions options, TimeProvider timeProvider = null) { _options = options; _logFileInfo = logFileInfo; + _timeProvider = timeProvider ?? TimeProvider.System; _filenameBuilder = new RolloverFilenameBuilder(_options.FormatPattern); _filenameBuilder.SetFileName(logFileInfo.FileName); } @@ -69,12 +72,18 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry) _filenameBuilder.Index += 1; fileName = _filenameBuilder.BuildFileName(); filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine - if (FileExistsWithinAgeLimit(filePath, pluginRegistry)) + var result = GetCandidateResult(filePath, pluginRegistry); + if (result == CandidateResult.Include) { _ = fileList.AddFirst(filePath); found = true; continue; } + + if (result == CandidateResult.TooOld) + { + return fileList; + } } // if file with index isn't found or no index is in format pattern, decrement the current date @@ -87,13 +96,19 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry) _filenameBuilder.DecrementDate(); fileName = _filenameBuilder.BuildFileName(); filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine - if (FileExistsWithinAgeLimit(filePath, pluginRegistry)) + var result = GetCandidateResult(filePath, pluginRegistry); + if (result == CandidateResult.Include) { _ = fileList.AddFirst(filePath); found = true; break; } + if (result == CandidateResult.TooOld) + { + return fileList; + } + tryCounter++; } } @@ -106,26 +121,36 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry) #region Private Methods - private bool FileExistsWithinAgeLimit(string filePath, IPluginRegistry pluginRegistry) + private CandidateResult GetCandidateResult(string filePath, IPluginRegistry pluginRegistry) { var fs = pluginRegistry.FindFileSystemForUri(filePath); if(fs == null) { - return false; + return CandidateResult.Missing; } var info = fs.GetLogfileInfo(filePath); if (info is null || !info.FileExists) { - return false; + return CandidateResult.Missing; } - // Zero keeps the historical unlimited behavior. Files without timestamp support are retained. - return _options.MaxDayTry <= 0 - || info.LastWriteTimeUtc is not { } lastWriteTimeUtc - || lastWriteTimeUtc >= DateTime.UtcNow.AddDays(-_options.MaxDayTry); + if (_options.MaxFileAgeDays <= 0 || info.LastWriteTimeUtc is not { } lastWriteTimeUtc) + { + return CandidateResult.Include; + } + + var cutoffUtc = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-_options.MaxFileAgeDays); + return lastWriteTimeUtc >= cutoffUtc ? CandidateResult.Include : CandidateResult.TooOld; + } + + private enum CandidateResult + { + Missing, + Include, + TooOld } #endregion -} +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs index 37e80e377..fb72943c0 100644 --- a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs +++ b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs @@ -71,6 +71,8 @@ public class PersistenceData public int MultiFileMaxDays { get; set; } + public int MultiFileMaxFileAgeDays { get; set; } + public List MultiFileNames { get; set; } = []; public string MultiFilePattern { get; set; } diff --git a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs index f27285a72..a236e19b8 100644 --- a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs +++ b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs @@ -521,6 +521,18 @@ FormatException or persistenceData.MultiFileMaxDays = 0; } + value = GetOptionsAttribute(optionsNode, "multifile", "maxFileAgeDays"); + try + { + persistenceData.MultiFileMaxFileAgeDays = value != null ? int.Parse(value, CultureInfo.InvariantCulture) : 0; + } + catch (Exception ex) when (ex is ArgumentNullException or + FormatException or + OverflowException) + { + persistenceData.MultiFileMaxFileAgeDays = 0; + } + XmlNode multiFileNode = optionsNode.SelectSingleNode("multifile"); if (multiFileNode != null) { diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs index 490aa8c83..5482075e2 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs @@ -36,6 +36,7 @@ public static PersistenceData Compose (SessionSnapshot snapshot) FilterSaveListVisible = snapshot.FilterSaveListVisible, MultiFile = snapshot.MultiFile, MultiFileMaxDays = snapshot.MultiFileMaxDays, + MultiFileMaxFileAgeDays = snapshot.MultiFileMaxFileAgeDays, MultiFilePattern = snapshot.MultiFilePattern, TabName = snapshot.TabName, HighlightGroupName = snapshot.HighlightGroupName, @@ -83,6 +84,7 @@ public static SessionSnapshot Decompose (PersistenceData persistenceData) FilterSaveListVisible = persistenceData.FilterSaveListVisible, MultiFile = persistenceData.MultiFile, MultiFileMaxDays = persistenceData.MultiFileMaxDays, + MultiFileMaxFileAgeDays = persistenceData.MultiFileMaxFileAgeDays, MultiFilePattern = persistenceData.MultiFilePattern, TabName = persistenceData.TabName, HighlightGroupName = persistenceData.HighlightGroupName, diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs index bec804861..462309012 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs @@ -43,6 +43,8 @@ public class SessionSnapshot public int MultiFileMaxDays { get; set; } + public int MultiFileMaxFileAgeDays { get; set; } + public string MultiFilePattern { get; set; } public string TabName { get; set; } diff --git a/src/LogExpert.Core/Entities/MultifileOptions.cs b/src/LogExpert.Core/Entities/MultifileOptions.cs index a1b0c16d3..e83d1362f 100644 --- a/src/LogExpert.Core/Entities/MultifileOptions.cs +++ b/src/LogExpert.Core/Entities/MultifileOptions.cs @@ -6,12 +6,16 @@ public class MultiFileOptions #region Properties /// - /// Gets or sets the maximum age, in days, of companion files loaded into a MultiFile. For date-based patterns, - /// this also limits how many missing calendar days are searched. Zero disables the file-age limit. + /// Gets or sets how many consecutive days without a file may be skipped when walking a date pattern. /// public int MaxDayTry { get; set; } = 3; + /// + /// Gets or sets the maximum age, in days, of rollover files loaded into a MultiFile. Zero disables the limit. + /// + public int MaxFileAgeDays { get; set; } + public string FormatPattern { get; set; } = "*$J(.)"; #endregion -} +} \ No newline at end of file diff --git a/src/LogExpert.Persister.Tests/PersisterTests.cs b/src/LogExpert.Persister.Tests/PersisterTests.cs index 52e0dcb78..b07be02cd 100644 --- a/src/LogExpert.Persister.Tests/PersisterTests.cs +++ b/src/LogExpert.Persister.Tests/PersisterTests.cs @@ -357,6 +357,7 @@ public void RoundTrip_WithApplicationStartupDir_PreservesAllData () MultiFile = true, MultiFilePattern = "test*.log", MultiFileMaxDays = 7, + MultiFileMaxFileAgeDays = 5, LineCount = 1000, CellSelectMode = true }; @@ -377,6 +378,8 @@ public void RoundTrip_WithApplicationStartupDir_PreservesAllData () Assert.That(loadedData.MultiFile, Is.EqualTo(originalData.MultiFile), "MultiFile should match"); Assert.That(loadedData.MultiFilePattern, Is.EqualTo(originalData.MultiFilePattern), "MultiFilePattern should match"); Assert.That(loadedData.MultiFileMaxDays, Is.EqualTo(originalData.MultiFileMaxDays), "MultiFileMaxDays should match"); + Assert.That(loadedData.MultiFileMaxFileAgeDays, Is.EqualTo(originalData.MultiFileMaxFileAgeDays), + "MultiFileMaxFileAgeDays should match"); Assert.That(loadedData.LineCount, Is.EqualTo(originalData.LineCount), "LineCount should match"); Assert.That(loadedData.CellSelectMode, Is.EqualTo(originalData.CellSelectMode), "CellSelectMode should match"); } diff --git a/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs b/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs index ee6aaba95..b4546e5f7 100644 --- a/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs +++ b/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs @@ -44,6 +44,7 @@ public void Load_FileElementWithoutOptions_ReturnsDefaults () Assert.That(data.MultiFile, Is.False); Assert.That(data.MultiFilePattern, Is.Null); Assert.That(data.MultiFileMaxDays, Is.Zero); + Assert.That(data.MultiFileMaxFileAgeDays, Is.Zero); Assert.That(data.FilterVisible, Is.False); // A missing means "nothing was persisted", so every option keeps its diff --git a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs index b1c97af82..f820d7165 100644 --- a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs +++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs @@ -172,6 +172,7 @@ private static SessionSnapshot CreateFullSnapshot (int filterTabDepth = 2) FilterSaveListVisible = true, MultiFile = true, MultiFileMaxDays = 7, + MultiFileMaxFileAgeDays = 5, MultiFilePattern = "app*.log", TabName = "My Tab", HighlightGroupName = "Errors", @@ -247,6 +248,7 @@ private static PersistenceData CreateCarriedPersistenceData (int filterTabDepth) FilterSaveListVisible = true, MultiFile = true, MultiFileMaxDays = 7, + MultiFileMaxFileAgeDays = 5, MultiFilePattern = "app*.log", TabName = "My Tab", HighlightGroupName = "Errors", @@ -422,6 +424,28 @@ public void DiskTrip_ComposePersistLoadDecompose_ReturnsEqualSnapshot () Assert.That(SerializeForComparison(roundTripped), Is.EqualTo(SerializeForComparison(snapshot))); } + [Test] + public void Decompose_LegacyJsonWithoutMaxFileAgeDays_DefaultsToOff () + { + var sessionFileName = Path.Join(_testDirectory, "legacy-json.lxp"); + File.WriteAllText(sessionFileName, """ + { + "MultiFile": true, + "MultiFileMaxDays": 7, + "MultiFilePattern": "*$J(.)" + } + """); + + var loaded = Core.Classes.Persister.Persister.Load(sessionFileName); + var snapshot = SessionFileComposer.Decompose(loaded); + + Assert.Multiple(() => + { + Assert.That(loaded.MultiFileMaxFileAgeDays, Is.Zero); + Assert.That(snapshot.MultiFileMaxFileAgeDays, Is.Zero); + }); + } + #endregion #region Window filter (issue #666) @@ -490,6 +514,8 @@ public void Decompose_LegacyXmlSessionFileOnDisk_TakesTheFirstFilterAsTheWindowF Assert.That(snapshot.FilterParams.SearchText, Is.EqualTo("the first global filter")); Assert.That(snapshot.FilterParams.SpreadBefore, Is.EqualTo(1), "the restored entry must be the first one, whole — the second carries SpreadBefore 2"); + Assert.That(snapshot.MultiFileMaxFileAgeDays, Is.Zero, + "legacy XML Session Files without the new field must keep age filtering disabled"); Assert.That(snapshot.FilterParams.Color, Is.EqualTo(Color.Black), "Color is the field the legacy payload writes as \"Black\" — losing it means the entry was not really read"); } diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index c5cb10175..7109f7a3f 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -4435,20 +4435,29 @@ public static string MultiFileMaskDialog_UI_Label_FileNamePattern { } /// - /// Looks up a localized string similar to Max days:. + /// Looks up a localized string similar to Max day gap:. /// - public static string MultiFileMaskDialog_UI_Label_MaxDays { + public static string MultiFileMaskDialog_UI_Label_MaxDayGap { get { - return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDays", resourceCulture); + return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDayGap", resourceCulture); } } /// - /// Looks up a localized string similar to 0 = Unlimited number of days. + /// Looks up a localized string similar to Max file age (days):. /// - public static string MultiFileMaskDialog_UI_Label_MaxDaysHint { + public static string MultiFileMaskDialog_UI_Label_MaxFileAgeDays { get { - return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDaysHint", resourceCulture); + return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxFileAgeDays", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 0 = Off. + /// + public static string MultiFileMaskDialog_UI_Label_MaxFileAgeDaysHint { + get { + return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxFileAgeDaysHint", resourceCulture); } } @@ -6193,11 +6202,47 @@ public static string SettingsDialog_UI_Label_labelLinesPerBlock { } /// - /// Looks up a localized string similar to Max days:. + /// Looks up a localized string similar to Max day gap:. + /// + public static string SettingsDialog_UI_Label_labelMaxDayGap { + get { + return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDayGap", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Max file age (days):. + /// + public static string SettingsDialog_UI_Label_labelMaxFileAgeDays { + get { + return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxFileAgeDays", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 0 = Off. + /// + public static string SettingsDialog_UI_Label_labelMaxFileAgeDaysHint { + get { + return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxFileAgeDaysHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to For $D patterns: how many consecutive days without a log file to skip before giving up.. + /// + public static string SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileDays { + get { + return ResourceManager.GetString("SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileDays", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maximum age of rollover files to load, in days. Set to 0 to disable the age limit.. /// - public static string SettingsDialog_UI_Label_labelMaxDays { + public static string SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileMaxFileAgeDays { get { - return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDays", resourceCulture); + return ResourceManager.GetString("SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileMaxFileAgeDays", resourceCulture); } } diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 882157a39..c27cab565 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -721,8 +721,20 @@ Damit die anderen Fenster beim Auswählen einer Zeile (Mausklick oder Pfeiltaste Pattern: - - Maximale Tage: + + Max. Tageslücke: + + + Max. Dateialter (Tage): + + + 0 = Aus + + + Bei $D-Mustern: Anzahl aufeinanderfolgender Tage ohne Protokolldatei, die vor dem Abbruch übersprungen werden. + + + Maximales Alter der zu ladenden Rollover-Dateien in Tagen. 0 deaktiviert die Altersgrenze. Argumente: @@ -1676,11 +1688,14 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Dateinamemuster: - - Max Tage: + + Max. Tageslücke: + + + Max. Dateialter (Tage): - - 0 = Unbegrenzte Tage + + 0 = Aus Muster syntax: @@ -1699,7 +1714,11 @@ Beispiele: DD = Tag MM = Monat YY[YY] = Jahr -Alle anderen Zeichen werden benutzt wie sie angegeben sind +Alle anderen Zeichen werden benutzt wie sie angegeben sind + +MultiFile-Grenzen: +Max. Tageslücke = Bei $D-Mustern aufeinanderfolgende Tage ohne Protokolldatei, die vor dem Abbruch übersprungen werden +Max. Dateialter (Tage) = Älteste zu ladende Rollover-Dateien in Tagen; 0 deaktiviert die Altersgrenze Spalten diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 7891f537b..20dce13fc 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -719,8 +719,20 @@ Checked tools will appear in the icon bar. All other tools are available in the Arguments: - - Max days: + + Max day gap: + + + Max file age (days): + + + 0 = Off + + + For $D patterns: how many consecutive days without a log file to skip before giving up. + + + Maximum age of rollover files to load, in days. Set to 0 to disable the age limit. Pattern: @@ -1739,11 +1751,14 @@ Checked tools will appear in the icon bar. All other tools are available in the File name pattern: - - Max days: + + Max day gap: + + + Max file age (days): - - 0 = Unlimited number of days + + 0 = Off Pattern syntax: @@ -1762,7 +1777,11 @@ Examples: DD = day MM = month YY[YY] = year -all other chars will be used as given +all other chars will be used as given + +MultiFile limits: +Max day gap = For $D patterns, consecutive days without a log file to skip before giving up +Max file age (days) = Oldest rollover files to load; 0 disables the age limit Columns diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index cf4bd7c05..e3bffad1e 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -616,8 +616,20 @@ 参数: - - 最大天数: + + 最大日期间隔: + + + 最大文件年龄(天): + + + 0 = 关闭 + + + 对于 $D 模式:放弃搜索前可跳过的连续无日志文件天数。 + + + 要加载的滚动日志文件的最大天数。设置为 0 可关闭文件期限限制。 模式: @@ -1545,11 +1557,14 @@ 文件名模式: - - 最大天数: + + 最大日期间隔: + + + 最大文件年龄(天): - - 0 = 天数不受限制 + + 0 = 关闭 模式语法: @@ -1568,7 +1583,11 @@ $J(<前缀>) = 类似 $J,但在非零时添加 <前缀> DD = 日 MM = 月 YY[YY] = 年 -其它字符将按原样使用 +其它字符将按原样使用 + +多文件限制: +最大日期间隔 = 对于 $D 模式,放弃搜索前可跳过的连续无日志文件天数 +最大文件年龄(天) = 要加载的最旧滚动日志文件天数;0 表示关闭期限限制 列 diff --git a/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs new file mode 100644 index 000000000..c56b796a5 --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs @@ -0,0 +1,48 @@ +using LogExpert.Core.Config; +using LogExpert.Core.Entities; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests.ConfigManagerTests; + +[TestFixture] +public class PreferencesMultiFileOptionsTests +{ + [Test] + public void Deserialize_LegacyJsonWithoutMaxFileAgeDays_DefaultsToOff () + { + const string legacyJson = """ + { + "MultiFileOptions": { + "FormatPattern": "*$J(.)", + "MaxDayTry": 7 + } + } + """; + + var preferences = JsonConvert.DeserializeObject(legacyJson); + + Assert.That(preferences, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(preferences!.MultiFileOptions.MaxDayTry, Is.EqualTo(7)); + Assert.That(preferences.MultiFileOptions.MaxFileAgeDays, Is.Zero); + }); + } + + [Test] + public void RoundTrip_PreservesMaxFileAgeDays () + { + var preferences = new Preferences + { + MultiFileOptions = new MultiFileOptions { MaxFileAgeDays = 14 } + }; + + var restored = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(preferences)); + + Assert.That(restored, Is.Not.Null); + Assert.That(restored!.MultiFileOptions.MaxFileAgeDays, Is.EqualTo(14)); + } +} diff --git a/src/LogExpert.Tests/LogExpert.Tests.csproj b/src/LogExpert.Tests/LogExpert.Tests.csproj index b78e92ee0..1f64b7ffe 100644 --- a/src/LogExpert.Tests/LogExpert.Tests.csproj +++ b/src/LogExpert.Tests/LogExpert.Tests.csproj @@ -111,9 +111,6 @@ PreserveNewest - - PreserveNewest - PreserveNewest diff --git a/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs b/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs new file mode 100644 index 000000000..b3a3036b5 --- /dev/null +++ b/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs @@ -0,0 +1,194 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Log; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests; + +[TestFixture] +internal sealed class RolloverFilenameHandlerAgeTests +{ + private const string LOG_DIRECTORY = "/logs"; + private static readonly DateTimeOffset _now = new(2026, 10, 19, 12, 0, 0, TimeSpan.Zero); + + [TestCase("*$J(.)", "app.log.1", "app.log.2")] + [TestCase("*$J(.).log", "app.1.log", "app.2.log")] + public void GetNameList_MaxFileAge_StopsAtFirstOldIndexedFile ( + string pattern, + string recentFileName, + string oldFileName) + { + var context = CreateContext( + "app.log", + new MultiFileOptions { FormatPattern = pattern, MaxFileAgeDays = 5 }, + (recentFileName, _now.AddDays(-2).UtcDateTime), + (oldFileName, _now.AddDays(-10).UtcDateTime)); + + var result = context.Handler.GetNameList(context.Registry); + + Assert.That(result, Is.EqualTo(new[] { GetPath(recentFileName), GetPath("app.log") })); + } + + [Test] + public void GetNameList_ZeroMaxFileAge_LoadsAllIndexedFiles () + { + var context = CreateContext( + "app.log", + new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 0 }, + ("app.log.1", _now.AddDays(-10).UtcDateTime), + ("app.log.2", _now.AddDays(-20).UtcDateTime)); + + var result = context.Handler.GetNameList(context.Registry); + + Assert.That(result, Is.EqualTo(new[] { GetPath("app.log.2"), GetPath("app.log.1"), GetPath("app.log") })); + } + + [Test] + public void GetNameList_FirstRolloverIsOld_StopsBeforeNewerHigherIndex () + { + var context = CreateContext( + "app.log", + new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 }, + ("app.log.1", _now.AddDays(-10).UtcDateTime), + ("app.log.2", _now.AddDays(-1).UtcDateTime)); + + var result = context.Handler.GetNameList(context.Registry); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(new[] { GetPath("app.log") })); + Assert.That(context.RequestedPaths, Does.Not.Contain(GetPath("app.log.2"))); + }); + } + + [Test] + public void GetNameList_OpenedFileIsOld_StillIncludesOpenedFile () + { + var context = CreateContext( + "app.log", + new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 }, + activeLastWriteTimeUtc: _now.AddDays(-10).UtcDateTime); + + var result = context.Handler.GetNameList(context.Registry); + + Assert.That(result, Is.EqualTo(new[] { GetPath("app.log") })); + } + + [Test] + public void GetNameList_DatePattern_SkipsConfiguredGapAndStopsAtFirstOldFile () + { + var context = CreateContext( + "app_2026-10-19.log", + new MultiFileOptions + { + FormatPattern = "*$D(yyyy-MM-dd).log", + MaxDayTry = 3, + MaxFileAgeDays = 5 + }, + ("app_2026-10-17.log", _now.AddDays(-2).UtcDateTime), + ("app_2026-10-16.log", _now.AddDays(-10).UtcDateTime)); + + var result = context.Handler.GetNameList(context.Registry); + + Assert.That(result, Is.EqualTo(new[] { GetPath("app_2026-10-17.log"), GetPath("app_2026-10-19.log") })); + } + + [Test] + public void GetNameList_UnknownLastWriteTime_IncludesFile () + { + var context = CreateContext( + "app.log", + new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 }, + ("app.log.1", null)); + + var result = context.Handler.GetNameList(context.Registry); + + Assert.That(result, Is.EqualTo(new[] { GetPath("app.log.1"), GetPath("app.log") })); + } + + [Test] + public void GetNameList_MixedPattern_OldIndexedFileStopsBeforeDateFallback () + { + var previousDateFile = "app_2026-10-18.log"; + var context = CreateContext( + "app_2026-10-19.log", + new MultiFileOptions + { + FormatPattern = "*$D(yyyy-MM-dd)$J(.).log", + MaxDayTry = 3, + MaxFileAgeDays = 5 + }, + ("app_2026-10-19.1.log", _now.AddDays(-10).UtcDateTime), + (previousDateFile, _now.AddDays(-1).UtcDateTime)); + + var result = context.Handler.GetNameList(context.Registry); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(new[] { GetPath("app_2026-10-19.log") })); + Assert.That(context.RequestedPaths, Does.Not.Contain(GetPath(previousDateFile))); + }); + } + + private static TestContext CreateContext ( + string activeFileName, + MultiFileOptions options, + params (string FileName, DateTime? LastWriteTimeUtc)[] rolloverFiles) + { + return CreateContext(activeFileName, options, _now.UtcDateTime, rolloverFiles); + } + + private static TestContext CreateContext ( + string activeFileName, + MultiFileOptions options, + DateTime? activeLastWriteTimeUtc, + params (string FileName, DateTime? LastWriteTimeUtc)[] rolloverFiles) + { + var activeFile = CreateFileInfo(activeFileName, activeLastWriteTimeUtc); + var files = rolloverFiles.ToDictionary(file => GetPath(file.FileName), + file => CreateFileInfo(file.FileName, file.LastWriteTimeUtc)); + List requestedPaths = []; + + var fileSystem = new Mock(); + _ = fileSystem.Setup(fs => fs.GetLogfileInfo(It.IsAny())) + .Returns((string path) => + { + requestedPaths.Add(path); + return files.GetValueOrDefault(path)?.Object; + }); + + var registry = new Mock(); + _ = registry.Setup(r => r.FindFileSystemForUri(It.IsAny())).Returns(fileSystem.Object); + + RolloverFilenameHandler handler = new(activeFile.Object, options, new FixedTimeProvider(_now)); + return new TestContext(handler, registry.Object, requestedPaths); + } + + private static Mock CreateFileInfo (string fileName, DateTime? lastWriteTimeUtc) + { + var fileInfo = new Mock(); + _ = fileInfo.SetupGet(file => file.FileName).Returns(fileName); + _ = fileInfo.SetupGet(file => file.DirectoryName).Returns(LOG_DIRECTORY); + _ = fileInfo.SetupGet(file => file.DirectorySeparatorChar).Returns('/'); + _ = fileInfo.SetupGet(file => file.FileExists).Returns(true); + _ = fileInfo.SetupGet(file => file.LastWriteTimeUtc).Returns(lastWriteTimeUtc); + return fileInfo; + } + + private static string GetPath (string fileName) => $"{LOG_DIRECTORY}/{fileName}"; + + private sealed record TestContext ( + RolloverFilenameHandler Handler, + IPluginRegistry Registry, + List RequestedPaths); + + private sealed class FixedTimeProvider (DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow () => utcNow; + } +} diff --git a/src/LogExpert.Tests/RolloverHandlerTest.cs b/src/LogExpert.Tests/RolloverHandlerTest.cs index 20f683172..1f5e13b17 100644 --- a/src/LogExpert.Tests/RolloverHandlerTest.cs +++ b/src/LogExpert.Tests/RolloverHandlerTest.cs @@ -91,58 +91,4 @@ public void TestFilenameListWithDate (string format, int retries) Cleanup(); } - - [Test] - [TestCase("*$J(.)", "app.log.1", "app.log.2")] - [TestCase("*$J(.).log", "app.1.log", "app.2.log")] - public void GetNameList_ExcludesIndexedFilesOlderThanMaxDays ( - string formatPattern, - string recentFileName, - string oldFileName) - { - var directory = Directory.CreateDirectory(TEST_DIR_NAME); - TestDirectory = directory; - var activeFile = CreateFile(directory, "app.log"); - var recentFile = CreateFile(directory, recentFileName); - var oldFile = CreateFile(directory, oldFileName); - File.SetLastWriteTimeUtc(activeFile, DateTime.UtcNow); - File.SetLastWriteTimeUtc(recentFile, DateTime.UtcNow.AddDays(-2)); - File.SetLastWriteTimeUtc(oldFile, DateTime.UtcNow.AddDays(-4)); - - MultiFileOptions options = new() - { - FormatPattern = formatPattern, - MaxDayTry = 3 - }; - ILogFileInfo info = new LogFileInfo(new Uri(activeFile)); - RolloverFilenameHandler handler = new(info, options); - - var fileList = handler.GetNameList(PluginRegistry.PluginRegistry.Instance); - - Assert.That(fileList, Is.EqualTo(new[] { recentFile, activeFile })); - Cleanup(); - } - - [Test] - public void GetNameList_WithZeroMaxDays_DoesNotFilterByAge () - { - var directory = Directory.CreateDirectory(TEST_DIR_NAME); - TestDirectory = directory; - var activeFile = CreateFile(directory, "app.log"); - var oldFile = CreateFile(directory, "app.log.1"); - File.SetLastWriteTimeUtc(oldFile, DateTime.UtcNow.AddYears(-1)); - - MultiFileOptions options = new() - { - FormatPattern = "*$J(.)", - MaxDayTry = 0 - }; - ILogFileInfo info = new LogFileInfo(new Uri(activeFile)); - RolloverFilenameHandler handler = new(info, options); - - var fileList = handler.GetNameList(PluginRegistry.PluginRegistry.Instance); - - Assert.That(fileList, Is.EqualTo(new[] { oldFile, activeFile })); - Cleanup(); - } -} +} \ No newline at end of file diff --git a/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs b/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs index c1a681f4f..00e36f731 100644 --- a/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs +++ b/src/LogExpert.Tests/StreamReaderTests/LogfileReaderMultiFileFlagTests.cs @@ -26,8 +26,6 @@ public void SetUp () File.Copy(Path.Join(_testDataDirectory, "app.log"), _logFile); File.Copy(Path.Join(_testDataDirectory, "app.log.1"), _logFile + ".1"); - File.SetLastWriteTimeUtc(_logFile, DateTime.UtcNow); - File.SetLastWriteTimeUtc(_logFile + ".1", DateTime.UtcNow); _ = PluginRegistry.PluginRegistry.Create(_testDirectory, 500); } @@ -70,39 +68,12 @@ public void SingleFileCtor_MultiFileTrue_ExpandsRollover () }); } - [Test] - public void SingleFileCtor_MultiFileTrue_ExcludesIndexAfterExtensionFilesOlderThanMaxDays () - { - var oldRolloverFile = _logFile + ".2"; - File.Copy(Path.Combine(_testDataDirectory, "app.log.2"), oldRolloverFile); - var now = DateTime.UtcNow; - File.SetLastWriteTimeUtc(_logFile, now); - File.SetLastWriteTimeUtc(_logFile + ".1", now.AddDays(-2)); - File.SetLastWriteTimeUtc(oldRolloverFile, now.AddDays(-4)); - MultiFileOptions options = new() - { - FormatPattern = "*$J(.)", - MaxDayTry = 3 - }; - using var reader = CreateSingleFileReader(multiFile: true, options); - - reader.ReadFiles(); - - Assert.Multiple(() => - { - Assert.That(reader.IsMultiFile, Is.True); - Assert.That(reader.GetLogFileInfoList().Select(file => Path.GetFileName(file.FullName)), - Is.EqualTo(new[] { "app.log.1", "app.log" })); - }); - } - [Test] public void SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension () { var options = new MultiFileOptions { FormatPattern = "*$J(.).log" }; - var (firstRolloverFile, secondRolloverFile) = CopyIndexBeforeExtensionFiles(); - File.SetLastWriteTimeUtc(firstRolloverFile, DateTime.UtcNow); - File.SetLastWriteTimeUtc(secondRolloverFile, DateTime.UtcNow); + File.Copy(Path.Combine(_testDataDirectory, "app.1.log"), Path.Combine(_testDirectory, "app.1.log")); + File.Copy(Path.Combine(_testDataDirectory, "app.2.log"), Path.Combine(_testDirectory, "app.2.log")); using var reader = CreateSingleFileReader(multiFile: true, options); reader.ReadFiles(); @@ -115,31 +86,6 @@ public void SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension () }); } - [Test] - public void SingleFileCtor_MultiFileTrue_ExcludesIndexBeforeExtensionFilesOlderThanMaxDays () - { - var options = new MultiFileOptions - { - FormatPattern = "*$J(.).log", - MaxDayTry = 3 - }; - var (firstRolloverFile, secondRolloverFile) = CopyIndexBeforeExtensionFiles(); - var now = DateTime.UtcNow; - File.SetLastWriteTimeUtc(_logFile, now); - File.SetLastWriteTimeUtc(firstRolloverFile, now.AddDays(-2)); - File.SetLastWriteTimeUtc(secondRolloverFile, now.AddDays(-4)); - using var reader = CreateSingleFileReader(multiFile: true, options); - - reader.ReadFiles(); - - Assert.Multiple(() => - { - Assert.That(reader.IsMultiFile, Is.True); - Assert.That(reader.GetLogFileInfoList().Select(file => Path.GetFileName(file.FullName)), - Is.EqualTo(new[] { "app.1.log", "app.log" })); - }); - } - [Test] public void MultiFileCtor_AlwaysMultiFile () { @@ -178,13 +124,4 @@ private LogfileReader CreateSingleFileReader (bool multiFile, MultiFileOptions? progressReporter: NullProgressReporter.Instance); } - private (string FirstRolloverFile, string SecondRolloverFile) CopyIndexBeforeExtensionFiles () - { - var firstRolloverFile = Path.Combine(_testDirectory, "app.1.log"); - var secondRolloverFile = Path.Combine(_testDirectory, "app.2.log"); - File.Copy(Path.Combine(_testDataDirectory, "app.1.log"), firstRolloverFile); - File.Copy(Path.Combine(_testDataDirectory, "app.2.log"), secondRolloverFile); - return (firstRolloverFile, secondRolloverFile); - } - } diff --git a/src/LogExpert.Tests/TestData/app.log.2 b/src/LogExpert.Tests/TestData/app.log.2 deleted file mode 100644 index 501086237..000000000 --- a/src/LogExpert.Tests/TestData/app.log.2 +++ /dev/null @@ -1 +0,0 @@ -app.log.2 diff --git a/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs b/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs new file mode 100644 index 000000000..a0a503774 --- /dev/null +++ b/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs @@ -0,0 +1,107 @@ +using System.Reflection; + +using LogExpert.Core.Config; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; +using LogExpert.Dialogs; +using LogExpert.UI.Dialogs; + +using Moq; + +using NUnit.Framework; + +using UIStrings = LogExpert.Resources; + +namespace LogExpert.UI.Tests.Dialogs; + +[TestFixture] +[Apartment(ApartmentState.STA)] +public class MultiFileAgeSettingsTests +{ + [Test] + public void MultiFileMaskDialog_LoadsAndSavesBothLimits () + { + using var dialog = new MultiFileMaskDialog("app.log") + { + FileNamePattern = "*$J(.)", + MaxDayGap = 3, + MaxFileAgeDays = 14 + }; + + InvokePrivate(dialog, "OnMultiFileMaskDialogLoad"); + + var maxDayGap = Find(dialog, "upDownMaxDayGap"); + var maxFileAgeDays = Find(dialog, "upDownMaxFileAgeDays"); + Assert.Multiple(() => + { + Assert.That(maxDayGap.Value, Is.EqualTo(3)); + Assert.That(maxFileAgeDays.Value, Is.EqualTo(14)); + Assert.That(Find