diff --git a/AGENTS.md b/AGENTS.md index 833716d..122f533 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -335,8 +335,9 @@ No abbreviations in names. shows, then rebuilds the sidebar. The chart is never rebuilt whole (`docs/architecture/overview.md#the-live-catalogue`). - A pen's reading is rendered through `SemiPlot.Core.Trends.PenValueFormat` and nowhere else: it owns - the stored mask's character-and-section rule and the `0.###` fallback, and the sidebar row and the - chart's hover readout both go through it (`docs/architecture/ui-text.md`). + the stored mask's character-and-section rule and the `0.###` fallback, and the sidebar row, the + chart's hover readout and the major labels of a log10 axis (`Chart/LogTickGenerator`) all go through it + (`docs/architecture/ui-text.md`). - Every failure the operator should see goes to `Messages/MessagePanelViewModel` through `Messages/ArchiveFailureMapper.Map`, which assigns the severity in its own per-kind switch — never at the call site. The one message with no error behind it is @@ -344,7 +345,8 @@ No abbreviations in names. errors. A `catch`, or an Rx `onError`, that only logs is a defect (`docs/architecture/data-integration.md#no-failure-stops-at-the-log`). Code-behind reaches the panel through its view model (`TrendChartViewModel.ReportFailure`, `MainWindowViewModel.ReportFailure`, - `PenEditorViewModel.ReportFailure`, `StartupFailureViewModel.ReportFailure`). + `PenEditorViewModel.ReportFailure`, `StartupFailureViewModel.ReportFailure`, + `AxisScalePanelViewModel.ReportFailure`). ### Data-source projects diff --git a/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs b/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs index d8615a9..87aa82b 100644 --- a/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs +++ b/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs @@ -15,7 +15,8 @@ public sealed record StoredPen( PenLineStyle LineStyle, bool EnabledOnStart, double? ScaleMinOnStart, - double? ScaleMaxOnStart); + double? ScaleMaxOnStart, + bool LogScaleOnStart); /// One semiplot_groups row and the ids of its member pens, in ascending order. public sealed record StoredGroup(int Id, string Name, IReadOnlyList MemberPenIds); diff --git a/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs b/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs index 35eb68f..871b29c 100644 --- a/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs +++ b/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs @@ -25,4 +25,6 @@ public sealed record LineStyle(PenLineStyle Value) : PenSettingChange; public sealed record EnabledOnStart(bool Value) : PenSettingChange; public sealed record ScaleOnStart(double? Min, double? Max) : PenSettingChange; + + public sealed record LogScaleOnStart(bool Value) : PenSettingChange; } diff --git a/SemiPlot/SemiPlot.Core/Trends/LogAxis.cs b/SemiPlot/SemiPlot.Core/Trends/LogAxis.cs new file mode 100644 index 0000000..b03f08c --- /dev/null +++ b/SemiPlot/SemiPlot.Core/Trends/LogAxis.cs @@ -0,0 +1,299 @@ +namespace SemiPlot.Core.Trends; + +/// A tick on a log10 axis; the position is in decades. +public readonly record struct LogTick(double Position, bool IsMajor); + +public static class LogAxis +{ + private const double MinimumMajorGap = 40.0; + private const double MinimumMinorGap = 1.0; + private const double LinearMinorSteps = 4.0; + private const double LargestExactInteger = 9007199254740992.0; + private const double Tolerance = 1e-9; + private static readonly double[] _oneTwoFive = [1.0, 2.0, 5.0]; + private static readonly double[] _skippedByOneTwoFive = [3.0, 4.0, 6.0, 7.0, 8.0, 9.0]; + private static readonly double[] _insideDecade = [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + private static readonly double[] _strideMantissas = [1.0, 2.0, 3.0, 5.0]; + + /// A log10 axis needs a minimum above zero; no minimum (autoscale) always qualifies. + public static bool AdmitsMinimum(double? minimum) + { + return minimum is not { } value || value > 0.0; + } + + /// A gap stays NaN; any value below the floor, zero and negatives included, lands on it. + public static double Project(double value, double floor) + { + if (double.IsNaN(value)) + { + return value; + } + + return value > 0.0 ? Math.Max(Math.Log10(value), floor) : floor; + } + + public static IReadOnlyList Ticks(double minLog, double maxLog, double pixelLength) + { + var span = maxLog - minLog; + if (!double.IsFinite(minLog) || !double.IsFinite(maxLog) || !double.IsFinite(span) || span <= 0.0 + || !double.IsFinite(pixelLength) || pixelLength <= 0.0) + { + return []; + } + + var axis = new AxisSpan(minLog, maxLog, pixelLength / span, (int)(pixelLength / MinimumMajorGap) + 2); + var layout = ChooseMajors(axis); + var ticks = new List(); + + foreach (var major in layout.Majors) + { + ticks.Add(new LogTick(major, IsMajor: true)); + } + + AddMinors(layout, axis, ticks); + ticks.Sort((left, right) => left.Position.CompareTo(right.Position)); + + return ticks; + } + + private static MajorLayout ChooseMajors(AxisSpan axis) + { + MajorLayout? chosen = null; + + foreach (var candidate in new[] { LinearMajors(axis), OneTwoFiveMajors(axis), DecadeMajors(axis) }) + { + if (candidate is { } layout && axis.IsReadable(layout.Majors) + && (chosen is null || layout.Majors.Count > chosen.Value.Majors.Count)) + { + chosen = layout; + } + } + + return chosen ?? new MajorLayout(MajorFamily.RangeEnds, [axis.MinLog, axis.MaxLog], Step: 0.0); + } + + private static MajorLayout? LinearMajors(AxisSpan axis) + { + var bottom = Math.Pow(10.0, axis.MinLog); + var top = Math.Pow(10.0, axis.MaxLog); + var narrowestGapAtTop = 1.0 - Math.Pow(10.0, -MinimumMajorGap / axis.PixelsPerDecade); + var step = SmallestStepAtLeast(top * narrowestGapAtTop, _oneTwoFive); + var first = Math.Max(1.0, Math.Ceiling((bottom / step) - Tolerance)); + var last = Math.Floor((top / step) + Tolerance); + var majors = LatticeMajors(axis, first, last, multiple => Math.Log10(multiple * step)); + + return majors is null ? null : new MajorLayout(MajorFamily.Linear, majors, step); + } + + private static MajorLayout? OneTwoFiveMajors(AxisSpan axis) + { + var decades = axis.DecadesTouched(); + if (decades - 1 > axis.MajorLimit) + { + return null; + } + + var majors = new List(); + for (var index = 0; index < decades; index++) + { + AddMantissas(axis, Math.Floor(axis.MinLog) + index, _oneTwoFive, majors); + if (majors.Count > axis.MajorLimit) + { + return null; + } + } + + return new MajorLayout(MajorFamily.OneTwoFive, majors, Step: 0.0); + } + + private static MajorLayout? DecadeMajors(AxisSpan axis) + { + var stride = Math.Max(1.0, SmallestStepAtLeast(MinimumMajorGap / axis.PixelsPerDecade, _strideMantissas)); + var first = Math.Ceiling((axis.MinLog / stride) - Tolerance); + var last = Math.Floor((axis.MaxLog / stride) + Tolerance); + var majors = LatticeMajors(axis, first, last, multiple => multiple * stride); + + return majors is null ? null : new MajorLayout(MajorFamily.Decades, majors, stride); + } + + /// The majors on the multiples first..last; null for fewer than two or more than the axis holds. + private static List? LatticeMajors( + AxisSpan axis, + double first, + double last, + Func positionOfMultiple) + { + var count = last - first + 1.0; + + if (!(count >= 2.0 && count <= axis.MajorLimit)) + { + return null; + } + + var majors = new List((int)count); + for (var index = 0; index < count; index++) + { + if (axis.TryPlace(positionOfMultiple(first + index), out var position)) + { + majors.Add(position); + } + } + + return majors; + } + + /// The smallest mantissa times a power of ten at or above the minimum; NaN for no finite one. + private static double SmallestStepAtLeast(double minimum, double[] mantissas) + { + if (!double.IsFinite(minimum) || minimum <= 0.0) + { + return double.NaN; + } + + var decade = Math.Pow(10.0, Math.Floor(Math.Log10(minimum))); + foreach (var mantissa in mantissas) + { + if (mantissa * decade >= minimum * (1.0 - Tolerance)) + { + return mantissa * decade; + } + } + + return 10.0 * decade; + } + + private static void AddMinors(MajorLayout layout, AxisSpan axis, List ticks) + { + var minors = new List(); + + switch (layout.Family) + { + case MajorFamily.Linear: + AddLinearMinors(axis, layout.Step, minors); + break; + case MajorFamily.OneTwoFive: + AddMantissasOfEveryDecade(axis, _skippedByOneTwoFive, minors); + break; + case MajorFamily.Decades when layout.Step == 1.0: + AddMantissasOfEveryDecade(axis, _insideDecade, minors); + break; + case MajorFamily.Decades: + AddSkippedDecades(axis, layout.Step, minors); + break; + case MajorFamily.RangeEnds: + break; + } + + foreach (var minor in minors) + { + ticks.Add(new LogTick(minor, IsMajor: false)); + } + } + + private static void AddLinearMinors(AxisSpan axis, double step, List minors) + { + var minorStep = step / LinearMinorSteps; + var first = Math.Max(1.0, Math.Ceiling((Math.Pow(10.0, axis.MinLog) / minorStep) - Tolerance)); + var last = Math.Floor((Math.Pow(10.0, axis.MaxLog) / minorStep) + Tolerance); + var count = last - first + 1.0; + var minorLimit = LinearMinorSteps * (axis.MajorLimit + 2); + + if (!(count <= minorLimit) || last > LargestExactInteger) + { + return; + } + + for (var index = 0L; index < count; index++) + { + var multiple = first + index; + if (multiple % LinearMinorSteps != 0.0 && axis.TryPlace(Math.Log10(multiple * minorStep), out var position)) + { + minors.Add(position); + } + } + } + + private static void AddMantissasOfEveryDecade(AxisSpan axis, double[] mantissas, List minors) + { + var decades = axis.DecadesTouched(); + for (var index = 0; index < decades; index++) + { + AddMantissas(axis, Math.Floor(axis.MinLog) + index, mantissas, minors); + } + } + + private static void AddMantissas(AxisSpan axis, double decade, double[] mantissas, List positions) + { + foreach (var mantissa in mantissas) + { + if (axis.TryPlace(decade + Math.Log10(mantissa), out var position)) + { + positions.Add(position); + } + } + } + + private static void AddSkippedDecades(AxisSpan axis, double stride, List minors) + { + if (axis.PixelsPerDecade < MinimumMinorGap) + { + return; + } + + var first = Math.Ceiling(axis.MinLog - Tolerance); + var count = Math.Floor(axis.MaxLog + Tolerance) - first + 1.0; + + for (var index = 0L; index < count; index++) + { + var decade = first + index; + if (decade % stride != 0.0 && axis.TryPlace(decade, out var position)) + { + minors.Add(position); + } + } + } + + private enum MajorFamily + { + Linear, + OneTwoFive, + Decades, + RangeEnds + } + + /// Step is the value step of the linear family and the decade stride of the decade family. + private readonly record struct MajorLayout(MajorFamily Family, List Majors, double Step); + + private readonly record struct AxisSpan(double MinLog, double MaxLog, double PixelsPerDecade, int MajorLimit) + { + public double DecadesTouched() + { + return Math.Floor(MaxLog) - Math.Floor(MinLog) + 1.0; + } + + public bool TryPlace(double position, out double placed) + { + placed = Math.Clamp(position, MinLog, MaxLog); + + return position >= MinLog - Tolerance && position <= MaxLog + Tolerance; + } + + public bool IsReadable(List majors) + { + if (majors.Count < 2 || majors.Count > MajorLimit) + { + return false; + } + + for (var index = 1; index < majors.Count; index++) + { + if ((majors[index] - majors[index - 1]) * PixelsPerDecade < MinimumMajorGap - Tolerance) + { + return false; + } + } + + return true; + } + } +} diff --git a/SemiPlot/SemiPlot.Core/Trends/Pen.cs b/SemiPlot/SemiPlot.Core/Trends/Pen.cs index 064d97b..1eadc27 100644 --- a/SemiPlot/SemiPlot.Core/Trends/Pen.cs +++ b/SemiPlot/SemiPlot.Core/Trends/Pen.cs @@ -10,6 +10,7 @@ public sealed record Pen( bool EnabledOnStart = true, double? ScaleMinOnStart = null, double? ScaleMaxOnStart = null, + bool LogScaleOnStart = false, PenLineStyle LineStyle = PenLineStyle.Interpolated) { /// Compares element by element, so two reads of one stored pen are equal. @@ -25,6 +26,7 @@ public bool Equals(Pen? other) && EnabledOnStart == other.EnabledOnStart && Nullable.Equals(ScaleMinOnStart, other.ScaleMinOnStart) && Nullable.Equals(ScaleMaxOnStart, other.ScaleMaxOnStart) + && LogScaleOnStart == other.LogScaleOnStart && LineStyle == other.LineStyle; } @@ -46,6 +48,7 @@ public override int GetHashCode() hash.Add(EnabledOnStart); hash.Add(ScaleMinOnStart); hash.Add(ScaleMaxOnStart); + hash.Add(LogScaleOnStart); hash.Add(LineStyle); return hash.ToHashCode(); diff --git a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs index 94784ea..ecd1bf5 100644 --- a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs +++ b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs @@ -4,8 +4,12 @@ public sealed class PenScaleModel { private const double AutoPaddingFraction = 0.05; private const double FlatRangePadding = 0.5; + private const double FlatLogRangeDecades = 0.5; private const double LogFallbackMin = 1.0; private const double LogFallbackMax = 10.0; + // 10 to either power is a finite, normal double. + private const double LowestLogDecade = -307.0; + private const double HighestLogDecade = 308.0; public IReadOnlyList Compute( IReadOnlyList settings, @@ -66,10 +70,15 @@ private static (double Min, double Max) ComputeRange( private static (double Min, double Max) SanitizeManualRange(PenScaleSettings setting, bool isLogarithmic) { + if (!double.IsFinite(setting.ManualMin) || !double.IsFinite(setting.ManualMax)) + { + return DefaultRange(isLogarithmic); + } + var min = Math.Min(setting.ManualMin, setting.ManualMax); var max = Math.Max(setting.ManualMin, setting.ManualMax); - if (isLogarithmic && min <= 0.0) + if (isLogarithmic && !LogAxis.AdmitsMinimum(min)) { min = max > 0.0 ? Math.Min(LogFallbackMin, max) : LogFallbackMin; if (max <= min) @@ -134,26 +143,28 @@ private static int FirstAtOrAfter(IReadOnlyList timestamps, DateTime w private static (double Min, double Max) PadRange(double min, double max, bool isLogarithmic) { - if (min == max) + if (isLogarithmic) { - return ClampLowerToPositive(min - FlatRangePadding, max + FlatRangePadding, isLogarithmic); + return PadLogRange(min, max); } - var padding = (max - min) * AutoPaddingFraction; + var padding = min == max ? FlatRangePadding : (max - min) * AutoPaddingFraction; - return ClampLowerToPositive(min - padding, max + padding, isLogarithmic); + return (min - padding, max + padding); } - // A log axis has no defined range below zero, so a padded lower bound is clamped to keep the - // minimum positive instead of dipping the auto padding past zero. - private static (double Min, double Max) ClampLowerToPositive(double min, double max, bool isLogarithmic) + // docs/architecture/charting.md#log10-y-axis + private static (double Min, double Max) PadLogRange(double min, double max) { - if (isLogarithmic && min <= 0.0) - { - min = Math.Min(LogFallbackMin, max); - } - - return (min, max); + var lowerDecade = Math.Clamp(Math.Log10(min), LowestLogDecade, HighestLogDecade); + var upperDecade = Math.Clamp(Math.Log10(max), LowestLogDecade, HighestLogDecade); + var padding = lowerDecade == upperDecade + ? FlatLogRangeDecades + : (upperDecade - lowerDecade) * AutoPaddingFraction; + + return ( + Math.Pow(10.0, Math.Max(lowerDecade - padding, LowestLogDecade)), + Math.Pow(10.0, Math.Min(upperDecade + padding, HighestLogDecade))); } private static (double Min, double Max) DefaultRange(bool isLogarithmic) diff --git a/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs b/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs index 26de818..1cf146b 100644 --- a/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs +++ b/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs @@ -9,7 +9,7 @@ public sealed record PenScaleSettings( { public static PenScaleSettings InitialFor(Pen pen) { - var settings = new PenScaleSettings(pen.PenId); + var settings = new PenScaleSettings(pen.PenId, IsLogarithmic: pen.LogScaleOnStart); if (pen.ScaleMinOnStart is { } min && pen.ScaleMaxOnStart is { } max) { diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs b/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs index 61ea841..8ca4917 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs @@ -1,7 +1,7 @@ namespace SemiPlot.DataSource.Postgres; /// -/// The column order of and, up to , of +/// The column order of and, up to , of /// , named so a transposition shows. /// internal static class PenCatalogColumn @@ -15,7 +15,8 @@ internal static class PenCatalogColumn public const int EnabledOnStart = 6; public const int ScaleMinOnStart = 7; public const int ScaleMaxOnStart = 8; - public const int Groups = 9; + public const int LogScaleOnStart = 9; + public const int Groups = 10; } /// The column order of , named so a transposition shows. @@ -65,7 +66,7 @@ internal static class ArchiveStatements /// public const string StoredPens = """ SELECT id, name, unit, format, color, line_style, enabled_on_start, - scale_min_on_start, scale_max_on_start + scale_min_on_start, scale_max_on_start, log_scale_on_start FROM semiplot_tags ORDER BY id; """; @@ -105,6 +106,10 @@ UPDATE semiplot_tags WHERE id = @id; """; + public const string UpdatePenLogScaleOnStart = """ + UPDATE semiplot_tags SET log_scale_on_start = @value WHERE id = @id; + """; + public const string CreateGroup = "INSERT INTO semiplot_groups (name) VALUES (@name) RETURNING id;"; public const string RenameGroup = "UPDATE semiplot_groups SET name = @name WHERE id = @id;"; @@ -132,6 +137,7 @@ DELETE FROM semiplot_pen_groups public const string PenCatalog = """ SELECT tag.id, tag.name, tag.unit, tag.format, tag.color, tag.line_style, tag.enabled_on_start, tag.scale_min_on_start, tag.scale_max_on_start, + tag.log_scale_on_start, coalesce(array_agg(grp.name ORDER BY grp.name) FILTER (WHERE grp.name IS NOT NULL), '{}') AS groups FROM semiplot_tags tag diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs index c8e7e0d..36d5bda 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs @@ -474,6 +474,7 @@ private Pen ReadPen(NpgsqlDataReader reader) reader.GetBoolean(PenCatalogColumn.EnabledOnStart), scaleMin, scaleMax, + reader.GetBoolean(PenCatalogColumn.LogScaleOnStart), ReadLineStyle(reader, penId)); } diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs index 4710daf..65a1b8b 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs @@ -168,6 +168,8 @@ PenSettingChange.EnabledOnStart enabledOnStart => Value(ArchiveStatements.UpdatePenEnabledOnStart, NpgsqlDbType.Boolean, enabledOnStart.Value), PenSettingChange.ScaleOnStart scale => (ArchiveStatements.UpdatePenScaleOnStart, ScaleOnStartParameters(scale)), + PenSettingChange.LogScaleOnStart logScaleOnStart + => Value(ArchiveStatements.UpdatePenLogScaleOnStart, NpgsqlDbType.Boolean, logScaleOnStart.Value), _ => throw new ArgumentOutOfRangeException(nameof(change), change, "Unknown pen setting change.") }; } @@ -258,7 +260,8 @@ private StoredPen ReadStoredPen(NpgsqlDataReader reader) StoredLineStyle.Read(storedLineStyle, penId, _logger, () => LogLevel.Warning), reader.GetBoolean(PenCatalogColumn.EnabledOnStart), ReadBound(reader, PenCatalogColumn.ScaleMinOnStart), - ReadBound(reader, PenCatalogColumn.ScaleMaxOnStart)); + ReadBound(reader, PenCatalogColumn.ScaleMaxOnStart), + reader.GetBoolean(PenCatalogColumn.LogScaleOnStart)); } private static string? ReadText(NpgsqlDataReader reader, int ordinal) diff --git a/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs b/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs index 97aff56..5e3558e 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs @@ -62,7 +62,8 @@ public async Task ReadReturnsEveryPenAndGroupAsStored() PenLineStyle.Interpolated, true, 0, - 100)); + 100, + false)); catalogue.Groups.Select(group => group.Name).Should().Equal(HeatersGroup, VacuumGroup); Group(catalogue, HeatersGroup).MemberPenIds.Should().Equal(_heaterIds); Group(catalogue, VacuumGroup).MemberPenIds.Should().Equal(ChamberPressureId); @@ -90,6 +91,7 @@ public async Task ReadReturnsANullColourAsNullAndAnUnusableMaskAsItIsStored() [InlineData(nameof(PenSettingChange.LineStyle))] [InlineData(nameof(PenSettingChange.EnabledOnStart))] [InlineData(nameof(PenSettingChange.ScaleOnStart))] + [InlineData(nameof(PenSettingChange.LogScaleOnStart))] public async Task EachChangeWritesItsOwnColumnAndNothingElse(string arm) { using var services = Build(); @@ -104,6 +106,26 @@ public async Task EachChangeWritesItsOwnColumnAndNothingElse(string arm) (await PenAsync(editor, ChamberPressureId)).Should().Be(expected); } + [Fact] + public async Task AWrittenLogScaleOnStartReadsBackThroughBothReads() + { + using var services = Build(); + + var editor = Editor(services); + var pen = await PenAsync(editor, ChamberPressureId); + + var result = await editor.ChangeAsync(pen, new PenSettingChange.LogScaleOnStart(true)); + + result.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(result)); + (await PenAsync(editor, ChamberPressureId)).LogScaleOnStart.Should().BeTrue(); + + var pens = await services.GetRequiredService().QueryPensAsync(); + + pens.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(pens)); + pens.Value.Should().ContainSingle(read => read.PenId == ChamberPressureId) + .Which.LogScaleOnStart.Should().BeTrue(); + } + [Fact] public async Task TwoInstancesWritingTwoColumnsOfOnePenBothSurvive() { @@ -424,6 +446,8 @@ private static (PenSettingChange Change, StoredPen Expected) Arm(string arm, Sto => (new PenSettingChange.EnabledOnStart(false), pen with { EnabledOnStart = false }), nameof(PenSettingChange.ScaleOnStart) => (new PenSettingChange.ScaleOnStart(-1, 1), pen with { ScaleMinOnStart = -1, ScaleMaxOnStart = 1 }), + nameof(PenSettingChange.LogScaleOnStart) + => (new PenSettingChange.LogScaleOnStart(true), pen with { LogScaleOnStart = true }), _ => throw new ArgumentOutOfRangeException(nameof(arm), arm, "Unknown change arm.") }; } diff --git a/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs b/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs index edf3725..d35350a 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs @@ -54,6 +54,14 @@ INSERT INTO public.semiplot_tags (id, name, format, color, line_style) (9998, 'Shaping mask', '0.##0', '#123456', 0); """; + private const int LogScaleTagId = 9995; + + private const string LogScaleTagCommand = + """ + INSERT INTO public.semiplot_tags (id, name, color, line_style, log_scale_on_start) + VALUES (9995, 'Log scale', '#123456', 0, true); + """; + private const int NullColumnTagId = 9999; // Every nullable column of SemiBase's semiplot_tags at once, and no membership: the state a pen @@ -105,6 +113,12 @@ public async Task SeededCatalogueCarriesTheCommissionedFieldsOfEachPen() commissioned.EnabledOnStart.Should().BeTrue(); Single(result.Value, SyntheticPenCatalog.HiddenOnStartPenId).EnabledOnStart.Should().BeFalse(); + + var logScale = Single(result.Value, SyntheticPenCatalog.LogScalePenId); + + logScale.LogScaleOnStart.Should().BeTrue(); + logScale.Format.Should().Be("0.0E+0"); + logScale.ScaleMinOnStart.Should().Be(1e-6); } // One row, not two: the memberships aggregate into the array the record carries. @@ -169,6 +183,24 @@ await ArchiveDatabase.ExecuteAsync( Single(result.Value, ScalingMaskTagId).Format.Should().BeNull(); } + [Fact] + public async Task AStoredLogScaleOnStartReadsBackAndTheDefaultReadsLinear() + { + await using var database = await postgresContainerFixture.CloneTemplateAsync( + TestContext.Current.CancellationToken); + + await ArchiveDatabase.ExecuteAsync( + database.AdminConnectionString, + LogScaleTagCommand, + TestContext.Current.CancellationToken); + + var result = await ReadCatalogueAsync(database.PlotConnectionString); + + result.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(result)); + Single(result.Value, LogScaleTagId).LogScaleOnStart.Should().BeTrue(); + Single(result.Value, SyntheticPenCatalog.TwoGroupPenId).LogScaleOnStart.Should().BeFalse(); + } + [Fact] public async Task SeededCatalogueLineStylesReadBackAsTheStoredOrdinals() { @@ -291,6 +323,7 @@ private static Pen ToPen(SyntheticPen pen) pen.EnabledOnStart, pen.ScaleMinOnStart, pen.ScaleMaxOnStart, + pen.LogScaleOnStart, pen.LineStyle); } diff --git a/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs b/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs index 1ad6a56..34f5c1d 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs @@ -29,6 +29,9 @@ FROM public.semiplot_pen_groups membership private const string StoredScalesCommand = "SELECT id, scale_min_on_start, scale_max_on_start FROM public.semiplot_tags ORDER BY id;"; + private const string LogScaleFlagsCommand = + "SELECT id, log_scale_on_start FROM public.semiplot_tags ORDER BY id;"; + [Fact] public async Task TheGroupTableHoldsEveryGroupTheSliceNames() { @@ -69,6 +72,26 @@ .. Slice() && scale.Max == null); } + [Fact] + public async Task TheStoredLogScaleFlagIsTheSyntheticPensOwn() + { + await using var database = await WrittenSliceAsync(); + + var written = await ReadAsync( + database, + LogScaleFlagsCommand, + reader => (PenId: reader.GetInt32(0), LogScaleOnStart: reader.GetBoolean(1))); + + written.Should().Equal( + [ + .. Slice() + .Select(pen => (pen.PenId, pen.LogScaleOnStart)) + .OrderBy(flag => flag.PenId) + ]); + written.Should().ContainSingle(flag => flag.LogScaleOnStart) + .Which.PenId.Should().Be(SyntheticPenCatalog.LogScalePenId); + } + // A template rebuild runs the writer over a catalogue it already wrote; the memberships are // replaced, so a rerun over the same slice duplicates none of them. [Fact] diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/LogAxisTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/LogAxisTests.cs new file mode 100644 index 0000000..5e9b514 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/LogAxisTests.cs @@ -0,0 +1,266 @@ +using AwesomeAssertions; + +using SemiPlot.Core.Trends; + +using Xunit; + +namespace SemiPlot.Tests.Unit.Core.Trends; + +[Trait("Component", "Core")] +[Trait("Area", "Chart")] +[Trait("Category", "Unit")] +public sealed class LogAxisTests +{ + private const double MinimumMajorGap = 40.0; + private const double Floor = -9.0; + private const double ProjectionTolerance = 1e-12; + private const double PositionTolerance = 1e-9; + private const double RelativeValueTolerance = 1e-9; + private const double GapTolerance = 1e-6; + private static readonly TimeSpan _ticksDeadline = TimeSpan.FromSeconds(5); + + [Fact] + public void Project_PositiveValue_GivesItsDecade() + { + LogAxis.Project(100.0, Floor).Should().BeApproximately(2.0, ProjectionTolerance); + LogAxis.Project(1e-6, Floor).Should().BeApproximately(-6.0, ProjectionTolerance); + } + + [Theory] + [InlineData(0.0)] + [InlineData(-5.0)] + public void Project_NonPositiveValue_LandsOnTheFloor(double value) + { + LogAxis.Project(value, Floor).Should().Be(Floor); + } + + [Fact] + public void Project_PositiveValueBelowTheFloor_LandsOnTheFloor() + { + LogAxis.Project(1e-12, Floor).Should().Be(Floor); + } + + [Fact] + public void Project_Gap_StaysNaN() + { + double.IsNaN(LogAxis.Project(double.NaN, Floor)).Should().BeTrue(); + } + + [Theory] + [InlineData(null, true)] + [InlineData(1e-6, true)] + [InlineData(0.0, false)] + [InlineData(-5.0, false)] + public void AdmitsMinimum_AdmitsNoMinimumAndAnyAboveZero(double? minimum, bool admits) + { + LogAxis.AdmitsMinimum(minimum).Should().Be(admits); + } + + [Fact] + public void Ticks_SixDecadesOver400Pixels_PutMajorsOnDecadesAndMinorsInside() + { + var ticks = LogAxis.Ticks(-6.0, -1.0, 400.0); + + Positions(ticks, isMajor: true).Should().Equal([-6.0, -5.0, -4.0, -3.0, -2.0, -1.0], SamePosition); + + var expectedMinors = new List(); + for (var decade = -6; decade <= -2; decade++) + { + for (var mantissa = 2; mantissa <= 9; mantissa++) + { + expectedMinors.Add(decade + Math.Log10(mantissa)); + } + } + + Positions(ticks, isMajor: false).Should().Equal(expectedMinors, SamePosition); + } + + [Fact] + public void Ticks_SixDecadesOver400Pixels_StayWithinThePerCallBound() + { + const double PixelLength = 400.0; + var majorBound = (int)(PixelLength / MinimumMajorGap) + 2; + + var ticks = LogAxis.Ticks(-6.0, -1.0, PixelLength); + + ticks.Count(tick => tick.IsMajor).Should().BeLessThanOrEqualTo(majorBound); + ticks.Should().HaveCountLessThanOrEqualTo(majorBound * 9); + } + + [Fact] + public void Ticks_TwelveDecadesOver200Pixels_StrideDecadesAndMarkTheSkippedOnes() + { + var ticks = LogAxis.Ticks(-12.0, 0.0, 200.0); + + Positions(ticks, isMajor: true).Should().Equal([-12.0, -9.0, -6.0, -3.0, 0.0], SamePosition); + Positions(ticks, isMajor: false).Should().Equal( + [-11.0, -10.0, -8.0, -7.0, -5.0, -4.0, -2.0, -1.0], SamePosition); + } + + [Theory] + [InlineData(2.0, 50.0, new[] { 2.0, 5.0, 10.0, 20.0, 50.0 })] + [InlineData(0.2, 5.0, new[] { 0.2, 0.5, 1.0, 2.0, 5.0 })] + [InlineData(1.5, 14.0, new[] { 2.0, 5.0, 10.0 })] + [InlineData(0.9, 1.4, new[] { 0.9, 1.0, 1.1, 1.2, 1.3, 1.4 })] + [InlineData(1.05, 1.2, new[] { 1.06, 1.08, 1.1, 1.12, 1.14, 1.16, 1.18, 1.2 })] + public void Ticks_RangeUnderAFewDecades_LabelsReadableValues(double bottom, double top, double[] expected) + { + var ticks = LogAxis.Ticks(Math.Log10(bottom), Math.Log10(top), 400.0); + + Values(ticks, isMajor: true).Should().Equal(expected, SameValue); + } + + [Fact] + public void Ticks_LinearSteps_PlaceMinorsInThePartialIntervalsAtBothEnds() + { + var ticks = LogAxis.Ticks(Math.Log10(1.05), Math.Log10(1.19), 400.0); + + Values(ticks, isMajor: true).Should().Equal([1.06, 1.08, 1.1, 1.12, 1.14, 1.16, 1.18], SameValue); + Values(ticks, isMajor: false).Should().Equal( + [ + 1.05, 1.055, 1.065, 1.07, 1.075, 1.085, 1.09, 1.095, 1.105, 1.11, 1.115, + 1.125, 1.13, 1.135, 1.145, 1.15, 1.155, 1.165, 1.17, 1.175, 1.185, 1.19 + ], + SameValue); + } + + [Fact] + public void Ticks_OneTwoFive_PlaceMinorsOnTheMantissasItSkips() + { + var ticks = LogAxis.Ticks(Math.Log10(2.0), Math.Log10(50.0), 400.0); + + Values(ticks, isMajor: true).Should().Equal([2.0, 5.0, 10.0, 20.0, 50.0], SameValue); + Values(ticks, isMajor: false).Should().Equal([3.0, 4.0, 6.0, 7.0, 8.0, 9.0, 30.0, 40.0], SameValue); + } + + [Fact] + public void Ticks_AxisTooShortForAnyFamily_MakesTheRangeEndsTheMajorsWithNoMinors() + { + var ticks = LogAxis.Ticks(-6.0, -1.0, 30.0); + + ticks.Should().Equal(new LogTick(-6.0, IsMajor: true), new LogTick(-1.0, IsMajor: true)); + } + + [Fact] + public void Ticks_Decades_PlaceMinorsInThePartialDecadesAtBothEnds() + { + var ticks = LogAxis.Ticks(Math.Log10(1.5e-6), Math.Log10(5e-2), 400.0); + + Positions(ticks, isMajor: true).Should().Equal([-5.0, -4.0, -3.0, -2.0], SamePosition); + var minors = Positions(ticks, isMajor: false); + minors.Should().Contain(position => position < -5.0); + minors.Should().Contain(position => position > -2.0); + } + + [Fact] + public void Ticks_SpanEndingOnADecade_KeepsTheDecadeAsTheTopMajor() + { + var maxLog = Math.Log10(0.1); + + var ticks = LogAxis.Ticks(Math.Log10(3e-3), maxLog, 400.0); + + var top = ticks.Last(tick => tick.IsMajor); + top.Position.Should().BeApproximately(maxLog, PositionTolerance); + ticks[^1].Should().Be(top); + } + + [Theory] + [InlineData(1e-6, 1e-1, 400.0)] + [InlineData(1e-12, 1.0, 200.0)] + [InlineData(2.0, 50.0, 400.0)] + [InlineData(0.2, 5.0, 400.0)] + [InlineData(1.5, 14.0, 400.0)] + [InlineData(0.9, 1.4, 400.0)] + [InlineData(1.05, 1.2, 400.0)] + [InlineData(1.05, 1.19, 400.0)] + [InlineData(1.5e-6, 5e-2, 400.0)] + [InlineData(3e-3, 1e-1, 400.0)] + public void Ticks_AnyRange_StayInsideItAscendingWithReadableMajors(double bottom, double top, double pixelLength) + { + var minLog = Math.Log10(bottom); + var maxLog = Math.Log10(top); + var pixelsPerDecade = pixelLength / (maxLog - minLog); + + var ticks = LogAxis.Ticks(minLog, maxLog, pixelLength); + + ticks.Select(tick => tick.Position).Should().AllSatisfy( + position => position.Should().BeInRange(minLog, maxLog)); + ticks.Select(tick => tick.Position).Should().BeInAscendingOrder(); + ticks.Select(tick => tick.Position).Should().OnlyHaveUniqueItems(); + + var majors = Positions(ticks, isMajor: true); + majors.Should().HaveCountGreaterThanOrEqualTo(2); + majors.Zip(majors.Skip(1), (lower, upper) => (upper - lower) * pixelsPerDecade) + .Should().AllSatisfy(gap => gap.Should().BeGreaterThanOrEqualTo(MinimumMajorGap - GapTolerance)); + } + + [Theory] + [InlineData(1.0, 1.0000000000000007, 400.0)] + [InlineData(2.5, 2.5000000000000044, 300.0)] + [InlineData(2.5, 2.5000000000000044, 400.0)] + [InlineData(2.5, 2.5000000000000044, 900.0)] + public async Task Ticks_SpanOfFloatNoise_ReturnsTicksInsideTheRange(double bottom, double top, double pixelLength) + { + var minLog = Math.Log10(bottom); + var maxLog = Math.Log10(top); + + var ticks = await TicksWithinDeadline(minLog, maxLog, pixelLength); + + ticks.Select(tick => tick.Position).Should().AllSatisfy( + position => position.Should().BeInRange(minLog, maxLog)); + } + + [Fact] + public async Task Ticks_DecadesBeyondExactIntegers_ReturnMajorsWithNoMinors() + { + var ticks = await TicksWithinDeadline(-1e20, 1e20, 400.0); + + ticks.Should().NotBeEmpty(); + ticks.Should().OnlyContain(tick => tick.IsMajor); + } + + [Theory] + [InlineData(-6.0, double.PositiveInfinity, 400.0)] + [InlineData(double.NegativeInfinity, -1.0, 400.0)] + [InlineData(double.NaN, -1.0, 400.0)] + [InlineData(-3.0, -3.0, 400.0)] + [InlineData(-1.0, -6.0, 400.0)] + [InlineData(-6.0, -1.0, 0.0)] + [InlineData(-6.0, -1.0, -400.0)] + [InlineData(-6.0, -1.0, double.NaN)] + public void Ticks_DegenerateInput_GivesNoTicks(double minLog, double maxLog, double pixelLength) + { + LogAxis.Ticks(minLog, maxLog, pixelLength).Should().BeEmpty(); + } + + private static async Task> TicksWithinDeadline( + double minLog, + double maxLog, + double pixelLength) + { + var cancellationToken = TestContext.Current.CancellationToken; + + return await Task.Run(() => LogAxis.Ticks(minLog, maxLog, pixelLength), cancellationToken) + .WaitAsync(_ticksDeadline, cancellationToken); + } + + private static List Positions(IReadOnlyList ticks, bool isMajor) + { + return [.. ticks.Where(tick => tick.IsMajor == isMajor).Select(tick => tick.Position)]; + } + + private static List Values(IReadOnlyList ticks, bool isMajor) + { + return [.. Positions(ticks, isMajor).Select(position => Math.Pow(10.0, position))]; + } + + private static bool SamePosition(double actual, double expected) + { + return Math.Abs(actual - expected) < PositionTolerance; + } + + private static bool SameValue(double actual, double expected) + { + return Math.Abs(actual - expected) < expected * RelativeValueTolerance; + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs index be875c4..8d125f2 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs @@ -38,6 +38,7 @@ public sealed class PenListDeltaTests nameof(Pen.EnabledOnStart), nameof(Pen.ScaleMinOnStart), nameof(Pen.ScaleMaxOnStart), + nameof(Pen.LogScaleOnStart), nameof(Pen.LineStyle) ]; @@ -136,6 +137,17 @@ public void AnEnabledOnStartChange_GivesARevision() delta.Revised.Should().ContainSingle().Which.EnabledOnStart.Should().BeFalse(); } + [Fact] + public void ALogScaleOnStartChangeAlone_GivesARevision() + { + var logarithmic = _pressure with { LogScaleOnStart = true }; + + var delta = PenListDelta.Between([_pressure], [logarithmic]); + + delta.Revised.Should().ContainSingle().Which.LogScaleOnStart.Should().BeTrue(); + delta.ChangesPenSet.Should().BeFalse(); + } + [Fact] public void ANewPen_IsAddedAndChangesThePenSet() { @@ -198,6 +210,7 @@ private static Pen WithOneMemberChanged(Pen pen, string member) nameof(Pen.EnabledOnStart) => pen with { EnabledOnStart = !pen.EnabledOnStart }, nameof(Pen.ScaleMinOnStart) => pen with { ScaleMinOnStart = -5.0 }, nameof(Pen.ScaleMaxOnStart) => pen with { ScaleMaxOnStart = 500.0 }, + nameof(Pen.LogScaleOnStart) => pen with { LogScaleOnStart = !pen.LogScaleOnStart }, nameof(Pen.LineStyle) => pen with { LineStyle = PenLineStyle.Stepped }, _ => throw new ArgumentOutOfRangeException(nameof(member), member, "No change is defined for it.") }; diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs index c38a778..2ca5e0b 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs @@ -155,9 +155,59 @@ public void Compute_LogarithmicAxis_DropsNonPositiveValuesBeforeComputingRange() var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(3)); var logScale = scales.Should().ContainSingle().Which; + var paddingDecades = (Math.Log10(50.0) - Math.Log10(2.0)) * 0.05; logScale.IsLogarithmic.Should().BeTrue(); - logScale.Min.Should().BeGreaterThan(0.0, "a log axis lower bound is clamped positive after padding"); - logScale.Max.Should().BeApproximately(50.0 + (48.0 * 0.05), 1e-9); + logScale.Min.Should().BeApproximately(2.0 / Math.Pow(10.0, paddingDecades), 1e-9); + logScale.Max.Should().BeApproximately(50.0 * Math.Pow(10.0, paddingDecades), 1e-9); + } + + [Fact] + public void Compute_LogarithmicAxisOverDecades_PadsBeyondBothEnds() + { + var model = new PenScaleModel(); + var settings = new[] { new PenScaleSettings(PenId: 1, IsLogarithmic: true) }; + var envelopes = new Dictionary { [1] = Envelope(1, (1e-6, 1e-4), (1e-3, 1e-2)) }; + + var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(2)); + + var logScale = scales.Should().ContainSingle().Which; + logScale.Min.Should().BeGreaterThan(0.0).And.BeLessThan(1e-6); + logScale.Max.Should().BeGreaterThan(1e-2); + } + + [Fact] + public void Compute_FlatLogarithmicLine_PadsHalfADecadeOnEachSide() + { + var model = new PenScaleModel(); + var settings = new[] { new PenScaleSettings(PenId: 1, IsLogarithmic: true) }; + var envelopes = new Dictionary { [1] = Envelope(1, (0.3, 0.3)) }; + + var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(1)); + + var flat = scales.Should().ContainSingle().Which; + flat.Min.Should().BeLessThan(0.3); + flat.Max.Should().BeGreaterThan(0.3); + flat.Min.Should().BeApproximately(0.3 / Math.Sqrt(10.0), 1e-12); + flat.Max.Should().BeApproximately(0.3 * Math.Sqrt(10.0), 1e-12); + } + + [Theory] + [InlineData(1.0, double.PositiveInfinity)] + [InlineData(double.Epsilon, 1.0)] + [InlineData(double.Epsilon, double.MaxValue)] + [InlineData(double.MaxValue, double.PositiveInfinity)] + public void Compute_LogarithmicAxisAtTheEdgesOfTheDoubleRange_KeepsFiniteDecadeLimits(double low, double high) + { + var model = new PenScaleModel(); + var settings = new[] { new PenScaleSettings(PenId: 1, IsLogarithmic: true) }; + var envelopes = new Dictionary { [1] = Envelope(1, (low, low), (high, high)) }; + + var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(2)); + + var logScale = scales.Should().ContainSingle().Which; + double.IsFinite(Math.Log10(logScale.Min)).Should().BeTrue(); + double.IsFinite(Math.Log10(logScale.Max)).Should().BeTrue(); + logScale.Min.Should().BeLessThan(logScale.Max); } [Fact] @@ -246,6 +296,36 @@ public void Compute_ManualLogarithmic_SanitizesNonPositiveLowerBound() manual.Max.Should().Be(100.0); } + [Theory] + [InlineData(1.0, double.PositiveInfinity, true, 1.0, 10.0)] + [InlineData(1.0, double.NaN, true, 1.0, 10.0)] + [InlineData(double.NegativeInfinity, 100.0, true, 1.0, 10.0)] + [InlineData(1.0, double.PositiveInfinity, false, 0.0, 1.0)] + [InlineData(double.NaN, 100.0, false, 0.0, 1.0)] + public void Compute_ManualModeWithANonFiniteBound_FallsBackToTheDefaultRange( + double manualMin, + double manualMax, + bool isLogarithmic, + double defaultMin, + double defaultMax) + { + var model = new PenScaleModel(); + var settings = new[] + { + new PenScaleSettings(PenId: 1, Mode: ScaleMode.Manual, ManualMin: manualMin, ManualMax: manualMax) + { + IsLogarithmic = isLogarithmic + } + }; + var envelopes = new Dictionary { [1] = Envelope(1, (2.0, 50.0)) }; + + var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(1)); + + var manual = scales.Should().ContainSingle().Which; + manual.Min.Should().Be(defaultMin); + manual.Max.Should().Be(defaultMax); + } + private static PenHistoryEnvelope Envelope(int penId, params (double Min, double Max)[] columns) { var timestamps = new DateTime[columns.Length]; diff --git a/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs b/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs index 91122cb..8d11705 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs @@ -202,7 +202,7 @@ public void PensAreTakenRoundRobinAcrossTheGroupsRatherThanFirstN() } [Fact] - public void TheStandardSliceCarriesTheThreeCommissioningStates() + public void TheStandardSliceCarriesTheFourCommissioningStates() { var pens = RawLayerGenerator.SelectPens(SeederOptions.DefaultPenCount); @@ -211,6 +211,13 @@ public void TheStandardSliceCarriesTheThreeCommissioningStates() pens.Should().ContainSingle(pen => pen.PenId == SyntheticPenCatalog.HiddenOnStartPenId) .Which.EnabledOnStart.Should().BeFalse(); + var logScale = pens.Should().ContainSingle(pen => pen.LogScaleOnStart).Which; + + logScale.PenId.Should().Be(SyntheticPenCatalog.LogScalePenId); + logScale.Format.Should().Be("0.0E+0"); + logScale.ScaleMinOnStart.Should().Be(1e-6); + logScale.ScaleMaxOnStart.Should().Be(1e-1); + var uncommissioned = pens.Should() .ContainSingle(pen => pen.PenId == SyntheticPenCatalog.UncommissionedPenId).Which; @@ -221,6 +228,22 @@ public void TheStandardSliceCarriesTheThreeCommissioningStates() uncommissioned.ScaleMaxOnStart.Should().BeNull(); } + [Fact] + public void TheLogPenCoversAtLeastFourDecadesOfItsRangeOverADay() + { + var options = BenchOptions.For(days: 1); + var pen = RawLayerGenerator.SelectPens(options.PenCount) + .Single(candidate => candidate.PenId == SyntheticPenCatalog.LogScalePenId); + + var values = RawLayerGenerator.Generate(options) + .Where(row => row.Id == pen.PenId) + .Select(row => row.Value) + .ToArray(); + + values.Should().AllSatisfy(value => value.Should().BeInRange(pen.MinValue, pen.MaxValue)); + Math.Log10(values.Max() / values.Min()).Should().BeGreaterThanOrEqualTo(4.0); + } + [Fact] public void TheStandardSliceGivesEveryPenItsOwnColour() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs index 4f5f20a..cce9e86 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs @@ -1,10 +1,17 @@ +using System.ComponentModel; +using System.Globalization; + using Avalonia.Headless.XUnit; using AwesomeAssertions; +using Microsoft.Reactive.Testing; + using SemiPlot.Core.Trends; +using SemiPlot.Tests.Unit.UI.Bridge; using SemiPlot.UI.Chart; using SemiPlot.UI.Localization; +using SemiPlot.UI.Messages; using SemiPlot.UI.PenEditor; using Xunit; @@ -40,7 +47,8 @@ public void Seed_FillsTheFieldsAndTheHeaderFromTheActivePen() chart.AxisScale.PenUnit.Should().Be("sccm"); chart.AxisScale.MaximumText.Should().Be(PenFormRules.FormatBound(90.5)); chart.AxisScale.MinimumText.Should().Be(PenFormRules.FormatBound(10.0)); - chart.AxisScale.IsValid.Should().BeTrue(); + chart.AxisScale.IsMinimumValid.Should().BeTrue(); + chart.AxisScale.IsMaximumValid.Should().BeTrue(); chart.AxisScale.ValidationMessage.Should().BeEmpty(); } @@ -75,7 +83,7 @@ public void Seed_WhenTheActivePenIsHidden_OpensNothing() } [AvaloniaFact] - public void ASmallMagnitudeScale_IsShownAndWrittenBackWithoutLoss() + public void ASmallMagnitudeScale_IsShownAndKeptWithoutLoss() { var (chart, _, _, provider) = CreateViewModel(); chart.ApplyCatalogue(provider.Pens); @@ -86,7 +94,7 @@ public void ASmallMagnitudeScale_IsShownAndWrittenBackWithoutLoss() chart.AxisScale.MinimumText.Should().Be(PenValueFormat.Format(1e-7, null)); chart.AxisScale.MaximumText.Should().Be(PenValueFormat.Format(5e-6, null)); - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((1e-7, 5e-6)); } @@ -120,7 +128,7 @@ public void Seed_WithoutAMask_FallsBackToTheDefaultMask() } [AvaloniaFact] - public void Apply_WithBothFieldsUntouched_KeepsTheExactBoundsAMaskRounded() + public void CommitBounds_WithBothFieldsUntouched_KeepsTheExactBoundsAMaskRounded() { using var culture = new CultureScope("en-US"); var (chart, _, _, provider) = CreateViewModel(); @@ -131,13 +139,42 @@ public void Apply_WithBothFieldsUntouched_KeepsTheExactBoundsAMaskRounded() chart.AxisScale.MinimumText.Should().Be("0"); chart.AxisScale.MaximumText.Should().Be("10"); - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((0.4, 9.6)); } [AvaloniaFact] - public void Apply_WithOneFieldEdited_WritesTheTypedValueAndKeepsTheOtherExact() + public void CommitBounds_OnAnAutoscaledPenWithUntouchedFields_WritesNothing() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.AxisScale.Seed(); + + chart.AxisScale.CommitBounds(); + + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1)); + } + + [AvaloniaFact] + public void CommitBounds_WithTheSeededValuesRetypedInAnotherNotation_WritesNothing() + { + using var culture = new CultureScope("en-US"); + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.AxisScale.Seed(); + var (minimum, maximum) = chart.ScaleRangeForPen(1)!.Value; + + chart.AxisScale.MinimumText = minimum.ToString("E16", CultureInfo.CurrentCulture); + chart.AxisScale.MaximumText = maximum.ToString("E16", CultureInfo.CurrentCulture); + chart.AxisScale.MaximumText.Should().NotBe(PenValueFormat.Format(maximum, null)); + chart.AxisScale.CommitBounds(); + + chart.ScaleSettings[1].Mode.Should().Be(ScaleMode.Auto, "the typed pair is the pair the panel was seeded with"); + } + + [AvaloniaFact] + public void CommitBounds_WithOneFieldEdited_WritesTheTypedValueAndKeepsTheOtherExact() { using var culture = new CultureScope("en-US"); var (chart, _, _, provider) = CreateViewModel(); @@ -146,14 +183,15 @@ public void Apply_WithOneFieldEdited_WritesTheTypedValueAndKeepsTheOtherExact() chart.AxisScale.Seed(); chart.AxisScale.MaximumText = "20"; - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((0.4, 20.0)); } [AvaloniaFact] - public void Apply_WritesBothBoundsAsAManualScaleOnThePenThePanelWasOpenedFor() + public void CommitBounds_WritesAChangedPairOnThePenThePanelWasOpenedFor_AndReseedsWithThePanelOpen() { + using var culture = new CultureScope("en-US"); var (chart, _, _, provider) = CreateViewModel(); chart.ApplyCatalogue(provider.Pens); chart.SetActivePen(2); @@ -161,9 +199,9 @@ public void Apply_WritesBothBoundsAsAManualScaleOnThePenThePanelWasOpenedFor() var closes = 0; using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++); - chart.AxisScale.MaximumText = PenFormRules.FormatBound(120.0); - chart.AxisScale.MinimumText = PenFormRules.FormatBound(-20.0); - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.MaximumText = "120.0"; + chart.AxisScale.MinimumText = "-20.00"; + chart.AxisScale.CommitBounds(); chart.ScaleSettings[2].Should().Be(new PenScaleSettings(2) { @@ -172,7 +210,23 @@ public void Apply_WritesBothBoundsAsAManualScaleOnThePenThePanelWasOpenedFor() ManualMax = 120.0 }); chart.ScaleSettings[1].Mode.Should().Be(ScaleMode.Auto, "the other pen is not touched"); - closes.Should().Be(1); + chart.AxisScale.MaximumText.Should().Be("120", "the write re-seeds the fields"); + chart.AxisScale.MinimumText.Should().Be("-20"); + closes.Should().Be(0); + } + + [AvaloniaFact] + public void ReportFailure_ReachesTheChartsMessagePanel() + { + var scheduler = new TestScheduler(); + var provider = new FakeDataProvider(scheduler, TimeSpan.FromHours(1)); + using var messagePanel = new MessagePanelViewModel(); + using var chart = CreateChart(scheduler, CreateCoordinator(scheduler, provider), messagePanel); + + chart.AxisScale.ReportFailure(new InvalidOperationException("the focus handler threw")); + + messagePanel.Entries.Should().ContainSingle() + .Which.View.Detail.Should().Contain("the focus handler threw"); } [AvaloniaFact] @@ -190,9 +244,8 @@ public void AnInvertedPair_IsRefusedOnTheMessageLine_AndNothingIsWritten() chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleMinimumBelowMaximum); chart.AxisScale.IsMinimumValid.Should().BeFalse(); chart.AxisScale.IsMaximumValid.Should().BeFalse(); - CanApply(chart.AxisScale).Should().BeFalse(); - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0)); closes.Should().Be(0); @@ -209,7 +262,10 @@ public void AnEqualPair_IsRefused() chart.AxisScale.MinimumText = "90"; chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleMinimumBelowMaximum); - CanApply(chart.AxisScale).Should().BeFalse(); + + chart.AxisScale.CommitBounds(); + + chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0)); } [AvaloniaTheory] @@ -236,9 +292,8 @@ public void AnEmptyField_IsRefusedAndMarkedInvalid(bool emptyMaximum) chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleBoundsRequired); chart.AxisScale.IsMaximumValid.Should().Be(!emptyMaximum); chart.AxisScale.IsMinimumValid.Should().Be(emptyMaximum); - CanApply(chart.AxisScale).Should().BeFalse(); - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0)); closes.Should().Be(0); @@ -256,15 +311,12 @@ public void ABoundThatAMaskShowsAsBlank_StaysValidWithNoMessageWhileAsSeeded() chart.AxisScale.MinimumText.Should().Be(" "); chart.AxisScale.ValidationMessage.Should().BeEmpty(); - chart.AxisScale.IsValid.Should().BeTrue(); chart.AxisScale.IsMinimumValid.Should().BeTrue(); - CanApply(chart.AxisScale).Should().BeTrue(); chart.AxisScale.MinimumText = string.Empty; chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleBoundsRequired); chart.AxisScale.IsMinimumValid.Should().BeFalse(); - CanApply(chart.AxisScale).Should().BeFalse(); } [AvaloniaTheory] @@ -288,9 +340,8 @@ public void AnUnreadableField_IsRefusedAndNothingIsWritten(string cultureName, s chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleBoundInvalid); chart.AxisScale.IsMaximumValid.Should().BeFalse(); chart.AxisScale.IsMinimumValid.Should().BeTrue(); - CanApply(chart.AxisScale).Should().BeFalse(); - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0)); closes.Should().Be(0); @@ -306,13 +357,13 @@ public void AFractionInTheCurrentCulturesSeparator_IsWritten() chart.AxisScale.MinimumText = "1,5"; chart.AxisScale.MaximumText = "150,5"; - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((1.5, 150.5)); } [AvaloniaFact] - public void Cancel_WritesNothingAndAsksToClose() + public void Cancel_WritesNothingAndAsksToClose_AndTheCommitOnDetachWritesNothing() { var (chart, _, _, provider) = CreateViewModel(); chart.ApplyCatalogue(provider.Pens); @@ -323,6 +374,7 @@ public void Cancel_WritesNothingAndAsksToClose() using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++); chart.AxisScale.CancelCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0)); closes.Should().Be(1); @@ -404,7 +456,7 @@ public void WhenThePenIsNoLongerDrawn_ThePanelClosesAndEveryCommandWritesNothing closes.Should().Be(1, "the pen the panel was opened for is no longer the one whose axis is drawn"); chart.AxisScale.MaximumText = "500"; - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.AxisScale.AutoscaleCommand.Execute().Subscribe(); chart.AxisScale.InitialScaleCommand.Execute().Subscribe(); @@ -418,7 +470,7 @@ public void WhenThePenIsNoLongerDrawn_ThePanelClosesAndEveryCommandWritesNothing } [AvaloniaFact] - public void Apply_LeavesTheNavigationWindowAndDeltaCursorsUntouched() + public void CommitBounds_LeavesTheNavigationWindowAndDeltaCursorsUntouched() { var (chart, _, _, provider) = CreateViewModel(); chart.ApplyCatalogue(provider.Pens); @@ -428,7 +480,7 @@ public void Apply_LeavesTheNavigationWindowAndDeltaCursorsUntouched() chart.AxisScale.MaximumText = "42"; chart.AxisScale.MinimumText = "1"; - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.Navigation.From.Should().Be(fromBefore); chart.Navigation.To.Should().Be(toBefore); @@ -438,7 +490,7 @@ public void Apply_LeavesTheNavigationWindowAndDeltaCursorsUntouched() } [AvaloniaFact] - public void Apply_PutsBothBoundsOnTheRenderedAxis() + public void CommitBounds_PutsBothBoundsOnTheRenderedAxis() { var (chart, _, _, provider) = CreateViewModel(); chart.ApplyCatalogue(provider.Pens); @@ -446,18 +498,326 @@ public void Apply_PutsBothBoundsOnTheRenderedAxis() chart.AxisScale.MaximumText = "120"; chart.AxisScale.MinimumText = "-20"; - chart.AxisScale.ApplyCommand.Execute().Subscribe(); + chart.AxisScale.CommitBounds(); chart.Plot.RenderInMemory(600, 400); chart.ActivePenAxis!.Range.Min.Should().Be(-20.0); chart.ActivePenAxis.Range.Max.Should().Be(120.0); } - private static bool CanApply(AxisScalePanelViewModel panel) + [AvaloniaFact] + public void Seed_ReadsTheAxisTypeFromTheChartsSettings() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue([.. provider.Pens.Select(pen => pen with { LogScaleOnStart = pen.PenId == 2 })]); + + chart.AxisScale.Seed(); + chart.AxisScale.IsLogarithmic.Should().BeFalse(); + + chart.SetActivePen(2); + chart.AxisScale.Seed(); + chart.AxisScale.IsLogarithmic.Should().BeTrue(); + } + + [AvaloniaFact] + public void ToggleLogarithmic_WritesThePendingPairFlipsTheFlagAndReseedsWithoutClosing() + { + using var culture = new CultureScope("en-US"); + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 0.5, 200.0); + chart.AxisScale.Seed(); + chart.AxisScale.MaximumText = "500.0"; + var closes = 0; + using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++); + + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) + { + Mode = ScaleMode.Manual, + IsLogarithmic = true, + ManualMin = 0.5, + ManualMax = 500.0 + }); + chart.AxisScale.IsLogarithmic.Should().BeTrue(); + chart.AxisScale.MaximumText.Should().Be("500", "the switch re-seeds the bounds"); + chart.AxisScale.ValidationMessage.Should().BeEmpty(); + closes.Should().Be(0); + + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].IsLogarithmic.Should().BeFalse(); + chart.AxisScale.IsLogarithmic.Should().BeFalse(); + closes.Should().Be(0); + } + + [AvaloniaFact] + public void ToggleLogarithmic_OverAManualMinimumOfZero_IsRefusedAndNamesTheRule() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 0.0, 100.0); + chart.AxisScale.Seed(); + var closes = 0; + using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++); + + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) + { + Mode = ScaleMode.Manual, + ManualMin = 0.0, + ManualMax = 100.0 + }); + chart.AxisScale.IsLogarithmic.Should().BeFalse(); + chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive); + chart.AxisScale.IsMinimumValid.Should().BeTrue("zero is a valid minimum on the linear axis it stays on"); + closes.Should().Be(0); + + chart.AxisScale.Seed(); + + chart.AxisScale.ValidationMessage.Should().BeEmpty("a new seed clears the refusal"); + } + + [AvaloniaFact] + public void ARefusedTick_DropsItsMessageOnceTheMinimumIsRetyped() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 0.0, 100.0); + chart.AxisScale.Seed(); + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive); + + chart.AxisScale.MinimumText = PenFormRules.FormatBound(5.0); + + chart.AxisScale.ValidationMessage.Should().BeEmpty("the typed minimum is what the operator applies next"); + chart.AxisScale.IsMinimumValid.Should().BeTrue(); + } + + [AvaloniaFact] + public void TypedOne_ThenToggleOn_WritesTheMinimumAndSwitchesTheAxisOn() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 0.0, 100.0); + chart.AxisScale.Seed(); + var closes = 0; + using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++); + + chart.AxisScale.MinimumText = "1"; + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) + { + Mode = ScaleMode.Manual, + IsLogarithmic = true, + ManualMin = 1.0, + ManualMax = 100.0 + }); + chart.AxisScale.IsLogarithmic.Should().BeTrue(); + chart.AxisScale.ValidationMessage.Should().BeEmpty(); + closes.Should().Be(0); + } + + [AvaloniaFact] + public void TypedZero_ThenToggleOff_SwitchesTheAxisOffAndWritesZeroUnderTheLinearRule() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 1.0, 100.0); + chart.SetLogarithmic(1, true); + chart.AxisScale.Seed(); + + chart.AxisScale.MinimumText = "0"; + chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive); + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) + { + Mode = ScaleMode.Manual, + ManualMin = 0.0, + ManualMax = 100.0 + }); + chart.AxisScale.IsLogarithmic.Should().BeFalse(); + chart.AxisScale.MinimumText.Should().Be("0"); + chart.AxisScale.ValidationMessage.Should().BeEmpty(); + } + + [AvaloniaTheory] + [InlineData("0")] + [InlineData("-5")] + public void ATypedMinimumTheLogRuleRefuses_ThenToggleOn_IsWrittenOnTheLinearAxisAndTheRuleIsNamed(string typed) + { + using var culture = new CultureScope("en-US"); + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 5.0, 100.0); + chart.AxisScale.Seed(); + + chart.AxisScale.MinimumText = typed; + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) + { + Mode = ScaleMode.Manual, + ManualMin = double.Parse(typed, CultureInfo.InvariantCulture), + ManualMax = 100.0 + }); + chart.AxisScale.IsLogarithmic.Should().BeFalse(); + chart.AxisScale.MinimumText.Should().Be(typed); + chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive); + } + + [AvaloniaTheory] + [InlineData("abc", false)] + [InlineData("", false)] + [InlineData("200", false)] + [InlineData("abc", true)] + [InlineData("200", true)] + public void APairThatDoesNotRead_RefusesTheToggle_AndKeepsTheText(string typed, bool startsLogarithmic) { - var canExecute = false; - using var subscription = panel.ApplyCommand.CanExecute.Subscribe(value => canExecute = value); + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 1.0, 100.0); + chart.SetLogarithmic(1, startsLogarithmic); + chart.AxisScale.Seed(); + chart.AxisScale.MinimumText = typed; + var raised = new List(); + chart.AxisScale.PropertyChanged += Record; - return canExecute; + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.AxisScale.PropertyChanged -= Record; + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) + { + Mode = ScaleMode.Manual, + IsLogarithmic = startsLogarithmic, + ManualMin = 1.0, + ManualMax = 100.0 + }); + chart.AxisScale.IsLogarithmic.Should().Be(startsLogarithmic); + chart.AxisScale.MinimumText.Should().Be(typed); + chart.AxisScale.ValidationMessage.Should().Be(typed switch + { + "" => Resources.AxisScaleBoundsRequired, + "200" => Resources.AxisScaleMinimumBelowMaximum, + _ => Resources.AxisScaleBoundInvalid + }); + raised.Should().Contain( + nameof(AxisScalePanelViewModel.IsLogarithmic), + "the box ticked itself and must re-read"); + + void Record(object? sender, PropertyChangedEventArgs change) + { + raised.Add(change.PropertyName); + } + } + + [AvaloniaFact] + public void ALogPenWhoseMinimumTheMaskShowsAsZero_StaysValidAndKeepsTheExactBound() + { + using var culture = new CultureScope("en-US"); + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue([.. provider.Pens.Select(pen => pen with + { + Format = "0.000", + ScaleMinOnStart = 1e-6, + ScaleMaxOnStart = 1e-1, + LogScaleOnStart = true + })]); + + chart.AxisScale.Seed(); + + chart.AxisScale.MinimumText.Should().Be("0.000"); + chart.AxisScale.IsLogarithmic.Should().BeTrue(); + chart.AxisScale.IsMinimumValid.Should().BeTrue(); + chart.AxisScale.ValidationMessage.Should().BeEmpty(); + + chart.AxisScale.CommitBounds(); + + chart.ScaleRangeForPen(1)!.Value.Should().Be((1e-6, 1e-1)); + } + + [AvaloniaFact] + public void ToggleLogarithmic_SwitchesTheRenderedAxisToDecadesAndBack() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 1e-6, 1e-1); + chart.AxisScale.Seed(); + + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + chart.Plot.RenderInMemory(600, 400); + + chart.ActivePenAxis!.TickGenerator.Should().BeOfType(); + chart.ActivePenAxis.Range.Min.Should().BeApproximately(-6.0, 1e-9); + chart.ActivePenAxis.Range.Max.Should().BeApproximately(-1.0, 1e-9); + + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + chart.Plot.RenderInMemory(600, 400); + + chart.ActivePenAxis.TickGenerator.Should().NotBeOfType(); + chart.ActivePenAxis.Range.Min.Should().Be(1e-6); + chart.ActivePenAxis.Range.Max.Should().Be(1e-1); + } + + [AvaloniaFact] + public void ToggleLogarithmic_OnAnAutoscaledPen_Succeeds() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.AxisScale.Seed(); + + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) { IsLogarithmic = true }); + chart.AxisScale.IsLogarithmic.Should().BeTrue(); + chart.AxisScale.ValidationMessage.Should().BeEmpty(); + } + + [AvaloniaFact] + public void InitialScale_RestoresTheStoredAxisType() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue([.. provider.Pens.Select(pen => pen with + { + ScaleMinOnStart = 1e-6, + ScaleMaxOnStart = 1e-1, + LogScaleOnStart = true + })]); + chart.AxisScale.Seed(); + chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe(); + chart.ScaleSettings[1].IsLogarithmic.Should().BeFalse(); + + chart.AxisScale.InitialScaleCommand.Execute().Subscribe(); + + chart.ScaleSettings[1].IsLogarithmic.Should().BeTrue(); + chart.ScaleRangeForPen(1)!.Value.Should().Be((1e-6, 1e-1)); + } + + [AvaloniaFact] + public void WithTheFlagOn_AMinimumOfZero_IsRefusedAndNotWritten() + { + var (chart, _, _, provider) = CreateViewModel(); + chart.ApplyCatalogue(provider.Pens); + chart.SetAxisLimits(1, 1.0, 100.0); + chart.SetLogarithmic(1, true); + chart.AxisScale.Seed(); + var closes = 0; + using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++); + + chart.AxisScale.MinimumText = "0"; + + chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive); + chart.AxisScale.IsMinimumValid.Should().BeFalse(); + chart.AxisScale.IsMaximumValid.Should().BeTrue(); + + chart.AxisScale.CommitBounds(); + + chart.ScaleRangeForPen(1)!.Value.Should().Be((1.0, 100.0)); + chart.ScaleSettings[1].IsLogarithmic.Should().BeTrue(); + closes.Should().Be(0); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs index ad25a46..97ece66 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs @@ -59,30 +59,12 @@ public void ClickWhereAHiddenPensAxisWas_OpensNothing() FlyoutBase.GetAttachedFlyout(shown.PlotControl)!.IsOpen.Should().BeFalse(); } - [AvaloniaFact] - public void TypedBoundsAndTheApplyButton_WriteAManualScaleAndClosePanel() - { - using var viewModel = CreateLoadedViewModel(); - using var shown = ShowChart(viewModel); - var (flyout, panel) = OpenPanel(viewModel, shown); - - TypeInto(shown.Window, panel, "AxisScaleMaximum", "120"); - TypeInto(shown.Window, panel, "AxisScaleMinimum", "-20"); - HeadlessInput.Click(shown.Window, panel.FindControl