Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7aec62b
Initial plan
Copilot Jul 30, 2026
9e0feef
Add calibration service and WPF workflow
Copilot Jul 30, 2026
428eaa2
fix calibration overreport result and curve layout
Copilot Aug 1, 2026
5944a29
Scale calibration chart to fit dialog frame
Copilot Aug 1, 2026
99969b4
Streamline semantics of Remaining Capacity API and UI, per Part 5 of …
avk1im Aug 6, 2026
e58df14
Set EW mechanism as the only source of truth to reserve TOC space whi…
avk1im Aug 7, 2026
0056793
Implement and integrate an aligned buffer to accelerate write operati…
avk1im Aug 8, 2026
802b163
Update write packer tests to integrate aligned buffer.
avk1im Aug 8, 2026
80b0de8
Update the design doc Design-RemainingAndEw.md to document aligned bu…
avk1im Aug 8, 2026
d6224aa
Update calibration options to follow the sample count preference.
avk1im Aug 9, 2026
19a38a3
Improvbe calibration graph view UI.
avk1im Aug 9, 2026
d5e4af0
Improve calibration UI
avk1im Aug 9, 2026
6ca3176
Accelerate TapeDrive writing in SCSI direct path post EW.
avk1im Aug 10, 2026
a2f0da2
Improve Calibration UI: ensure TOC validation upon calibration; add "…
avk1im Aug 10, 2026
e82854c
Ensure reconciliation of virtual drive caps with virtual media parame…
avk1im Aug 11, 2026
d831610
For tape drive calibration, impolement automatically finer step for t…
avk1im Aug 12, 2026
ca582b5
Update tape drive claibration to increase resolution for the final me…
avk1im Aug 14, 2026
c297d93
Update calibration process UI messaging.
avk1im Aug 14, 2026
3d038f0
Add calibration profile browsing / viewing / removing UI.
avk1im Aug 16, 2026
e83939b
Make including LtoRemaining (SCSI sense) with calibration optional; o…
avk1im Aug 16, 2026
ed56ee7
Merge branch 'copilot/calibration-ui-part-4-phase-5' of https://githu…
avk1im Aug 16, 2026
313ed4e
Implement calibration restart capability in TapeCalibrator. Fix the i…
avk1im Aug 16, 2026
0f6e4a7
Add calibration modes to TapeLibNET service layer: new calibration, r…
avk1im Aug 17, 2026
6c2091d
Optimize drive movements in Resume Calibration path of TapeCalibrator…
avk1im Aug 17, 2026
0c1a63b
Add InspectMedia() verb to TapeCalibrator, along with its unit tests.
avk1im Aug 18, 2026
bb9011a
Implement Recalibration / Resume Calibration UI for TapeWinNET.
avk1im Aug 18, 2026
eead226
Update calibration curve visualization control UI.
avk1im Aug 20, 2026
1b9803a
Improve recalibration TapeLibNET service and TapeWinNET UI.
avk1im Aug 22, 2026
7d23dbe
Update TapeLibNET services to warn on attempting to calibrate a multi…
avk1im Aug 22, 2026
756b24c
Update the color of "Writable" display to refelct the remaining capac…
avk1im Aug 22, 2026
b76646d
Merge branch 'copilot/calibration-ui-part-4-phase-5' of https://githu…
avk1im Aug 22, 2026
8701664
Update TapeWinNET toolbar tape drive icons: add drive number and opti…
avk1im Aug 23, 2026
fcd70c0
Adjust tape drive calibration / reported remaining and early warning …
avk1im Aug 24, 2026
2c2b124
Add comment on DLT-V4 behavior.
avk1im Aug 24, 2026
883f0d4
Merge branch 'copilot/calibration-ui-part-4-phase-5' of https://githu…
avk1im Aug 24, 2026
29c4f54
Update TOC emergency export suggestion comment in the TapeLibNET serv…
avk1im Aug 25, 2026
abf8677
Rename "LTO-4-like" emulated profile to "EmulatedOverreport"
avk1im Aug 27, 2026
b06ca1b
Merge branch 'copilot/calibration-ui-part-4-phase-5' of https://githu…
avk1im Aug 27, 2026
c7e30fd
Ensure correct Early Warning behavior after repositioning the media, …
avk1im Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 208 additions & 23 deletions TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs

Large diffs are not rendered by default.

