Settings changes are applied to the latest stored settings under a cross-process lock and renamed into place in one step, so two instances no longer erase each other's changes and a file that cannot be read is never replaced by defaults. Invalid content is repaired per property with a copy of the original kept aside. ADR-0006 records the file protocol. Closes #46 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
135 lines
5.5 KiB
C#
135 lines
5.5 KiB
C#
/*
|
|
* Copyright (c) 2026 James Maes
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
|
|
namespace MarkdownViewr.Windows.Core.Tests;
|
|
|
|
/// <summary>Renaming one real file over another, with other real handles open on the destination.</summary>
|
|
public sealed class FileSwapTests
|
|
{
|
|
/// <summary>A full path is given the form Windows takes as written: a drive path, a network path, and paths that already have it.</summary>
|
|
[Theory]
|
|
[InlineData(@"C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
|
[InlineData(@"\\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
|
[InlineData(@"\\?\C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
|
[InlineData(@"\\?\UNC\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
|
[InlineData(@"\\.\C:\data\settings.json", @"\\.\C:\data\settings.json")]
|
|
public void Verbatim_FullPath_IsPrefixedOnce(string path, string expected)
|
|
{
|
|
Assert.Equal(expected, FileSwap.Verbatim(path));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>The rename replaces the destination and removes the source name.</summary>
|
|
[Fact]
|
|
public void TryRenameOver_ExistingDestination_ReplacesIt()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
File.WriteAllText(temp.File("stored.json"), "old");
|
|
File.WriteAllText(temp.File("new.tmp"), "new");
|
|
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
|
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
|
Assert.False(File.Exists(temp.File("new.tmp")));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A reader that allows deletion does not stop the rename, and goes on reading the content it opened.</summary>
|
|
[Fact]
|
|
public void TryRenameOver_DestinationOpenByAReaderThatAllowsDeletion_ReplacesItUnderTheReader()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
File.WriteAllText(temp.File("stored.json"), "old content");
|
|
File.WriteAllText(temp.File("new.tmp"), "new");
|
|
using(var reader = new StreamReader(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)))
|
|
{
|
|
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
|
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
|
Assert.Equal("old content", reader.ReadToEnd());
|
|
}
|
|
Assert.Equal(["stored.json"], Directory.GetFiles(temp.Path).Select(file => Path.GetFileName(file)!), StringComparer.Ordinal);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A handle that does not allow deletion refuses the rename with a sharing violation, and both files stay as they were.</summary>
|
|
[Fact]
|
|
public void TryRenameOver_DestinationOpenWithoutDeleteSharing_IsASharingViolation()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
File.WriteAllText(temp.File("stored.json"), "old");
|
|
File.WriteAllText(temp.File("new.tmp"), "new");
|
|
using(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
{
|
|
IOException refused = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
|
Assert.Equal(unchecked((int)0x80070020), refused.HResult);
|
|
}
|
|
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
|
Assert.Equal("new", File.ReadAllText(temp.File("new.tmp")));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A read-only destination is access denied, as it is for a managed move.</summary>
|
|
[Fact]
|
|
public void TryRenameOver_ReadOnlyDestination_IsAccessDenied()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
File.WriteAllText(temp.File("stored.json"), "old");
|
|
File.WriteAllText(temp.File("new.tmp"), "new");
|
|
File.SetAttributes(temp.File("stored.json"), FileAttributes.ReadOnly);
|
|
try
|
|
{
|
|
Assert.Throws<UnauthorizedAccessException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
|
}
|
|
finally
|
|
{
|
|
File.SetAttributes(temp.File("stored.json"), FileAttributes.Normal);
|
|
}
|
|
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A source that does not exist is reported with the Windows error for a missing file.</summary>
|
|
[Fact]
|
|
public void TryRenameOver_MissingSource_IsAnIoFailure()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
File.WriteAllText(temp.File("stored.json"), "old");
|
|
IOException missing = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("absent.tmp"), temp.File("stored.json")));
|
|
Assert.Equal(unchecked((int)0x80070002), missing.HResult);
|
|
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A destination that does not exist yet is simply created by the rename.</summary>
|
|
[Fact]
|
|
public void TryRenameOver_NoDestinationYet_RenamesTheSource()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
File.WriteAllText(temp.File("new.tmp"), "new");
|
|
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
|
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Only the errors a volume gives for an unimplemented rename mean "use another way"; every other error is a failure.</summary>
|
|
[Theory]
|
|
[InlineData(1, true)]
|
|
[InlineData(50, true)]
|
|
[InlineData(87, true)]
|
|
[InlineData(0, false)]
|
|
[InlineData(2, false)]
|
|
[InlineData(5, false)]
|
|
[InlineData(32, false)]
|
|
[InlineData(17, false)]
|
|
public void IsUnsupported_WindowsError_SeparatesAnUnimplementedRenameFromAFailure(int error, bool unsupported)
|
|
=> Assert.Equal(unsupported, FileSwap.IsUnsupported(error));
|
|
}
|