Files
LanMountainDesktop/LanMountainDesktop.Tests/StudyComponentRenderingTests.cs
lincube b71687cecd Introduce render gate and chart caching
Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout.
2026-05-06 16:00:45 +08:00

245 lines
8.4 KiB
C#

using System;
using System.Collections.Generic;
using Avalonia;
using LanMountainDesktop.Models;
using LanMountainDesktop.Views.Components;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class StudyComponentRenderingTests
{
[Fact]
public void RenderGate_ProcessesOnlyLatestSnapshot()
{
var rendered = new List<string>();
using var gate = new StudySnapshotRenderGate(
canRender: () => true,
renderSnapshot: snapshot => rendered.Add(snapshot.LastError));
gate.Queue(CreateSnapshot("first"));
gate.Queue(CreateSnapshot("second"));
Assert.True(gate.ProcessPending());
Assert.Equal(["second"], rendered);
Assert.False(gate.HasPendingSnapshot);
}
[Fact]
public void RenderGate_DropsPendingSnapshot_WhenRenderIsBlocked()
{
var renderCount = 0;
using var gate = new StudySnapshotRenderGate(
canRender: () => false,
renderSnapshot: _ => renderCount++);
gate.Queue(CreateSnapshot("blocked"));
Assert.False(gate.ProcessPending());
Assert.Equal(0, renderCount);
Assert.False(gate.HasPendingSnapshot);
}
[Fact]
public void CurveChart_SplitsStableHistoryFromDynamicTail()
{
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
var counts = StudyNoiseCurveChartControl.ResolveLayerSourceCounts(points, TimeSpan.FromSeconds(4));
Assert.Equal(5, StudyNoiseCurveChartControl.ResolveFirstTailIndex(points, TimeSpan.FromSeconds(4)));
Assert.Equal(5, counts.StaticSourceCount);
Assert.Equal(6, counts.DynamicSourceCount);
}
[Fact]
public void CurveChart_UsesStableLogicalTimeCoordinates()
{
var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
var x = StudyNoiseCurveChartControl.MapTimestampToLogicalX(
origin.AddSeconds(3),
origin,
pixelsPerSecond: 12);
Assert.Equal(36, x);
}
[Fact]
public void DistributionAreaChart_BuildsAreaPathCache()
{
var points = CreateRealtimePoints(count: 24, step: TimeSpan.FromMilliseconds(500));
var control = new StudyNoiseDistributionAreaChartControl();
control.UpdateSeries(points, baselineDb: 45);
control.RebuildCacheForTesting(new Rect(1, 1, 320, 160));
Assert.True(control.CachedPathCount > 0);
Assert.True(control.CachedPathCount <= 4);
Assert.True(control.StaticSourceCount > 0);
Assert.True(control.DynamicSourceCount > 0);
}
[Fact]
public void DistributionAreaChart_UsesStableLogicalTimeCoordinates_WhenNewPointArrives()
{
var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
var oldPointTimestamp = origin.AddSeconds(3);
var before = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX(
oldPointTimestamp,
origin,
pixelsPerSecond: 20);
var after = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX(
oldPointTimestamp,
origin,
pixelsPerSecond: 20);
Assert.Equal(before, after);
Assert.Equal(60, after);
}
[Fact]
public void DistributionAreaChart_ReusesStaticAreaPath_WhenOnlyDynamicTailChanges()
{
var firstSeries = CreateRealtimePoints(
new[]
{
(0d, 40d),
(1d, 43d),
(2d, 45d),
(3d, 47d),
(8d, 52d)
});
var secondSeries = CreateRealtimePoints(
new[]
{
(0d, 40d),
(1d, 43d),
(2d, 45d),
(3d, 47d),
(8d, 52d),
(8.05d, 54d)
});
var control = new StudyNoiseDistributionAreaChartControl();
var plot = new Rect(1, 1, 320, 160);
control.UpdateSeries(firstSeries, baselineDb: 45);
control.RebuildCacheForTesting(plot);
var staticBuildVersion = control.StaticPathBuildVersion;
control.UpdateSeries(secondSeries, baselineDb: 45);
control.RebuildCacheForTesting(plot);
Assert.Equal(staticBuildVersion, control.StaticPathBuildVersion);
Assert.True(control.DynamicPathBuildVersion > 1);
}
[Fact]
public void DistributionAreaChart_SplitsStaticHistoryFromDynamicTail()
{
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts(
points,
TimeSpan.FromSeconds(4));
Assert.Equal(5, counts.StaticSourceCount);
Assert.Equal(6, counts.DynamicSourceCount);
}
[Fact]
public void DistributionAreaChart_StaticReportKeepsWholeSeriesStatic()
{
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts(
points,
TimeSpan.FromSeconds(4),
isStaticSeries: true);
Assert.Equal(10, counts.StaticSourceCount);
Assert.Equal(0, counts.DynamicSourceCount);
}
[Fact]
public void DistributionAreaChart_ResolvesLevelsFromBaseline()
{
Assert.Equal(NoiseDistributionLevel.Quiet, StudyNoiseDistributionAreaChartControl.ResolveLevel(44.9, 45));
Assert.Equal(NoiseDistributionLevel.Normal, StudyNoiseDistributionAreaChartControl.ResolveLevel(45, 45));
Assert.Equal(NoiseDistributionLevel.Noisy, StudyNoiseDistributionAreaChartControl.ResolveLevel(55, 45));
Assert.Equal(NoiseDistributionLevel.Extreme, StudyNoiseDistributionAreaChartControl.ResolveLevel(65, 45));
}
private static IReadOnlyList<NoiseRealtimePoint> CreateRealtimePoints(int count, TimeSpan step)
{
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
var points = new List<NoiseRealtimePoint>(count);
for (var i = 0; i < count; i++)
{
var displayDb = 38 + i;
points.Add(new NoiseRealtimePoint(
Timestamp: start + TimeSpan.FromTicks(step.Ticks * i),
Rms: 0.2,
Dbfs: -60 + i,
DisplayDb: displayDb,
Peak: 0.3,
IsOverThreshold: displayDb > 50));
}
return points;
}
private static IReadOnlyList<NoiseRealtimePoint> CreateRealtimePoints(IReadOnlyList<(double OffsetSeconds, double DisplayDb)> samples)
{
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
var points = new List<NoiseRealtimePoint>(samples.Count);
for (var i = 0; i < samples.Count; i++)
{
var sample = samples[i];
points.Add(new NoiseRealtimePoint(
Timestamp: start + TimeSpan.FromSeconds(sample.OffsetSeconds),
Rms: 0.2,
Dbfs: -60 + i,
DisplayDb: sample.DisplayDb,
Peak: 0.3,
IsOverThreshold: sample.DisplayDb > 50));
}
return points;
}
private static StudyAnalyticsSnapshot CreateSnapshot(string marker)
{
var config = new StudyAnalyticsConfig();
var session = new StudySessionSnapshot(
State: StudySessionRuntimeState.Idle,
SessionId: null,
Label: string.Empty,
StartedAt: null,
EndedAt: null,
Elapsed: TimeSpan.Zero,
Metrics: new StudySessionMetrics(
CurrentScore: 0,
AvgScore: 0,
MinScore: 0,
MaxScore: 0,
WeightedOverRatioDbfs: 0,
TotalSegmentCount: 0,
EffectiveDuration: TimeSpan.Zero,
SliceCount: 0),
LastError: string.Empty);
return new StudyAnalyticsSnapshot(
State: StudyAnalyticsRuntimeState.Ready,
StreamStatus: NoiseStreamStatus.Initializing,
DataMode: StudyDataMode.Realtime,
Config: config,
LatestRealtimePoint: null,
LatestSlice: null,
RealtimeBuffer: Array.Empty<NoiseRealtimePoint>(),
Session: session,
LastSessionReport: null,
SelectedSessionReportId: null,
SessionHistory: Array.Empty<StudySessionHistoryEntry>(),
LastError: marker);
}
}