576 changes: 576 additions & 0 deletions TapeLibNET.Tests/CalibrationResumeTests.cs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion TapeLibNET.Tests/Helpers/VirtualTapeFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public VirtualTapeFixture(
DriveProfile.Setmarks => VirtualTapeDriveCapabilities.WithSetmarks,
DriveProfile.Partitions => VirtualTapeDriveCapabilities.WithPartitions,
DriveProfile.SeqFilemarks => VirtualTapeDriveCapabilities.WithSeqFilemarks,
DriveProfile.FilemarksOnly => VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
DriveProfile.FilemarksOnly => VirtualTapeDriveCapabilities.WithFilemarksOnly, // WithFilemarksOnlyLargeBlocks,
_ => throw new ArgumentOutOfRangeException(nameof(profile)),
};

Expand Down
2 changes: 0 additions & 2 deletions TapeLibNET.Tests/RemoteBackendTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,13 @@ protected virtual void EnsureServiceAvailable() { }

#region *** Test Data ***

#pragma warning disable CA1825
public static TheoryData<DriveProfile> AllProfiles =>
[
DriveProfile.Setmarks,
DriveProfile.Partitions,
DriveProfile.SeqFilemarks,
DriveProfile.FilemarksOnly,
];
#pragma warning restore CA1825

#endregion

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ public class RemoteServiceMultiVolumeTests(LocalHostTapeServiceFixture fixture)
private const int MvIncFileCount = 16;
private const long MvIncFileSizeFull = 350L * 1024;
private const long MvIncFileSizeModified = 700L * 1024;
private const long MvIncVol1Capacity_Setmarks = 40L * 1024 * 1024;
private const long MvIncVol1Capacity_Setmarks = 41L * 1024 * 1024;
// Added 1 MiB so that at least one file from the follow-up set fits, therefore splitting
// the set across vol-1 and vol-2 -- as we check volume continuation in the test.
private const long MvIncVol1Capacity_Initiator = 8L * 1024 * 1024;
private const long MvIncVol2Capacity_Setmarks = 46L * 1024 * 1024;
private const long MvIncVol2Capacity_Initiator = 14L * 1024 * 1024;
Expand Down Expand Up @@ -81,7 +83,8 @@ public async Task Remote_MultiVolume_RegularBackup_SpansVolumes_RestoreAllFiles(
long volumeCapacity = withInitiator ? MultiVolumeCapacity_Initiator : MultiVolumeCapacity_Setmarks;
using var vol1 = new TempVirtualMedia(withInitiator, volumeCapacity);
using var vol2 = new TempVirtualMedia(withInitiator, volumeCapacity);
IReadOnlyList<TempVirtualMedia> volumes = [vol1, vol2];
//using var vol3 = new TempVirtualMedia(withInitiator, volumeCapacity);
IReadOnlyList<TempVirtualMedia> volumes = [vol1, vol2,/*vol3,*/ ];

using var src = new TempFileTree();
AddMultiVolumeContent(src);
Expand Down
299 changes: 299 additions & 0 deletions TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
using System;
using TapeLibNET.Services;
using TapeLibNET.Tests.Helpers;
using TapeLibNET.Virtual;

namespace TapeLibNET.Tests.Services;

/// <summary>
/// Service-level coverage for the extended calibration surface: the <see cref="CalibrationMode"/>
/// dispatch (New / Resume / Recalibrate) through <see cref="TapeServiceBase.ExecuteCalibrateAsync"/>,
/// result tagging (<see cref="CalibrateResult.Mode"/> / <see cref="CalibrateResult.RecalibrationDelta"/> /
/// <see cref="CalibrateResult.RecalibrationVerdict"/>), and host-pane logging — driven over small
/// memory-backed virtual cartridges.
/// <para>
/// These complement the drive-level <c>CalibrationResumeTests</c> (which prove the mechanism itself);
/// here the focus is the SERVICE plumbing: mode routing, verdict reporting, and mode-appropriate
/// failure messages. The specific recalibration VERDICT is intentionally not pinned on an unchanged
/// small drive — the EW→EOM distance is block-quantized, so an unchanged-profile recalibrate can move
/// by a block; that is production-irrelevant but would make a strict "Holds" assertion flaky. Verdict
/// behavior under a genuine drive-behavior change is covered separately (see the breach test).
/// </para>
/// </summary>
public class ServiceCalibrationResumeTests : ServiceTestBase
{
private const long MB = 1024L * 1024;
private const long CalibrationCapacity = 64L * MB;

private static async Task<(TapeServiceBase service, TestTapeServiceHost host)> OpenCalibrationServiceAsync(
long capacity = CalibrationCapacity,
VirtualTapeDriveIoRate? ioRate = null,
VirtualTapeEwProfile? ewProfile = null)
{
var (service, host) = CreateService();

var vmd = new VirtualMediaDescriptor("memory-calibration", capacity, null, 0, InMemory: true);

Assert.True(await service.OpenVirtualDriveAsync(
VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
vmd,
ioRate: ioRate,
ewProfile: ewProfile ?? VirtualTapeEwProfile.EmulatedOverreport(capacity)),
$"OpenVirtualDriveAsync failed: {service.LastError}");

Assert.True(await service.LoadMediaAsync(),
$"LoadMediaAsync failed: {service.LastError}");

return (service, host);
}

private static TapeCalibrationOptions FastOptions() => new()
{
SampleCount = 40,
NumCheckpoints = 16,
};

// ── New calibration (extra coverage) ──────────────────────────────────────

[Fact]
public async Task ExecuteCalibrateAsync_New_ProducesMonotonicCurve_AndDefaultsToNewMode()
{
var (service, _) = await OpenCalibrationServiceAsync();
using (service)
{
var result = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));

Assert.True(result.Success);

// The default mode is New, and the recalibration fields stay null for New/Resume.
Assert.Equal(CalibrationMode.New, result.Mode);
Assert.Null(result.RecalibrationDelta);
Assert.Null(result.RecalibrationVerdict);

var cal = result.Calibration;
Assert.NotNull(cal);
Assert.True(cal!.Curve.Count >= 2, "Curve should have at least two points");

// Curve is ascending in ReportedRemaining and monotonic non-decreasing in ActualRemaining.
for (int i = 1; i < cal.Curve.Count; i++)
{
Assert.True(cal.Curve[i].ReportedRemaining >= cal.Curve[i - 1].ReportedRemaining,
"ReportedRemaining axis must be ascending");
Assert.True(cal.Curve[i].ActualRemaining >= cal.Curve[i - 1].ActualRemaining,
"ActualRemaining must be monotonic non-decreasing");
}

Assert.NotNull(cal.EarlyWarning);
Assert.Equal(service.DriveProfileKey, result.ProfileKey);
}
}

