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 6a10b530c..ed5a36309 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 diff --git a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs index 1c4fe52c9..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 (FileExists(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 (FileExists(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,17 +121,35 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry) #region Private Methods - private bool FileExists(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); - return info is not null && info.FileExists; + if (info is null || !info.FileExists) + { + return CandidateResult.Missing; + } + + 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 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 b9ab6d782..e83d1362f 100644 --- a/src/LogExpert.Core/Entities/MultifileOptions.cs +++ b/src/LogExpert.Core/Entities/MultifileOptions.cs @@ -5,8 +5,16 @@ public class MultiFileOptions { #region Properties + /// + /// 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 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 c9a2efa58..7109f7a3f 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -4435,11 +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 Max file age (days):. + /// + public static string MultiFileMaskDialog_UI_Label_MaxFileAgeDays { + get { + 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); } } @@ -6184,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 2ef41f1cb..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,8 +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 = Aus Muster syntax: @@ -1696,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 79a319f5b..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,8 +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 = Off Pattern syntax: @@ -1759,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 d8026b569..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,8 +1557,14 @@ 文件名模式: - - 最大天数: + + 最大日期间隔: + + + 最大文件年龄(天): + + + 0 = 关闭 模式语法: @@ -1565,7 +1583,11 @@ $J(<前缀>) = 类似 $J,但在非零时添加 <前缀> DD = 日 MM = 月 YY[YY] = 年 -其它字符将按原样使用 +其它字符将按原样使用 + +多文件限制: +最大日期间隔 = 对于 $D 模式,放弃搜索前可跳过的连续无日志文件天数 +最大文件年龄(天) = 要加载的最旧滚动日志文件天数;0 表示关闭期限限制 列 @@ -2261,4 +2283,4 @@ YY[YY] = 年 无法应用隐藏规则,显示所有行:{0} - \ No newline at end of file + 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/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.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