diff --git a/claude.md b/claude.md
index 86d8ca690..5369cebf7 100644
--- a/claude.md
+++ b/claude.md
@@ -687,7 +687,20 @@ the comment there about not caching "nothing staged" asks for.
the core's, and the question a locked file asks is the head's form (`LockedFilesHandler`).
`IssueLauncher.Declined` declines in the core, which has no toolkit to ask with, and the head
hands it `IssueQuestion`'s MessageBox first thing in Main. The core is signed and named in
- DiffEngine's `InternalsVisibleTo`, and names the head and the tests in its own
+ DiffEngine's `InternalsVisibleTo`, and names the head and both test projects in its own
+- The core's tests are `DiffEngineTray.Core.Tests` (net10.0), which runs in the unix CI job as
+ well as on Windows. `DiffEngineTray.Tests` keeps what needs the head: the menu, the forms, the
+ hot keys, `LockedFilesHandler`, `DiffToolLauncher` and `TrayViewerSyncTest`. The helpers both
+ use (`RecordingTracker`, `FakeViewer`, `FileLockUtils` and the rest) live with the core's tests
+ and are linked into the head's. What stays Windows only is marked `[RunOn(OS.Windows)]` with
+ the reason in its summary, and is of three kinds: `ProcessEx` and `FileLockKiller`, a lock held
+ by a Windows PowerShell process, and Windows' file semantics, under which a file open with no
+ sharing or marked read-only cannot be moved over or deleted. On Unix a rename or an unlink
+ takes no notice of either. Test data is written as the platform roots a path
+ (`OwnedInlineHostTest.Repo`): a Windows path read on Linux is one file name of the whole
+ string, so every entry had the same name, and a viewer at `C:\tools\DiffEngineViewer.exe` was
+ not the viewer. `DebugReportTests` writes `{Directory}\` for the separator on every platform,
+ so one snapshot holds for all of them
- `PiperServer` - TCP server (localhost, 3492) receiving move/delete payloads from DiffEngine.
Deliberately a second listener beside the viewer protocol, not debt: its format is frozen
(every stable DiffEngine embeds PiperClient, pinned in test projects while the tray updates
diff --git a/docs/tray.md b/docs/tray.md
index 9be642b8c..c51431a7c 100644
--- a/docs/tray.md
+++ b/docs/tray.md
@@ -202,7 +202,7 @@ The case it exists for is a test suite that needs the launch to happen but does
"ProcessId":1000
}
```
-snippet source | anchor
+snippet source | anchor
@@ -216,7 +216,7 @@ The case it exists for is a test suite that needs the launch to happen but does
"File":"theFilePath"
}
```
-snippet source | anchor
+snippet source | anchor
@@ -238,7 +238,7 @@ A move or a delete of a file that was [derived from a document](/docs/viewer.md#
"Source":"theSourceTempFilePath"
}
```
-snippet source | anchor
+snippet source | anchor
@@ -250,7 +250,7 @@ A move or a delete of a file that was [derived from a document](/docs/viewer.md#
"Source":"theSourceTempFilePath"
}
```
-snippet source | anchor
+snippet source | anchor
The tray tracks such a file as it tracks any other, so it is listed in the menu and taken by an accept-all. The property is only passed on to the viewer, which is what folds the file beneath its document. A tray older than the property ignores it.
diff --git a/src/DiffEngine.slnx b/src/DiffEngine.slnx
index 54bfc9baa..564463277 100644
--- a/src/DiffEngine.slnx
+++ b/src/DiffEngine.slnx
@@ -88,6 +88,10 @@
+
+
+
+
+
+ net10.0
+ $(NoWarn);CA1416;NU1902
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs b/src/DiffEngineTray.Core.Tests/DiffRunnerCanKillTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs
rename to src/DiffEngineTray.Core.Tests/DiffRunnerCanKillTest.cs
diff --git a/src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs b/src/DiffEngineTray.Core.Tests/DiffRunnerViewerFocusRaceTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs
rename to src/DiffEngineTray.Core.Tests/DiffRunnerViewerFocusRaceTest.cs
diff --git a/src/DiffEngineTray.Tests/FakeLauncher.cs b/src/DiffEngineTray.Core.Tests/FakeLauncher.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/FakeLauncher.cs
rename to src/DiffEngineTray.Core.Tests/FakeLauncher.cs
diff --git a/src/DiffEngineTray.Tests/FakeViewer.cs b/src/DiffEngineTray.Core.Tests/FakeViewer.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/FakeViewer.cs
rename to src/DiffEngineTray.Core.Tests/FakeViewer.cs
diff --git a/src/DiffEngineTray.Tests/FileComparerTests.cs b/src/DiffEngineTray.Core.Tests/FileComparerTests.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/FileComparerTests.cs
rename to src/DiffEngineTray.Core.Tests/FileComparerTests.cs
diff --git a/src/DiffEngineTray.Tests/FileExTest.cs b/src/DiffEngineTray.Core.Tests/FileExTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/FileExTest.cs
rename to src/DiffEngineTray.Core.Tests/FileExTest.cs
diff --git a/src/DiffEngineTray.Tests/FileLockKillerTest.cs b/src/DiffEngineTray.Core.Tests/FileLockKillerTest.cs
similarity index 95%
rename from src/DiffEngineTray.Tests/FileLockKillerTest.cs
rename to src/DiffEngineTray.Core.Tests/FileLockKillerTest.cs
index af20ac7ff..437238f6b 100644
--- a/src/DiffEngineTray.Tests/FileLockKillerTest.cs
+++ b/src/DiffEngineTray.Core.Tests/FileLockKillerTest.cs
@@ -1,3 +1,8 @@
+///
+/// Windows only: FileLockKiller asks the restart manager who holds a file, and the lock is held by
+/// a Windows PowerShell process. A Unix head that kills lockers will need a way of its own to ask.
+///
+[RunOn(TUnit.Core.Enums.OS.Windows)]
public class FileLockKillerTest
{
[Test]
diff --git a/src/DiffEngineTray.Tests/FileLockUtils.cs b/src/DiffEngineTray.Core.Tests/FileLockUtils.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/FileLockUtils.cs
rename to src/DiffEngineTray.Core.Tests/FileLockUtils.cs
diff --git a/src/DiffEngineTray.Core.Tests/GlobalUsings.cs b/src/DiffEngineTray.Core.Tests/GlobalUsings.cs
new file mode 100644
index 000000000..1a0a642a0
--- /dev/null
+++ b/src/DiffEngineTray.Core.Tests/GlobalUsings.cs
@@ -0,0 +1,13 @@
+global using System.Diagnostics;
+global using System.Net;
+global using System.Net.Sockets;
+global using System.Security.AccessControl;
+global using System.Security.Principal;
+global using EmptyFiles;
+
+[assembly: ParallelLimiter]
+
+public class SingleThreadedLimit : TUnit.Core.Interfaces.IParallelLimit
+{
+ public int Limit => 1;
+}
diff --git a/src/DiffEngineTray.Tests/HeldApply.cs b/src/DiffEngineTray.Core.Tests/HeldApply.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/HeldApply.cs
rename to src/DiffEngineTray.Core.Tests/HeldApply.cs
diff --git a/src/DiffEngineTray.Tests/IssueLauncherTests.cs b/src/DiffEngineTray.Core.Tests/IssueLauncherTests.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/IssueLauncherTests.cs
rename to src/DiffEngineTray.Core.Tests/IssueLauncherTests.cs
diff --git a/src/DiffEngineTray.Tests/LinkLauncherTests.cs b/src/DiffEngineTray.Core.Tests/LinkLauncherTests.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/LinkLauncherTests.cs
rename to src/DiffEngineTray.Core.Tests/LinkLauncherTests.cs
diff --git a/src/DiffEngineTray.Core.Tests/ModuleInitializer.cs b/src/DiffEngineTray.Core.Tests/ModuleInitializer.cs
new file mode 100644
index 000000000..44c77f06c
--- /dev/null
+++ b/src/DiffEngineTray.Core.Tests/ModuleInitializer.cs
@@ -0,0 +1,50 @@
+public static class ModuleInitializer
+{
+ [ModuleInitializer]
+ public static void Initialize()
+ {
+ DeclineToOpenAnIssue();
+ MachineSettings.Ignore();
+ KeepEnvironmentWritesInProcess();
+ PointAtAClosedPort();
+ }
+
+ ///
+ /// The tray follows an error it did not expect by offering to open an issue on GitHub, and a
+ /// yes opens a browser. A test that reaches the offer is declined here, with nobody asked, and
+ /// what it reached is kept for the tests that are about an error being reported.
+ ///
+ static void DeclineToOpenAnIssue() =>
+ IssueLauncher.Declined = text =>
+ {
+ IssuesAsked.Enqueue(text);
+ return true;
+ };
+
+ ///
+ /// Every question has declined, oldest first.
+ ///
+ internal static ConcurrentQueue IssuesAsked { get; } = new();
+
+ ///
+ /// Tests must not write the user environment of the machine running them. The test projects
+ /// run as parallel processes over the one registry key, so a capture in one and a restore in
+ /// the other race, and the value that loses is gone.
+ ///
+ static void KeepEnvironmentWritesInProcess() =>
+ EnvironmentHelper.Set = EnvironmentHelper.SetProcessOnly;
+
+ ///
+ /// The tray asks the viewer for pending snapshots. Without this a DiffEngineViewer running on
+ /// the developer's machine would answer, and tests that expect nothing pending would see its
+ /// queue. FakeViewer overrides this for the tests that do want a viewer.
+ ///
+ static void PointAtAClosedPort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint) listener.LocalEndpoint).Port;
+ listener.Stop();
+ Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString());
+ }
+}
diff --git a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs b/src/DiffEngineTray.Core.Tests/OwnedInlineHostTest.cs
similarity index 95%
rename from src/DiffEngineTray.Tests/OwnedInlineHostTest.cs
rename to src/DiffEngineTray.Core.Tests/OwnedInlineHostTest.cs
index f10fae37e..bf1bb5df9 100644
--- a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs
+++ b/src/DiffEngineTray.Core.Tests/OwnedInlineHostTest.cs
@@ -6,6 +6,16 @@
///
public class OwnedInlineHostTest
{
+ ///
+ /// A source file in a repository that need not exist, rooted as this platform roots a path.
+ /// The queue names an entry by its source's file name, and a Windows path read on Linux is one
+ /// file name of the whole string, so every entry was named c:\repo\SampleTests.cs there.
+ ///
+ static string Repo(string file) =>
+ Path.Combine(repo, file);
+
+ static readonly string repo = OperatingSystem.IsWindows() ? @"c:\repo" : "/repo";
+
sealed class Owner : IDisposable
{
public Owner(Func? applier = null)
@@ -32,12 +42,12 @@ public ViewerResponse Send(ViewerMessage message, TimeSpan? wait = null)
}
public ViewerResponse Queue(
- string source = @"c:\repo\SampleTests.cs",
+ string? source = null,
int line = 42,
string content = "new",
string? framework = null,
string? testName = null) =>
- Send(new(ViewerVerb.Inline, Body: InlinePatchFile.Build(new(source, line, "\"old\"", content)
+ Send(new(ViewerVerb.Inline, Body: InlinePatchFile.Build(new(source ?? Repo("SampleTests.cs"), line, "\"old\"", content)
{
Framework = framework,
TestName = testName
@@ -110,7 +120,7 @@ public async Task APatchArrivingOnceTheSessionIsEndingIsRefused()
owner.Queue();
owner.Host.SessionEnding();
- var response = owner.Queue(@"c:\repo\OtherTests.cs", 7);
+ var response = owner.Queue(Repo("OtherTests.cs"), 7);
await Assert.That(response.Ok).IsFalse();
await Assert.That(response.Message).IsEqualTo("This tray is going with the session and can take nothing more.");
@@ -139,7 +149,7 @@ public async Task ASecondPatchDoesNotStartASecondViewer()
using var owner = new Owner();
owner.Queue();
- owner.Queue(@"c:\repo\OtherTests.cs", 7);
+ owner.Queue(Repo("OtherTests.cs"), 7);
await Assert.That(owner.Launcher.Launches).IsEqualTo(1);
await Assert.That(owner.Host.List().Count).IsEqualTo(2);
@@ -152,7 +162,7 @@ public async Task AViewerThatWentAwayIsStartedAgain()
owner.Queue();
owner.Launcher.Running = false;
- owner.Queue(@"c:\repo\OtherTests.cs", 7);
+ owner.Queue(Repo("OtherTests.cs"), 7);
await Assert.That(owner.Launcher.Launches).IsEqualTo(2);
}
@@ -176,7 +186,7 @@ public async Task APassingRerunSettlesTheEntry()
using var owner = new Owner();
owner.Queue();
- var response = owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 42)));
+ var response = owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 42)));
await Assert.That(response.Ok).IsTrue();
await Assert.That(owner.Host.List()).IsEmpty();
@@ -370,7 +380,7 @@ public async Task DiscardAllEmptiesTheQueue()
{
using var owner = new Owner();
owner.Queue();
- owner.Queue(@"c:\repo\OtherTests.cs", 7);
+ owner.Queue(Repo("OtherTests.cs"), 7);
owner.Host.DiscardAll(out _);
@@ -549,7 +559,7 @@ public async Task AConflictedAcceptIsRefusedOverTheWire()
owner.Queue(content: "eight", framework: "net8.0");
owner.Queue(content: "nine", framework: "net9.0");
- var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(@"c:\repo\SampleTests.cs", 42)));
+ var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(Repo("SampleTests.cs"), 42)));
await Assert.That(response.Ok).IsFalse();
await Assert.That(response.Message).IsEqualTo("Conflicting snapshots (net8.0 / net9.0), resolve in the viewer");
@@ -572,7 +582,7 @@ public async Task AnAcceptWithAnOriginBodyAppliesThatVariant()
owner.Queue(content: "eight", framework: "net8.0");
owner.Queue(content: "nine", framework: "net9.0");
- var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(@"c:\repo\SampleTests.cs", 42), "net9.0"));
+ var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(Repo("SampleTests.cs"), 42), "net9.0"));
await Assert.That(response.Ok).IsTrue();
await Assert.That(applied.Single().NewContent).IsEqualTo("nine");
@@ -583,7 +593,7 @@ public async Task AnAcceptWithAnOriginBodyAppliesThatVariant()
public async Task AcceptAllOverTheWireSkipsConflicts()
{
using var owner = new Owner(_ => InlineApplyResult.Applied);
- owner.Queue(@"c:\repo\OtherTests.cs", 7);
+ owner.Queue(Repo("OtherTests.cs"), 7);
owner.Queue(content: "eight", framework: "net8.0");
owner.Queue(content: "nine", framework: "net9.0");
@@ -600,10 +610,10 @@ public async Task ASettleWithABodyRemovesOneVariant()
owner.Queue(content: "eight", framework: "net8.0");
owner.Queue(content: "nine", framework: "net9.0");
- owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 42), "net8.0"));
+ owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 42), "net8.0"));
await Assert.That(owner.Host.List().Single().Status).IsNull();
- owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 42), "net9.0"));
+ owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 42), "net9.0"));
await Assert.That(owner.Host.List()).IsEmpty();
}
@@ -814,7 +824,7 @@ public async Task AListingDuringAnAcceptAllSaysHowFarItHasGot()
{
using var held = new HeldApply(2);
using var owner = new Owner(held.Apply);
- owner.Queue(@"c:\repo\OtherTests.cs", 7);
+ owner.Queue(Repo("OtherTests.cs"), 7);
owner.Queue();
var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)));
@@ -842,9 +852,9 @@ public async Task TheSnapshotsOfOneFileAreCompletedTogether()
{
using var held = new HeldApply(2);
using var owner = new Owner(held.Apply);
- owner.Queue(@"c:\repo\SampleTests.cs", 7);
- owner.Queue(@"c:\repo\SampleTests.cs", 9);
- owner.Queue(@"c:\repo\OtherTests.cs", 3);
+ owner.Queue(Repo("SampleTests.cs"), 7);
+ owner.Queue(Repo("SampleTests.cs"), 9);
+ owner.Queue(Repo("OtherTests.cs"), 3);
var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)));
held.WaitUntilHeld();
@@ -871,15 +881,15 @@ public async Task EachSnapshotOfAFileHasItsOwnOutcome()
_ => _.LineHint == 9
? InlineApplyResult.Failed("the file is held")
: InlineApplyResult.Applied);
- owner.Queue(@"c:\repo\SampleTests.cs", 7);
- owner.Queue(@"c:\repo\SampleTests.cs", 9);
- owner.Queue(@"c:\repo\SampleTests.cs", 11);
+ owner.Queue(Repo("SampleTests.cs"), 7);
+ owner.Queue(Repo("SampleTests.cs"), 9);
+ owner.Queue(Repo("SampleTests.cs"), 11);
var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30));
await Assert.That(response.Message).IsEqualTo("Accepted 2, 1 failed. the file is held");
var left = owner.Send(new(ViewerVerb.ListFull)).Items.Single();
- await Assert.That(left.Key).IsEqualTo(InlineKey.For(@"c:\repo\SampleTests.cs", 9));
+ await Assert.That(left.Key).IsEqualTo(InlineKey.For(Repo("SampleTests.cs"), 9));
}
///
@@ -898,24 +908,24 @@ public async Task WhatIsLeftOfAFileGoesToWhereItsCallSitesAre()
9 => InlineApplyResult.Failed("the file is held"),
_ => InlineApplyResult.Applied
});
- owner.Queue(@"c:\repo\SampleTests.cs", 7);
- owner.Queue(@"c:\repo\SampleTests.cs", 9);
- owner.Queue(@"c:\repo\OtherTests.cs", 11);
+ owner.Queue(Repo("SampleTests.cs"), 7);
+ owner.Queue(Repo("SampleTests.cs"), 9);
+ owner.Queue(Repo("OtherTests.cs"), 11);
var accepted = owner.Host.Accept(owner.Host.List()[0], out _);
await Assert.That(accepted).IsEqualTo(AcceptOutcome.Applied);
await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Items.Select(_ => _.Key))
- .IsEquivalentTo([InlineKey.For(@"c:\repo\SampleTests.cs", 13), InlineKey.For(@"c:\repo\OtherTests.cs", 11)]);
+ .IsEquivalentTo([InlineKey.For(Repo("SampleTests.cs"), 13), InlineKey.For(Repo("OtherTests.cs"), 11)]);
- owner.Queue(@"c:\repo\SampleTests.cs", 7);
- owner.Queue(@"c:\repo\SampleTests.cs", 9);
+ owner.Queue(Repo("SampleTests.cs"), 7);
+ owner.Queue(Repo("SampleTests.cs"), 9);
var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30));
// The one at 13 is applied, and the one at 9 is not and is four lines down
await Assert.That(response.Message).IsEqualTo("Accepted 3, 1 failed. the file is held");
var left = owner.Send(new(ViewerVerb.ListFull)).Items.Single();
- await Assert.That(left.Key).IsEqualTo(InlineKey.For(@"c:\repo\SampleTests.cs", 13));
+ await Assert.That(left.Key).IsEqualTo(InlineKey.For(Repo("SampleTests.cs"), 13));
await Assert.That(left.Status).IsEqualTo("the file is held");
}
@@ -1083,12 +1093,12 @@ public async Task AnEntryDiscardedDuringAnAcceptAllIsNotWritten()
return held.Apply(patch);
});
- owner.Queue(@"c:\repo\SampleTests.cs", 1);
- owner.Queue(@"c:\repo\OtherTests.cs", 2);
+ owner.Queue(Repo("SampleTests.cs"), 1);
+ owner.Queue(Repo("OtherTests.cs"), 2);
var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)));
held.WaitUntilHeld();
- owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\OtherTests.cs", 2)));
+ owner.Send(new(ViewerVerb.Discard, InlineKey.For(Repo("OtherTests.cs"), 2)));
held.Release();
await accepting;
@@ -1123,7 +1133,7 @@ public async Task AnEntryDiscardedWhileItsOwnFileIsWaitedForIsNotWritten()
var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)));
held.WaitUntilHeld();
- owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\SampleTests.cs", 2)));
+ owner.Send(new(ViewerVerb.Discard, InlineKey.For(Repo("SampleTests.cs"), 2)));
held.Release();
var response = await accepting;
@@ -1158,7 +1168,7 @@ public async Task AnEntrySettledWhileItsOwnFileIsWaitedForIsNotWritten()
var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)));
held.WaitUntilHeld();
- owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 2)));
+ owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 2)));
held.Release();
var response = await accepting;
diff --git a/src/DiffEngineTray.Tests/PiperTest.Delete.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.Delete.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.Delete.verified.txt
rename to src/DiffEngineTray.Core.Tests/PiperTest.Delete.verified.txt
diff --git a/src/DiffEngineTray.Tests/PiperTest.DeleteJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.DeleteJson.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.DeleteJson.verified.txt
rename to src/DiffEngineTray.Core.Tests/PiperTest.DeleteJson.verified.txt
diff --git a/src/DiffEngineTray.Tests/PiperTest.DeleteWithSourceJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.DeleteWithSourceJson.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.DeleteWithSourceJson.verified.txt
rename to src/DiffEngineTray.Core.Tests/PiperTest.DeleteWithSourceJson.verified.txt
diff --git a/src/DiffEngineTray.Tests/PiperTest.Move.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.Move.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.Move.verified.txt
rename to src/DiffEngineTray.Core.Tests/PiperTest.Move.verified.txt
diff --git a/src/DiffEngineTray.Tests/PiperTest.MoveJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.MoveJson.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.MoveJson.verified.txt
rename to src/DiffEngineTray.Core.Tests/PiperTest.MoveJson.verified.txt
diff --git a/src/DiffEngineTray.Tests/PiperTest.MoveWithSourceJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.MoveWithSourceJson.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.MoveWithSourceJson.verified.txt
rename to src/DiffEngineTray.Core.Tests/PiperTest.MoveWithSourceJson.verified.txt
diff --git a/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.SendOnly.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt
rename to src/DiffEngineTray.Core.Tests/PiperTest.SendOnly.verified.txt
diff --git a/src/DiffEngineTray.Tests/PiperTest.cs b/src/DiffEngineTray.Core.Tests/PiperTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/PiperTest.cs
rename to src/DiffEngineTray.Core.Tests/PiperTest.cs
diff --git a/src/DiffEngineTray.Tests/ProcessExHandleTests.cs b/src/DiffEngineTray.Core.Tests/ProcessExHandleTests.cs
similarity index 93%
rename from src/DiffEngineTray.Tests/ProcessExHandleTests.cs
rename to src/DiffEngineTray.Core.Tests/ProcessExHandleTests.cs
index 89e5f65f4..a2a1a83eb 100644
--- a/src/DiffEngineTray.Tests/ProcessExHandleTests.cs
+++ b/src/DiffEngineTray.Core.Tests/ProcessExHandleTests.cs
@@ -11,8 +11,12 @@
/// ended. That is the same thing as the id being held, since Windows will not hand out an id while
/// a handle to it exists.
///
+///
+/// Windows only, as ProcessEx is: the handle is kernel32's.
+///
///
[NotInParallel]
+[RunOn(TUnit.Core.Enums.OS.Windows)]
public class ProcessExHandleTests
{
[Test]
diff --git a/src/DiffEngineTray.Tests/ProcessExTest.cs b/src/DiffEngineTray.Core.Tests/ProcessExTest.cs
similarity index 77%
rename from src/DiffEngineTray.Tests/ProcessExTest.cs
rename to src/DiffEngineTray.Core.Tests/ProcessExTest.cs
index 6ff768de5..b5387da25 100644
--- a/src/DiffEngineTray.Tests/ProcessExTest.cs
+++ b/src/DiffEngineTray.Core.Tests/ProcessExTest.cs
@@ -1,6 +1,10 @@
public class ProcessExTest
{
+ ///
+ /// Windows only, as TryGet is: it opens the process through kernel32.
+ ///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task TryGet()
{
using var current = Process.GetCurrentProcess();
@@ -8,7 +12,9 @@ public async Task TryGet()
found!.Dispose();
}
+ ///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task TryGetMissing()
{
await Assert.That(ProcessEx.TryGet(40000, out var found)).IsFalse();
diff --git a/src/DiffEngineTray.Tests/RecordingTracker.cs b/src/DiffEngineTray.Core.Tests/RecordingTracker.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/RecordingTracker.cs
rename to src/DiffEngineTray.Core.Tests/RecordingTracker.cs
diff --git a/src/DiffEngineTray.Tests/SerializerTests.cs b/src/DiffEngineTray.Core.Tests/SerializerTests.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/SerializerTests.cs
rename to src/DiffEngineTray.Core.Tests/SerializerTests.cs
diff --git a/src/DiffEngineTray.Tests/SettingsHelperTests.ReadWrite.verified.txt b/src/DiffEngineTray.Core.Tests/SettingsHelperTests.ReadWrite.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/SettingsHelperTests.ReadWrite.verified.txt
rename to src/DiffEngineTray.Core.Tests/SettingsHelperTests.ReadWrite.verified.txt
diff --git a/src/DiffEngineTray.Tests/SettingsHelperTests.cs b/src/DiffEngineTray.Core.Tests/SettingsHelperTests.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/SettingsHelperTests.cs
rename to src/DiffEngineTray.Core.Tests/SettingsHelperTests.cs
diff --git a/src/DiffEngineTray.Tests/SettingsWriteTests.cs b/src/DiffEngineTray.Core.Tests/SettingsWriteTests.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/SettingsWriteTests.cs
rename to src/DiffEngineTray.Core.Tests/SettingsWriteTests.cs
diff --git a/src/DiffEngineTray.Tests/SilentOwner.cs b/src/DiffEngineTray.Core.Tests/SilentOwner.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/SilentOwner.cs
rename to src/DiffEngineTray.Core.Tests/SilentOwner.cs
diff --git a/src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.Find.verified.txt b/src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.Find.verified.txt
similarity index 100%
rename from src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.Find.verified.txt
rename to src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.Find.verified.txt
diff --git a/src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.cs b/src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.cs
rename to src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.cs
diff --git a/src/DiffEngineTray.Tests/StubInlineHost.cs b/src/DiffEngineTray.Core.Tests/StubInlineHost.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/StubInlineHost.cs
rename to src/DiffEngineTray.Core.Tests/StubInlineHost.cs
diff --git a/src/DiffEngineTray.Tests/TrackedFilesExtensions.cs b/src/DiffEngineTray.Core.Tests/TrackedFilesExtensions.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/TrackedFilesExtensions.cs
rename to src/DiffEngineTray.Core.Tests/TrackedFilesExtensions.cs
diff --git a/src/DiffEngineTray.Tests/TrackerAcceptOpenTest.cs b/src/DiffEngineTray.Core.Tests/TrackerAcceptOpenTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/TrackerAcceptOpenTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerAcceptOpenTest.cs
diff --git a/src/DiffEngineTray.Tests/TrackerAcceptRetryTest.cs b/src/DiffEngineTray.Core.Tests/TrackerAcceptRetryTest.cs
similarity index 90%
rename from src/DiffEngineTray.Tests/TrackerAcceptRetryTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerAcceptRetryTest.cs
index df1ee93a7..534e50d20 100644
--- a/src/DiffEngineTray.Tests/TrackerAcceptRetryTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerAcceptRetryTest.cs
@@ -28,7 +28,12 @@ public async Task TransientLock_RetriesUntilAccepted()
await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("new");
}
+ ///
+ /// Windows only: a file open with no sharing cannot be moved over there. On Unix the lock is
+ /// advisory, and a rename takes no notice of it.
+ ///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task UnresolvedLock_KeepsMovePendingAndNotifies()
{
TrackedMove? failed = null;
diff --git a/src/DiffEngineTray.Tests/TrackerClearTest.cs b/src/DiffEngineTray.Core.Tests/TrackerClearTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/TrackerClearTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerClearTest.cs
diff --git a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs b/src/DiffEngineTray.Core.Tests/TrackerDeleteTest.cs
similarity index 95%
rename from src/DiffEngineTray.Tests/TrackerDeleteTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerDeleteTest.cs
index 038f90ded..a6530b8f5 100644
--- a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerDeleteTest.cs
@@ -85,8 +85,13 @@ public async Task AcceptSingle_NotEmpty()
/// read-only or open verified file threw out of the click handler - onto the UI thread, where
/// nothing hooks Application.ThreadException - and the entry had already been untracked, so
/// the pending delete went with it.
+ ///
+ /// Windows only: a file open with no sharing cannot be deleted there. On Unix the lock is
+ /// advisory, and an unlink takes no notice of it.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AcceptLeavesAnUndeletableFileTracked()
{
await using var tracker = new RecordingTracker();
@@ -104,8 +109,12 @@ public async Task AcceptLeavesAnUndeletableFileTracked()
///
/// And one bad delete does not stop the rest of Accept all. Unguarded, the throw skipped the
/// remaining deletes, every pending move, and the snapshots.
+ ///
+ /// Windows only, for the reason above.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AcceptAllContinuesPastAnUndeletableFile()
{
await using var tracker = new RecordingTracker();
diff --git a/src/DiffEngineTray.Tests/TrackerLockedMoveTest.cs b/src/DiffEngineTray.Core.Tests/TrackerLockedMoveTest.cs
similarity index 95%
rename from src/DiffEngineTray.Tests/TrackerLockedMoveTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerLockedMoveTest.cs
index 7ffbdcdb9..32988fed9 100644
--- a/src/DiffEngineTray.Tests/TrackerLockedMoveTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerLockedMoveTest.cs
@@ -1,3 +1,8 @@
+///
+/// Windows only: a file another process has open cannot be moved over there, which is the lock
+/// these are about, and the restart manager is what says who holds it. On Unix the move succeeds.
+///
+[RunOn(TUnit.Core.Enums.OS.Windows)]
public class TrackerLockedMoveTest :
IDisposable
{
diff --git a/src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs b/src/DiffEngineTray.Core.Tests/TrackerMoveOntoDeleteTest.cs
similarity index 97%
rename from src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerMoveOntoDeleteTest.cs
index 971acefdd..8acb97ba6 100644
--- a/src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerMoveOntoDeleteTest.cs
@@ -251,8 +251,13 @@ public async Task ADeleteWaitingOnAMoveIsCarriedOutOnceTheMoveIsDiscarded()
/// delete on its target was held by nothing meanwhile: not by a pending move, which had left,
/// and not by a write, which had yet to be marked. Asked from where a refused move is reported,
/// which is inside the accept, before the move is put back.
+ ///
+ /// Windows only: a read-only file cannot be moved over there. On Unix a rename needs only the
+ /// directory to be writable.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task ADeleteIsHeldWhileTheMoveOntoItsFileIsBeingAccepted()
{
RecordingTracker? tracker = null;
@@ -352,8 +357,13 @@ public async Task TheWireSweepHoldsADeleteOfTheFileItsMoveJustWrote()
///
/// A move that could not be carried out is still going to write the file, so the delete waits
/// for it as well. Deleting here left the old snapshot gone and the new one not yet in place.
+ ///
+ /// Windows only: a file open with no sharing cannot be moved there. On Unix the lock is
+ /// advisory, and a rename takes no notice of it.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task TheWireSweepHoldsADeleteOfAFileAMoveStillPendingTargets()
{
await using var tracker = new RecordingTracker();
diff --git a/src/DiffEngineTray.Tests/TrackerMoveTest.cs b/src/DiffEngineTray.Core.Tests/TrackerMoveTest.cs
similarity index 66%
rename from src/DiffEngineTray.Tests/TrackerMoveTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerMoveTest.cs
index e85a4d862..8f7821a54 100644
--- a/src/DiffEngineTray.Tests/TrackerMoveTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerMoveTest.cs
@@ -20,7 +20,12 @@ public async Task AddMultiple()
await Assert.That(tracker.TrackingAny).IsTrue();
}
+ ///
+ /// Windows only: the tool is a Windows PowerShell process, and a move's process is tracked
+ /// only where its image and command line can be read.
+ ///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AddSame()
{
await using var tracker = new RecordingTracker();
@@ -112,54 +117,4 @@ public void Dispose()
string file1 = Path.GetTempFileName();
string file2 = Path.GetTempFileName();
string file3 = Path.GetTempFileName();
-
- ///
- /// DiffRunner resends a move for the same received file on every re-run, with a process
- /// id each time. The update factory disposes the Process of the move it replaces and leaves it
- /// there, so a menu opened before that re-run holds a move whose process is disposed, and its
- /// "Open diff tool" item throws on the UI thread.
- ///
- [Test]
- public async Task OpenDiffToolFromAMenuBuiltBeforeTheMoveWasUpdated()
- {
- await using var tracker = new RecordingTracker();
- var temp = file1;
- var target = file2;
- var toolLock = file3;
- // Stands in for an auto refresh diff tool that is still open, which is what DiffRunner
- // resends the id of
- var tool = FileLockUtils.StartFileLockProcess(toolLock, shows: temp);
- try
- {
- // Nothing at this path exists, so if the launcher gets past the process it starts
- // nothing. Named as the tool is, since a process running something else is not tracked
- var exe = Path.Combine(
- Path.GetTempPath(),
- $"ReviewReproNoSuchTool_{Guid.NewGuid()}",
- Path.GetFileName(tool.MainModule!.FileName));
-
- // What the menu captured when it opened
- var shown = tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id);
- await Assert.That(shown.Process).IsNotNull();
- // The re-run's move, landing while that menu is open
- tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id);
-
- Exception? thrown = null;
- try
- {
- // The "Open diff tool" item's click handler
- DiffToolLauncher.Launch(shown);
- }
- catch (Exception exception)
- {
- thrown = exception;
- }
-
- await Assert.That(thrown).IsNull();
- }
- finally
- {
- FileLockUtils.Cleanup(tool);
- }
- }
}
diff --git a/src/DiffEngineTray.Tests/TrackerProcessImageTest.cs b/src/DiffEngineTray.Core.Tests/TrackerProcessImageTest.cs
similarity index 98%
rename from src/DiffEngineTray.Tests/TrackerProcessImageTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerProcessImageTest.cs
index 0ddff2910..c2685e87b 100644
--- a/src/DiffEngineTray.Tests/TrackerProcessImageTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerProcessImageTest.cs
@@ -8,8 +8,13 @@
/// Every process here is started by the test, and stands in both for a diff tool and for the
/// stranger that got a diff tool's id.
///
+///
+/// Windows only: an image and a command line are read through kernel32 and ntdll, and with
+/// neither a move is tracked with no process at all.
+///
///
[NotInParallel]
+[RunOn(TUnit.Core.Enums.OS.Windows)]
public class TrackerProcessImageTest :
IDisposable
{
diff --git a/src/DiffEngineTray.Tests/TrackerProcessReleaseTest.cs b/src/DiffEngineTray.Core.Tests/TrackerProcessReleaseTest.cs
similarity index 96%
rename from src/DiffEngineTray.Tests/TrackerProcessReleaseTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerProcessReleaseTest.cs
index 3faa8be9b..c2443510c 100644
--- a/src/DiffEngineTray.Tests/TrackerProcessReleaseTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerProcessReleaseTest.cs
@@ -7,8 +7,12 @@
/// Every process here is one the test started, and none of them may be ended by the tray: the
/// move says it cannot be killed.
///
+///
+/// Windows only: the tool is a Windows PowerShell process, and the handle is kernel32's.
+///
///
[NotInParallel]
+[RunOn(TUnit.Core.Enums.OS.Windows)]
public class TrackerProcessReleaseTest :
IDisposable
{
diff --git a/src/DiffEngineTray.Tests/TrackerScanTest.cs b/src/DiffEngineTray.Core.Tests/TrackerScanTest.cs
similarity index 98%
rename from src/DiffEngineTray.Tests/TrackerScanTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerScanTest.cs
index 1cbd823e6..3ed7f9f1b 100644
--- a/src/DiffEngineTray.Tests/TrackerScanTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerScanTest.cs
@@ -70,8 +70,12 @@ public async Task WhatAMoveWasFoundToBeIsForgottenOnceItHasLeft()
/// A file that may not be read is no more a failure of the scan than one that is locked: the
/// pair cannot be compared this round. Only the locked one was caught, so this one failed the
/// whole scan, every two seconds, for as long as the move stayed pending.
+ ///
+ /// Windows only: the read is denied by an access control entry.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task ATargetThatMayNotBeReadIsPassedOver()
{
await using var tracker = new RecordingTracker();
diff --git a/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs b/src/DiffEngineTray.Core.Tests/TrackerSnapshotTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/TrackerSnapshotTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerSnapshotTest.cs
diff --git a/src/DiffEngineTray.Tests/TrackerSourceTest.cs b/src/DiffEngineTray.Core.Tests/TrackerSourceTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/TrackerSourceTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerSourceTest.cs
diff --git a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs b/src/DiffEngineTray.Core.Tests/TrackerTrackedFilesTest.cs
similarity index 89%
rename from src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerTrackedFilesTest.cs
index edcb9ed66..7d77fac80 100644
--- a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerTrackedFilesTest.cs
@@ -79,8 +79,13 @@ public async Task AcceptingATrackedMoveMovesTheFile()
///
/// Wire accepts run on a listener thread with nobody at a dialog, so a locked move is refused
/// with a pointer at the tray menu and the resolver is never consulted.
+ ///
+ /// Windows only: a file open with no sharing cannot be moved over there. On Unix the lock is
+ /// advisory, and a rename takes no notice of it.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task ALockedMoveIsRefusedWithoutPrompting()
{
await using var tracker = new RecordingTracker(
@@ -280,8 +285,13 @@ public async Task AMoveArrivingAgainOverTheViewerPortKeepsItsTool()
///
/// The same for any other tool: what the sender said about it, and the process it started,
/// are still the tool showing the pair.
+ ///
+ /// Windows only: the tool is a Windows PowerShell process, and a move's process is tracked
+ /// only where its image and command line can be read.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AMoveArrivingAgainOverTheViewerPortKeepsAnotherToolAndItsProcess()
{
await using var tracker = new RecordingTracker();
@@ -371,45 +381,4 @@ public TrackerTrackedFilesTest()
temp = Path.Combine(tempDirectory, "Sample.Test.received.txt");
target = Path.Combine(Path.GetTempPath(), $"TrackedFilesTest_{Guid.NewGuid():N}.verified.txt");
}
-
- ///
- /// With "Always kill locking processes" on, the menu accepts a locked move by killing
- /// the locker without asking. The same accept arriving from the viewer is refused as locked,
- /// because the preference lives inside the resolver and a wire accept never consults it.
- ///
- [Test]
- public async Task AlwaysKillAppliesToAnAcceptArrivingOverTheSocket()
- {
- var previous = LockedFilesPolicy.AlwaysKill;
- // The stored preference, which Program loads into this at startup
- LockedFilesPolicy.AlwaysKill = true;
- try
- {
- await File.WriteAllTextAsync(temp, "new");
- await File.WriteAllTextAsync(target, "old");
- // The resolver Program passes
- await using var tracker = new RecordingTracker(LockedFilesHandler.Resolve);
- var locker = FileLockUtils.StartFileLockProcess(target);
- try
- {
- tracker.AddMove(temp, target, "theExe", "theArguments", false, null);
-
- // What OwnedInlineHost does with an accept sent by a viewer displaying this queue
- var (ok, message) = ((ITrackedFiles) tracker).Accept(TrackedKeys.ForMove(temp));
-
- await Assert.That(message).IsNotEqualTo($"Files for '{Path.GetFileNameWithoutExtension(target)}' are locked. Accept from the tray menu to resolve.");
- await Assert.That(ok).IsTrue();
- }
- finally
- {
- FileLockUtils.Cleanup(locker);
- }
-
- await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("new");
- }
- finally
- {
- LockedFilesPolicy.AlwaysKill = previous;
- }
- }
}
diff --git a/src/DiffEngineTray.Tests/TrackerVersionTest.cs b/src/DiffEngineTray.Core.Tests/TrackerVersionTest.cs
similarity index 89%
rename from src/DiffEngineTray.Tests/TrackerVersionTest.cs
rename to src/DiffEngineTray.Core.Tests/TrackerVersionTest.cs
index f9981b847..0d2675753 100644
--- a/src/DiffEngineTray.Tests/TrackerVersionTest.cs
+++ b/src/DiffEngineTray.Core.Tests/TrackerVersionTest.cs
@@ -14,7 +14,12 @@
public class TrackerVersionTest :
IDisposable
{
+ ///
+ /// Windows only: a read-only file cannot be moved over there. On Unix a rename needs only the
+ /// directory to be writable.
+ ///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AMoveTakenOutAndPutBackIsAChange()
{
ITrackedFiles tracked = null!;
@@ -39,8 +44,13 @@ public async Task AMoveTakenOutAndPutBackIsAChange()
///
/// The same gap for a delete, which is untracked while its file is deleted and tracked again
/// when that fails.
+ ///
+ /// Windows only: a file open with no sharing cannot be deleted there. On Unix the lock is
+ /// advisory, and an unlink takes no notice of it.
+ ///
///
[Test]
+ [RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task ADeleteTakenOutAndPutBackIsAChange()
{
await using var tracker = new RecordingTracker();
diff --git a/src/DiffEngineTray.Tests/TrayVersionFileTest.cs b/src/DiffEngineTray.Core.Tests/TrayVersionFileTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/TrayVersionFileTest.cs
rename to src/DiffEngineTray.Core.Tests/TrayVersionFileTest.cs
diff --git a/src/DiffEngineTray.Tests/VersionReaderTest.cs b/src/DiffEngineTray.Core.Tests/VersionReaderTest.cs
similarity index 100%
rename from src/DiffEngineTray.Tests/VersionReaderTest.cs
rename to src/DiffEngineTray.Core.Tests/VersionReaderTest.cs
diff --git a/src/DiffEngineTray.Core/InternalsVisibleTo.cs b/src/DiffEngineTray.Core/InternalsVisibleTo.cs
index e657259a9..517cd273b 100644
--- a/src/DiffEngineTray.Core/InternalsVisibleTo.cs
+++ b/src/DiffEngineTray.Core/InternalsVisibleTo.cs
@@ -1,2 +1,3 @@
[assembly: InternalsVisibleTo("DiffEngineTray, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")]
[assembly: InternalsVisibleTo("DiffEngineTray.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")]
+[assembly: InternalsVisibleTo("DiffEngineTray.Core.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")]
diff --git a/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj b/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj
index b200989ad..b9edb65fe 100644
--- a/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj
+++ b/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj
@@ -6,6 +6,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/DiffEngineTray.Tests/DiffToolLauncherTest.cs b/src/DiffEngineTray.Tests/DiffToolLauncherTest.cs
new file mode 100644
index 000000000..ffe3ad2d7
--- /dev/null
+++ b/src/DiffEngineTray.Tests/DiffToolLauncherTest.cs
@@ -0,0 +1,68 @@
+///
+/// The menu's "Open diff tool", which is the head's, over a tracked move: the tracker's own tests
+/// are the core's, and run on every platform.
+///
+public class DiffToolLauncherTest :
+ IDisposable
+{
+ string file1 = Path.GetTempFileName();
+ string file2 = Path.GetTempFileName();
+ string file3 = Path.GetTempFileName();
+
+ public void Dispose()
+ {
+ File.Delete(file1);
+ File.Delete(file2);
+ File.Delete(file3);
+ }
+
+ ///
+ /// DiffRunner resends a move for the same received file on every re-run, with a process
+ /// id each time. The update factory disposes the Process of the move it replaces and leaves it
+ /// there, so a menu opened before that re-run holds a move whose process is disposed, and its
+ /// "Open diff tool" item throws on the UI thread.
+ ///
+ [Test]
+ public async Task OpenDiffToolFromAMenuBuiltBeforeTheMoveWasUpdated()
+ {
+ await using var tracker = new RecordingTracker();
+ var temp = file1;
+ var target = file2;
+ var toolLock = file3;
+ // Stands in for an auto refresh diff tool that is still open, which is what DiffRunner
+ // resends the id of
+ var tool = FileLockUtils.StartFileLockProcess(toolLock, shows: temp);
+ try
+ {
+ // Nothing at this path exists, so if the launcher gets past the process it starts
+ // nothing. Named as the tool is, since a process running something else is not tracked
+ var exe = Path.Combine(
+ Path.GetTempPath(),
+ $"ReviewReproNoSuchTool_{Guid.NewGuid()}",
+ Path.GetFileName(tool.MainModule!.FileName));
+
+ // What the menu captured when it opened
+ var shown = tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id);
+ await Assert.That(shown.Process).IsNotNull();
+ // The re-run's move, landing while that menu is open
+ tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id);
+
+ Exception? thrown = null;
+ try
+ {
+ // The "Open diff tool" item's click handler
+ DiffToolLauncher.Launch(shown);
+ }
+ catch (Exception exception)
+ {
+ thrown = exception;
+ }
+
+ await Assert.That(thrown).IsNull();
+ }
+ finally
+ {
+ FileLockUtils.Cleanup(tool);
+ }
+ }
+}
diff --git a/src/DiffEngineTray.Tests/LockedFilesHandlerTest.cs b/src/DiffEngineTray.Tests/LockedFilesHandlerTest.cs
new file mode 100644
index 000000000..0004da9f6
--- /dev/null
+++ b/src/DiffEngineTray.Tests/LockedFilesHandlerTest.cs
@@ -0,0 +1,77 @@
+///
+/// The tracker with the resolver the Windows head hands it, which is the head's: the tracker's
+/// own tests are the core's, and run on every platform.
+///
+public class LockedFilesHandlerTest :
+ IDisposable
+{
+ string tempDirectory;
+ string temp;
+ string target;
+
+ public LockedFilesHandlerTest()
+ {
+ // The move's temp sits in its own directory, the way DiffEngine stages received files,
+ // because accepting a move deletes that directory.
+ tempDirectory = Path.Combine(Path.GetTempPath(), $"TrackedFilesTest_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDirectory);
+ temp = Path.Combine(tempDirectory, "Sample.Test.received.txt");
+ target = Path.Combine(Path.GetTempPath(), $"TrackedFilesTest_{Guid.NewGuid():N}.verified.txt");
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(temp))
+ {
+ File.Delete(temp);
+ }
+
+ if (File.Exists(target))
+ {
+ File.Delete(target);
+ }
+
+ FileEx.SafeDeleteDirectory(tempDirectory);
+ }
+
+ ///
+ /// With "Always kill locking processes" on, the menu accepts a locked move by killing
+ /// the locker without asking. The same accept arriving from the viewer is refused as locked,
+ /// because the preference lives inside the resolver and a wire accept never consults it.
+ ///
+ [Test]
+ public async Task AlwaysKillAppliesToAnAcceptArrivingOverTheSocket()
+ {
+ var previous = LockedFilesPolicy.AlwaysKill;
+ // The stored preference, which Program loads into this at startup
+ LockedFilesPolicy.AlwaysKill = true;
+ try
+ {
+ await File.WriteAllTextAsync(temp, "new");
+ await File.WriteAllTextAsync(target, "old");
+ // The resolver Program passes
+ await using var tracker = new RecordingTracker(LockedFilesHandler.Resolve);
+ var locker = FileLockUtils.StartFileLockProcess(target);
+ try
+ {
+ tracker.AddMove(temp, target, "theExe", "theArguments", false, null);
+
+ // What OwnedInlineHost does with an accept sent by a viewer displaying this queue
+ var (ok, message) = ((ITrackedFiles) tracker).Accept(TrackedKeys.ForMove(temp));
+
+ await Assert.That(message).IsNotEqualTo($"Files for '{Path.GetFileNameWithoutExtension(target)}' are locked. Accept from the tray menu to resolve.");
+ await Assert.That(ok).IsTrue();
+ }
+ finally
+ {
+ FileLockUtils.Cleanup(locker);
+ }
+
+ await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("new");
+ }
+ finally
+ {
+ LockedFilesPolicy.AlwaysKill = previous;
+ }
+ }
+}