// ── Resume ────────────────────────────────────────────────────────────────

[Fact]
public async Task ExecuteCalibrateAsync_Resume_OnTrailedCartridge_Completes()
{
var (service, host) = await OpenCalibrationServiceAsync();
using (service)
{
// A completed run leaves the resumable trail (header + body checkpoints) on the cartridge.
var first = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
Assert.True(first.Success);
Assert.Equal(CalibrationMode.New, first.Mode);

// Resume deterministically restarts from the last body checkpoint and re-measures the tail to
// EOM — no timing dependency, since the trail is already present.
var resumed = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
Mode: CalibrationMode.Resume));

Assert.True(resumed.Success, $"Resume failed: {resumed.Message}");
Assert.Equal(CalibrationMode.Resume, resumed.Mode);
Assert.NotNull(resumed.Calibration);
Assert.True(resumed.CapacityActual > 0);
Assert.Null(resumed.RecalibrationDelta); // Resume carries no recalibration delta
Assert.Null(resumed.RecalibrationVerdict);
Assert.True(host.ContainsMessage("Resuming calibration"));
}
}

[Fact]
public async Task ExecuteCalibrateAsync_Resume_OnBlankCartridge_FailsGracefully()
{
var (service, _ /*host*/) = await OpenCalibrationServiceAsync();
using (service)
{
// No run has been performed, so there is no header/trail to resume from.
var resumed = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
Mode: CalibrationMode.Resume));

Assert.False(resumed.Success);
Assert.Equal(CalibrationMode.Resume, resumed.Mode);
Assert.Contains("no resumable run", resumed.Message ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
}
}

// ── Recalibrate ───────────────────────────────────────────────────────────

[Fact]
public async Task ExecuteCalibrateAsync_Recalibrate_ReportsDeltaVerdictAndAssessment()
{
var (service, host) = await OpenCalibrationServiceAsync();
using (service)
{
var first = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
Assert.True(first.Success);
Assert.NotNull(first.Calibration);

var recal = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
Mode: CalibrationMode.Recalibrate, ExistingCalibration: first.Calibration));

// Plumbing: the mode is tagged, the delta and verdict are populated, and the assessment is
// logged to the host pane. The specific verdict is drive-quantization-dependent on a small
// cartridge, so it is not pinned here (see the breach test for verdict behavior).
Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
Assert.NotNull(recal.RecalibrationDelta);
Assert.NotNull(recal.RecalibrationVerdict);
Assert.NotNull(recal.Calibration);
Assert.True(host.ContainsMessage("Recalibration assessment"));

// The delta reports the raw before/after values verdict-free.
var d = recal.RecalibrationDelta!.Value;
Assert.Equal(first.Calibration!.EwToEomDistance, d.OldEwToEomDistance);
Assert.Equal(first.Calibration.CapacityActual, d.OldCapacityActual);
}
}

