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.
This commit is contained in:
lincube
2026-05-06 16:00:45 +08:00
parent 68ca532dc0
commit b71687cecd
55 changed files with 4529 additions and 1059 deletions

View File

@@ -0,0 +1,244 @@
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);
}
}

View File

@@ -0,0 +1,404 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Update;
using LanMountainDesktop.Shared.Contracts.Update;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class UpdateEngineRollbackRegressionTests : IDisposable
{
private readonly UpdateTestDirectory _directory = new();
[Fact]
public async Task ApplyPlondsUpdate_KeepsPreviousDeploymentForManualRollback()
{
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
var newState = Encoding.UTF8.GetBytes("new-state");
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.True(result.Success, result.ErrorMessage);
Assert.True(Directory.Exists(current));
Assert.False(File.Exists(Path.Combine(current, ".current")));
var rollback = service.RollbackLatest();
Assert.True(rollback.Success, rollback.ErrorMessage);
Assert.Equal("1.0.0", rollback.RolledBackTo);
Assert.True(File.Exists(Path.Combine(current, ".current")));
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
}
[Fact]
public async Task ApplyPlondsUpdate_WhenObjectHashMismatches_RollsBackToPreviousDeployment()
{
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
var newState = Encoding.UTF8.GetBytes("new-state");
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64));
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.False(result.Success);
Assert.Equal("apply_failed", result.Code);
Assert.Equal("1.0.0", result.RolledBackTo);
Assert.True(File.Exists(Path.Combine(current, ".current")));
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
Assert.Empty(Directory.GetDirectories(_directory.AppRoot, "app-1.1.0-*"));
}
[Fact]
public void RollbackLatest_WhenSnapshotSourceDirectoryIsMissing_ReturnsStructuredFailure()
{
_directory.CreateDeployment("1.1.0", "new-state", isCurrent: true);
_directory.WriteSnapshot(
sourceVersion: "1.0.0",
sourceDirectory: Path.Combine(_directory.AppRoot, "app-1.0.0-0"),
targetVersion: "1.1.0",
targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0"));
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
var result = service.RollbackLatest();
Assert.False(result.Success);
Assert.Equal("source_missing", result.Code);
Assert.Contains("app-1.0.0-0", result.ErrorMessage);
}
public void Dispose() => _directory.Dispose();
private static string Sha256Hex(byte[] bytes)
{
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private sealed class UpdateTestDirectory : IDisposable
{
private readonly string _root;
private readonly RSA _rsa = RSA.Create(2048);
public UpdateTestDirectory()
{
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.UpdateRegression", Guid.NewGuid().ToString("N"));
AppRoot = Path.Combine(_root, "app-root");
Directory.CreateDirectory(AppRoot);
var resolver = new DataLocationResolver(AppRoot);
LauncherRoot = resolver.ResolveLauncherDataPath();
IncomingRoot = Path.Combine(LauncherRoot, "update", "incoming");
SnapshotsRoot = Path.Combine(LauncherRoot, "snapshots");
Directory.CreateDirectory(Path.Combine(LauncherRoot, "update"));
File.WriteAllText(Path.Combine(LauncherRoot, "update", "public-key.pem"), _rsa.ExportSubjectPublicKeyInfoPem());
}
public string AppRoot { get; }
private string LauncherRoot { get; }
private string IncomingRoot { get; }
private string SnapshotsRoot { get; }
public string CreateDeployment(string version, string state, bool isCurrent)
{
var deployment = Path.Combine(AppRoot, $"app-{version}-0");
Directory.CreateDirectory(deployment);
File.WriteAllText(Path.Combine(deployment, ExecutableName), $"exe-{version}");
File.WriteAllText(Path.Combine(deployment, "state.txt"), state);
if (isCurrent)
{
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
}
return deployment;
}
public void StagePlondsUpdate(string fromVersion, string toVersion, byte[] statePayload, string expectedStateSha256)
{
Directory.CreateDirectory(IncomingRoot);
var objectsRoot = Path.Combine(IncomingRoot, "objects");
Directory.CreateDirectory(objectsRoot);
var objectHash = Convert.ToHexString(SHA256.HashData(statePayload)).ToLowerInvariant();
File.WriteAllBytes(Path.Combine(objectsRoot, objectHash), statePayload);
var currentExecutable = Path.Combine(AppRoot, $"app-{fromVersion}-0", ExecutableName);
var fileMap = new PlondsFileMap
{
DistributionId = $"stable-{PlondsStaticUpdateService.ResolveCurrentPlatform()}-{toVersion}",
FromVersion = fromVersion,
ToVersion = toVersion,
Platform = PlondsStaticUpdateService.ResolveCurrentPlatform(),
Files =
[
new PlondsFileEntry
{
Path = ExecutableName,
Action = "reuse",
Sha256 = Sha256File(currentExecutable)
},
new PlondsFileEntry
{
Path = "state.txt",
Action = "replace",
Sha256 = expectedStateSha256,
ObjectUrl = $"https://static.example/lanmountain/update/repo/sha256/{objectHash[..2]}/{objectHash}"
}
]
};
var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json");
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap));
Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig"));
}
public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory)
{
Directory.CreateDirectory(SnapshotsRoot);
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = sourceDirectory,
TargetDirectory = targetDirectory,
Status = "applied"
};
File.WriteAllText(
Path.Combine(SnapshotsRoot, $"{snapshot.SnapshotId}.json"),
JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
}
public void Dispose()
{
_rsa.Dispose();
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private void Sign(string payloadPath, string signaturePath)
{
var signature = _rsa.SignData(File.ReadAllBytes(payloadPath), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(signaturePath, Convert.ToBase64String(signature));
}
private static string Sha256File(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string ExecutableName => OperatingSystem.IsWindows()
? "LanMountainDesktop.exe"
: "LanMountainDesktop";
}
}
public sealed class PlondsStaticUpdateServiceTests
{
[Fact]
public async Task CheckForUpdatesAsync_ReadsStaticLatestDistributionAndBuildsPayloadUrls()
{
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
var handler = new StaticManifestHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (path.EndsWith($"/meta/channels/stable/{platform}/latest.json", StringComparison.Ordinal))
{
return Json("""{"distributionId":"dist-1","version":"1.2.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z"}"""
.Replace("PLATFORM", platform));
}
if (path.EndsWith("/meta/distributions/dist-1.json", StringComparison.Ordinal))
{
return Json("""{"distributionId":"dist-1","version":"1.2.0","sourceVersion":"1.0.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z","fileMapUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json","fileMapSignatureUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig"}"""
.Replace("PLATFORM", platform));
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
using var client = new HttpClient(handler);
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
Assert.True(result.Success, result.ErrorMessage);
Assert.True(result.IsUpdateAvailable);
Assert.Equal("1.2.0", result.LatestVersionText);
Assert.NotNull(result.PlondsPayload);
Assert.Equal("dist-1", result.PlondsPayload.DistributionId);
Assert.Equal(platform, result.PlondsPayload.SubChannel);
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json", result.PlondsPayload.FileMapJsonUrl);
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig", result.PlondsPayload.FileMapSignatureUrl);
}
[Fact]
public async Task CheckForUpdatesAsync_WhenLatestIsMissing_ReturnsFailureForFallback()
{
using var client = new HttpClient(new StaticManifestHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
Assert.False(result.Success);
Assert.False(result.IsUpdateAvailable);
Assert.Contains("latest manifest", result.ErrorMessage);
}
[Fact]
public void ResolveCurrentPlatform_UsesCanonicalNames()
{
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
Assert.DoesNotContain("win-", platform, StringComparison.OrdinalIgnoreCase);
if (OperatingSystem.IsWindows())
{
Assert.StartsWith("windows-", platform, StringComparison.Ordinal);
}
else if (OperatingSystem.IsLinux())
{
Assert.StartsWith("linux-", platform, StringComparison.Ordinal);
}
}
private static HttpResponseMessage Json(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(responder(request));
}
}
}
public sealed class UpdatePathConsistencyTests
{
[Fact]
public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing()
{
var incoming = UpdateWorkflowService.GetLauncherIncomingDirectory();
var sharedIncoming = UpdatePaths.GetIncomingDirectory("root");
Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming);
Assert.Equal(
Path.Combine("root", ".Launcher", "update", "incoming"),
sharedIncoming);
}
}
public sealed class PlondsApiManifestProviderTests
{
[Fact]
public async Task GetLatestAsync_MapsCanonicalAndLegacyFileFields()
{
using var client = new HttpClient(new StaticManifestHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (path.EndsWith("/api/plonds/v1/channels/stable/windows-x64/latest", StringComparison.Ordinal))
{
return Json("""{"distributionId":"dist-2","version":"1.2.0","publishedAt":"2026-05-06T00:00:00Z"}""");
}
if (path.EndsWith("/api/plonds/v1/distributions/dist-2", StringComparison.Ordinal))
{
return Json("""
{
"distributionId": "dist-2",
"version": "1.2.0",
"sourceVersion": "1.1.0",
"publishedAt": "2026-05-06T00:00:00Z",
"fileMapUrl": "https://static.example/filemap.json",
"signatures": [{ "signature": "https://static.example/filemap.json.sig" }],
"components": [
{
"files": [
{
"path": "LanMountainDesktop.exe",
"action": "replace",
"sha256": "abc123",
"size": 42,
"objectUrl": "https://static.example/repo/sha256/ab/abc123",
"archiveSha256": "archive123"
},
{
"path": "legacy.dll",
"op": "add",
"contentHash": "def456",
"size": 7
}
]
}
]
}
""");
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
}));
var provider = new PlondsApiManifestProvider("https://static.example", client);
var manifest = await provider.GetLatestAsync("stable", "windows-x64", new Version(1, 1, 0), CancellationToken.None);
Assert.NotNull(manifest);
Assert.Equal(UpdatePayloadKind.DeltaPlonds, manifest.Kind);
Assert.Equal("https://static.example/filemap.json.sig", manifest.FileMapSignatureUrl);
Assert.Collection(
manifest.Files,
first =>
{
Assert.Equal("replace", first.Action);
Assert.Equal("abc123", first.Sha256);
Assert.Equal("https://static.example/repo/sha256/ab/abc123", first.ObjectUrl);
Assert.Equal("archive123", first.ArchiveSha256);
},
second =>
{
Assert.Equal("add", second.Action);
Assert.Equal("def456", second.Sha256);
});
}
private static HttpResponseMessage Json(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(responder(request));
}
}
}