diff --git a/.gitignore b/.gitignore index b4f1bde9..24e47bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -719,6 +719,7 @@ http-client.private.env.json ######## END JET BRAINS git ignore https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore /AgentFiles/* /.claude/* +/tools/LogExpert-test-results/* # Local scratch notes (tickets, checklists, working docs) .scratch/ diff --git a/src/LogExpert.Tests/Timestamp/TimeSpreadCalculatorTests.cs b/src/LogExpert.Tests/Timestamp/TimeSpreadCalculatorTests.cs new file mode 100644 index 00000000..1b86681f --- /dev/null +++ b/src/LogExpert.Tests/Timestamp/TimeSpreadCalculatorTests.cs @@ -0,0 +1,196 @@ +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using ColumnizerLib; + +using LogExpert.Core.Classes; +using LogExpert.Core.Classes.Timestamp; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Timestamp; + +/// +/// Exercises both spread algorithms through the worker's public configuration and completion +/// event, with a real TimestampLocator over an in-memory source. No WinForms objects are created. +/// Keeping the worker covers mode selection and wakeup as well as the calculation; completion +/// is awaited with a bounded timeout rather than guessed with sleeps or private-method calls. +/// +[TestFixture] +public class TimeSpreadCalculatorTests +{ + private static readonly DateTime StartTime = new(2026, 1, 1, 10, 0, 0); + + [TestCase(false)] + [TestCase(true)] + public async Task Calculate_EmptyFile_CompletesWithNoEntries (bool timeMode) + { + var entries = await Calculate(timeMode, 100).ConfigureAwait(false); + + Assert.That(entries, Is.Empty); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Calculate_SingleTimestamp_CompletesWithNoEntries (bool timeMode) + { + var entries = await Calculate(timeMode, 100, 0).ConfigureAwait(false); + + Assert.That(entries, Is.Empty); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Calculate_NoTimestamps_CompletesWithNoEntries (bool timeMode) + { + var entries = await Calculate(timeMode, 100, null, null, null).ConfigureAwait(false); + + Assert.That(entries, Is.Empty); + } + + [Test] + public async Task Calculate_LineModeUniformSpacing_SamplesInteriorLines () + { + var entries = await Calculate(false, 100, 0, 30000, 60000, 90000, 120000, 150000).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(entries.Select(e => e.LineNum), Is.EqualTo(new[] { 1, 2, 3, 4 })); + Assert.That(entries.Select(e => e.Timestamp), Is.EqualTo(new[] + { + StartTime.AddSeconds(30), StartTime.AddSeconds(60), + StartTime.AddSeconds(90), StartTime.AddSeconds(120) + })); + Assert.That(entries.Select(e => e.Diff), Is.All.Zero); + Assert.That(entries.Skip(1).Select(e => e.Value).Distinct().Count(), Is.EqualTo(1)); + }); + } + + [Test] + public async Task Calculate_LineModeIrregularGaps_LargerGapIsDarker () + { + var entries = await Calculate(false, 100, 0, 30000, 60000, 150000, 180000, 210000).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(entries.Select(e => e.LineNum), Is.EqualTo(new[] { 1, 2, 3, 4 })); + Assert.That(entries.Select(e => e.Timestamp), Is.EqualTo(new[] + { + StartTime.AddSeconds(30), StartTime.AddSeconds(60), + StartTime.AddSeconds(150), StartTime.AddSeconds(180) + })); + Assert.That(entries[1].Value, Is.EqualTo(255)); + Assert.That(entries[2].Value, Is.LessThan(entries[1].Value)); + Assert.That(entries[3].Value, Is.EqualTo(255)); + }); + } + + [Test] + public async Task Calculate_LineModeSmallDisplay_SamplesEverySecondLine () + { + var entries = await Calculate(false, 3, 0, 30000, 60000, 90000, 120000, 150000).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(entries.Select(e => e.LineNum), Is.EqualTo(new[] { 1, 3 })); + Assert.That(entries.Select(e => e.Timestamp), Is.EqualTo(new[] + { + StartTime.AddSeconds(30), StartTime.AddSeconds(90) + })); + }); + } + + [TestCase(1000, 6)] + [TestCase(1, 100)] + public async Task Calculate_TimeModeUniformSpacing_ProducesEqualDensity (int spacing, int displayHeight) + { + var entries = await Calculate(true, displayHeight, 0, spacing, 2 * spacing, + 3 * spacing, 4 * spacing, 5 * spacing, 6 * spacing).ConfigureAwait(false); + + // The initial two samples are omitted by the spread display. One line remains per bin. + Assert.Multiple(() => + { + Assert.That(entries.Select(e => e.LineNum), Is.EqualTo(new[] { 2, 3, 4, 5, 6 })); + Assert.That(entries.Select(e => (e.Timestamp - StartTime).TotalMilliseconds), + Is.EqualTo(new[] { 2 * spacing, 3 * spacing, 4 * spacing, 5 * spacing, 6 * spacing })); + Assert.That(entries.Select(e => e.Diff), Is.All.EqualTo(1)); + Assert.That(entries.Select(e => e.Value), Is.All.EqualTo(198)); + }); + } + + [Test] + public async Task Calculate_TimeModeIrregularGaps_EmptyTimeBinsAreLighter () + { + var entries = await Calculate(true, 8, 0, 1000, 2000, 5000, 6000, 7000, 8000).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(entries.Select(e => e.LineNum), Is.EqualTo(new[] { 2, 2, 2, 3, 4, 5, 6 })); + Assert.That(entries.Select(e => (e.Timestamp - StartTime).TotalSeconds), + Is.EqualTo(new[] { 2, 3, 4, 5, 6, 7, 8 })); + Assert.That(entries.Select(e => e.Diff), Is.EqualTo(new[] { 1, 0, 0, 1, 1, 1, 1 })); + Assert.That(entries.Select(e => e.Value), Is.EqualTo(new[] { 122, 255, 255, 122, 122, 122, 122 })); + }); + } + + private static async Task> Calculate (bool timeMode, int displayHeight, params int?[] milliseconds) + { + var source = SourceOver(milliseconds); + var calculator = new TimeSpreadCalculator(new TimestampLocator(source), source) + { + TimeMode = timeMode + }; + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + calculator.CalcDone += (_, _) => completion.TrySetResult(calculator.DiffList); + + try + { + calculator.SetLineCount(milliseconds.Length); + calculator.SetDisplayHeight(displayHeight); + calculator.Enabled = true; + return await completion.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + } + finally + { + calculator.Stop(); + } + } + + private static ITimestampSource SourceOver (int?[] milliseconds) + { + var reader = new Mock(); + _ = reader.Setup(r => r.LineCount).Returns(milliseconds.Length); + _ = reader.Setup(r => r.GetLogLineMemory(It.IsAny())) + .Returns((int lineNumber) => + { + if (lineNumber < 0 || lineNumber >= milliseconds.Length) + { + return null!; + } + + var line = new Mock(); + var text = milliseconds[lineNumber]?.ToString(CultureInfo.InvariantCulture) ?? ""; + _ = line.Setup(l => l.FullLine).Returns(text.AsMemory()); + return line.Object; + }); + + var columnizer = new Mock(); + _ = columnizer.Setup(c => c.IsTimeshiftImplemented()).Returns(true); + _ = columnizer.Setup(c => c.GetTimestamp(It.IsAny(), It.IsAny())) + .Returns((ILogLineMemoryColumnizerCallback _, ILogLineMemory line) => + line.FullLine.IsEmpty ? DateTime.MinValue : StartTime.AddMilliseconds(int.Parse(line.FullLine.Span, CultureInfo.InvariantCulture))); + + var source = new Mock(); + _ = source.Setup(s => s.Reader).Returns(reader.Object); + _ = source.Setup(s => s.Columnizer).Returns(columnizer.Object); + _ = source.Setup(s => s.Callback).Returns(Mock.Of()); + _ = source.Setup(s => s.ColumnizerLock).Returns(new Lock()); + return source.Object; + } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs index 51423bce..2b2c2cac 100644 --- a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs +++ b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs @@ -246,6 +246,10 @@ private void DoCalc () OnCalcDone(EventArgs.Empty); } } + else + { + OnCalcDone(EventArgs.Empty); + } } //TODO Refactor this method @@ -350,6 +354,10 @@ private void DoCalcViaTime () OnCalcDone(EventArgs.Empty); } } + else + { + OnCalcDone(EventArgs.Empty); + } } private DateTime CalcValuesViaLines (int timePerLine)