[Fact]
public async Task ExecuteCalibrateAsync_Recalibrate_OnBlankCartridge_Fails()
{
var (service, _) = await OpenCalibrationServiceAsync();
using (service)
{
// A baseline to compare against exists, but the cartridge carries no calibration trail.
var existing = TapeCalibration.Apriori(service.DriveProfileKey, CalibrationCapacity);

var recal = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
Mode: CalibrationMode.Recalibrate, ExistingCalibration: existing));

Assert.False(recal.Success);
Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
Assert.Contains("no calibration trail", recal.Message ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
}
}

[Fact]
public async Task ExecuteCalibrateAsync_Recalibrate_WithoutExistingOrTrail_Fails()
{
var (service, _) = await OpenCalibrationServiceAsync();
using (service)
{
// No explicit existing calibration, none loaded on the drive, none in the store → the service
// refuses before touching the tape.
var recal = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
Mode: CalibrationMode.Recalibrate));

Assert.False(recal.Success);
Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
}
}

// ── Recalibrate — threshold breach + host confirm chain ──────────────────
// The breach is induced with a divergent STORED baseline (a pre-firmware-change calibration),
// which drives the same service judge → confirm → chain path as a live drive-behavior change while
// needing only public API. The drive-level profile-swap test proves the calibrator itself surfaces
// a real behavior shift as a large delta.

private static TapeCalibration StaleBaseline(TapeServiceBase service)
{
// Hand-craft a plausible PRE-firmware-change baseline via the public FromMeasurements factory:
// a normal measured-shape calibration whose EW→EOM distance (~22 MB) is an order of magnitude
// larger than the drive's actual ~2.5 MB, so recalibration's EW-shift comfortably breaches every
// service tolerance and drives FullRecalibrationAdvised.
// (Replaces the removed Apriori(marginPercent, remainingAtEwPercent) overload the test abused.)
const long capacityActual = CalibrationCapacity; // 64 MB — matches the emulated drive
const long ewToEomDistance = 22L * 1024 * 1024; // 22 MB, vs the drive's ~2.5 MB
const long reportedAtBom = capacityActual; // truthful BOM (no boost)

// Samples as (ActualWritten, ReportedRemaining): BOM, the EW landmark, hard EOM.
var samples = new List<(long ActualWritten, long ReportedRemaining)>
{
(0L, reportedAtBom), // BOM
(capacityActual - ewToEomDistance, ewToEomDistance), // EW landmark (42 MB written)
(capacityActual, 0L), // hard EOM
};

(long ActualWritten, long ReportedRemaining) ew =
(capacityActual - ewToEomDistance, ewToEomDistance);

return TapeCalibration.FromMeasurements(
service.DriveProfileKey, reportedAtBom, capacityActual, samples, ew);
}

[Fact]
public async Task ExecuteCalibrateAsync_Recalibrate_Breach_Confirmed_ChainsFullRun()
{
var (service, host) = await OpenCalibrationServiceAsync();
using (service)
{
// A completed run leaves the resumable trail on the cartridge.
var first = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
Assert.True(first.Success);

// Script the host to CONFIRM the destructive full re-run.
host.ConfirmAnswers.Enqueue(true);

var recal = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
Mode: CalibrationMode.Recalibrate, ExistingCalibration: StaleBaseline(service)));

Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
Assert.Equal(RecalibrationVerdict.FullRecalibrationAdvised, recal.RecalibrationVerdict);
Assert.NotNull(recal.RecalibrationDelta);

// Confirmed → the service chained a fresh full run; the result is a NEW calibration re-tagged
// as a recalibration outcome, the confirm was consumed, and the chain was logged.
Assert.True(recal.Success, $"Chained full run should succeed: {recal.Message}");
Assert.NotNull(recal.Calibration);
Assert.True(host.ContainsMessage("Full recalibration confirmed"));
Assert.Empty(host.ConfirmAnswers);
}
}

[Fact]
public async Task ExecuteCalibrateAsync_Recalibrate_Breach_Declined_KeepsReassessed()
{
var (service, host) = await OpenCalibrationServiceAsync();
using (service)
{
var first = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
Assert.True(first.Success);

// No queued Confirm answer → the host returns its safe default (false): DECLINE the full re-run.
// This is the exact non-interactive guard that stops a quiet host launching a destructive run.
var recal = await service.ExecuteCalibrateAsync(
new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
Mode: CalibrationMode.Recalibrate, ExistingCalibration: StaleBaseline(service)));

Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
Assert.Equal(RecalibrationVerdict.FullRecalibrationAdvised, recal.RecalibrationVerdict);

// Declined → no chained run; the reassessed calibration is kept, and the decline is logged.
Assert.True(recal.Success);
Assert.NotNull(recal.Calibration);
Assert.True(host.ContainsMessage("Full recalibration declined"));
Assert.False(host.ContainsMessage("Full recalibration confirmed"));
}
}
}
Loading