Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its
`Warnings` line says the type requires a compile) gets no such line, because that
compile also establishes its baseline. This also holds for an existing file that
gains such a declaration, for example a nested type or a delegate.
Within one Editor session, a source compared with its baseline once is compared again
only when its length or last write time changes; a rewrite that keeps both (for example
a copy that preserves timestamps) is noticed only after the next domain reload, such as
`uloop compile`. This applies wherever a source is compared with its baseline: finding
drifted siblings, deciding which live patches to re-apply after a skip, and selecting
files when `--files` is omitted.

Property getters with a body (including expression-bodied properties) are patched
like ordinary methods. Editing a compiled property's setter, init, or indexer accessor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its
`Warnings` line says the type requires a compile) gets no such line, because that
compile also establishes its baseline. This also holds for an existing file that
gains such a declaration, for example a nested type or a delegate.
Within one Editor session, a source compared with its baseline once is compared again
only when its length or last write time changes; a rewrite that keeps both (for example
a copy that preserves timestamps) is noticed only after the next domain reload, such as
`uloop compile`. This applies wherever a source is compared with its baseline: finding
drifted siblings, deciding which live patches to re-apply after a skip, and selecting
files when `--files` is omitted.

Property getters with a body (including expression-bodied properties) are patched
like ordinary methods. Editing a compiled property's setter, init, or indexer accessor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
/// </summary>
public class HotReloadChangedSiblingSourceDetectorTests
{
// Why fixed write times: the detector reuses a verdict while a file's length and write time
// stay the same, so each test sets the stamp it means instead of relying on the clock.
private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1);

/// <summary>
/// What: a sibling whose on-disk bytes differ from its snapshot is returned, and the
/// edited file itself is excluded even when it also differs.
Expand Down Expand Up @@ -504,6 +509,142 @@ public void SourceMatchesSnapshot_NoPdb_IsFalse()
}
}

/// <summary>
/// What: a sibling that matched its snapshot is reported once it is rewritten with another
/// length while its write time stays the same, so the length alone triggers a new comparison.
/// </summary>
[Test]
public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewLength_ReturnsSibling()
{
string projectRoot = CreateTempProjectRoot();
try
{
string siblingRelative = "Assets/Sibling.cs";
WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);

WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA-longer", FirstWriteTimeUtc);

Assert.That(
ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
}
finally
{
Directory.Delete(projectRoot, recursive: true);
}
}

/// <summary>
/// What: a sibling that matched its snapshot is reported once it is rewritten with the same
/// length and a later write time, so the write time alone triggers a new comparison.
/// </summary>
[Test]
public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_ReturnsSibling()
{
string projectRoot = CreateTempProjectRoot();
try
{
string siblingRelative = "Assets/Sibling.cs";
WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);

WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", SecondWriteTimeUtc);

Assert.That(
ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
}
finally
{
Directory.Delete(projectRoot, recursive: true);
}
}

/// <summary>
/// What: a sibling rewritten with the same length while its write time is put back keeps
/// the earlier "matches" verdict. This is a known limit: a change that keeps both length and
/// write time (a copy that preserves timestamps) is not noticed until the next compile.
/// </summary>
[Test]
public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheMatchVerdict()
{
string projectRoot = CreateTempProjectRoot();
try
{
string siblingRelative = "Assets/Sibling.cs";
WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);

WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc);

Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);
}
finally
{
Directory.Delete(projectRoot, recursive: true);
}
}

/// <summary>
/// What: a verdict against one assembly generation's snapshot is not reused against
/// another generation's snapshot of the same file.
/// </summary>
[Test]
public void DetectFromSnapshotDirectory_SameSiblingAgainstAnotherSnapshotDirectory_ComparesAgain()
{
string projectRoot = CreateTempProjectRoot();
try
{
string siblingRelative = "Assets/Sibling.cs";
WriteSnapshot(projectRoot, "Asm-mvid1", siblingRelative, "sibling-AAAA");
WriteSnapshot(projectRoot, "Asm-mvid2", siblingRelative, "sibling-BBBB");
WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
Assert.That(ScanSibling(projectRoot, "Asm-mvid1", siblingRelative), Is.Empty);

Assert.That(
ScanSibling(projectRoot, "Asm-mvid2", siblingRelative),
Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
}
finally
{
Directory.Delete(projectRoot, recursive: true);
}
}

/// <summary>
/// What: a sibling put back to its snapshot bytes while its length and write time are
/// kept keeps the earlier "differs" verdict. This is the same known limit in the other
/// direction: it stays reported until the next compile.
/// </summary>
[Test]
public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheDiffersVerdict()
{
string projectRoot = CreateTempProjectRoot();
try
{
string siblingRelative = "Assets/Sibling.cs";
WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc);
Assert.That(
ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));

WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);

Assert.That(
ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
}
finally
{
Directory.Delete(projectRoot, recursive: true);
}
}

/// <summary>
/// What: sibling-derived warnings are ordinal-deduped among themselves and skipped
/// when the own-file list already contains the exact string, without collapsing
Expand All @@ -524,6 +665,38 @@ public void AppendSiblingDerivedWarnings_DedupesAmongSiblingsAndSkipsOwnFileMatc
Is.EqualTo(new[] { "own-a", "own-a", "shared", "sibling-b" }));
}

// Why an edited file: a run always has one, and the detector requires it.
private const string EditedRelative = "Assets/Edited.cs";

private static string[] ScanSibling(
string projectRoot,
string assemblySnapshotDirectoryName,
string siblingRelative)
{
return HotReloadChangedSiblingSourceDetector.DetectFromSnapshotDirectory(
projectRoot,
assemblySnapshotDirectoryName,
new[] { EditedRelative, siblingRelative },
new[] { EditedRelative }).ChangedSiblingAbsolutePaths;
}

// Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while
// LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly.
private static void WriteProjectFileAt(
string projectRoot,
string projectRelativePath,
string contents,
DateTime lastWriteTimeUtc)
{
WriteProjectFile(projectRoot, projectRelativePath, contents);
string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath);
File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc);
Assert.That(
new FileInfo(absolutePath).LastWriteTimeUtc,
Is.EqualTo(lastWriteTimeUtc),
"The write time must be settable exactly, or the stamp the detector reads is not the one the test chose.");
}

private static string CreateTempProjectRoot()
{
string projectRoot = Path.Combine(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
/// </summary>
public class HotReloadChangedSourceDetectorTests
{
// Why fixed write times: the detector reuses a verdict while a file's length and write time
// stay the same, so each test sets the stamp it means instead of relying on the clock.
private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1);

/// <summary>
/// What: a missing snapshot directory reports no baseline instead of conflating it with no changes.
/// </summary>
Expand Down Expand Up @@ -178,6 +183,82 @@ public void DetectAllChangedFromSnapshotDirectory_WhenMoreThanLimitChanged_Trunc
}
}

/// <summary>
/// What: the default file selection does not pick a source rewritten while it keeps its
/// length and write time, because the earlier "matches" verdict is reused. This is a known
/// limit until the next compile.
/// </summary>
[Test]
public void DetectAllChangedFromSnapshotDirectory_SecondScanWithSameStamp_DoesNotSelectTheRewrite()
{
string projectRoot = CreateTempProjectRoot();
try
{
string sourcePath = "Assets/Source.cs";
WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA");
WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc);
Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty);

WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", FirstWriteTimeUtc);

Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty);
}
finally
{
Directory.Delete(projectRoot, recursive: true);
}
}

/// <summary>
/// What: the default file selection picks a source rewritten with a later write time after
/// an earlier scan found it unchanged.
/// </summary>
[Test]
public void DetectAllChangedFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_SelectsTheRewrite()
{
string projectRoot = CreateTempProjectRoot();
try
{
string sourcePath = "Assets/Source.cs";
WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA");
WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc);
Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty);

WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", SecondWriteTimeUtc);

Assert.That(ScanAll(projectRoot, sourcePath), Is.EqualTo(new[] { sourcePath }));
}
finally
{
Directory.Delete(projectRoot, recursive: true);
}
}

private static System.Collections.Generic.List<string> ScanAll(string projectRoot, string sourcePath)
{
return HotReloadChangedSiblingSourceDetector.DetectAllChangedFromSnapshotDirectory(
projectRoot,
"Assembly-mvid",
new[] { sourcePath }).ChangedProjectRelativePaths;
}

// Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while
// LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly.
private static void WriteProjectFileAt(
string projectRoot,
string projectRelativePath,
string contents,
DateTime lastWriteTimeUtc)
{
WriteProjectFile(projectRoot, projectRelativePath, contents);
string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath);
File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc);
Assert.That(
new FileInfo(absolutePath).LastWriteTimeUtc,
Is.EqualTo(lastWriteTimeUtc),
"The write time must be settable exactly, or the stamp the detector reads is not the one the test chose.");
}

private static string CreateTempProjectRoot()
{
string projectRoot = Path.Combine(
Expand Down
Loading
Loading