Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using UnityEditor;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
Expand Down Expand Up @@ -174,5 +175,72 @@ public void FindErrors_WhenValidRegistryPackageAsmdefPathIsMentioned_ReturnsNoIs
Assert.That(result.HasErrors, Is.False);
Assert.That(result.Errors, Is.Empty);
}

[Test]
public void CreateFailureMessage_WhenErrorsHaveFiles_ListsEachIssueWithItsFile()
{
// Pins the failure message shape consumed by CompileResult.Message for compile responses.
AssemblyDefinitionConsoleError[] errors =
{
new("duplicate references", "Assets/Editor/Sample.asmdef", 0),
new("invalid target", "Assets/Tests/Sample.asmref", 0)
};

string message = AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors);

Assert.That(message, Does.StartWith(UnityCliLoopConstants.ERROR_MESSAGE_ASSEMBLY_DEFINITION_IMPORT_ERROR));
Assert.That(message, Does.Contain("- Assets/Editor/Sample.asmdef: duplicate references"));
Assert.That(message, Does.Contain("- Assets/Tests/Sample.asmref: invalid target"));
}

[Test]
public void CreateFailureMessage_WhenErrorHasNoFile_OmitsFilePrefix()
{
// Pins fallback formatting for issues without a resolvable asset path.
AssemblyDefinitionConsoleError[] errors = { new("generic import failure", "", 0) };

string message = AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors);

Assert.That(message, Does.Contain("- generic import failure"));
Assert.That(message, Does.Not.Contain(": generic import failure"));
}

[Test]
public void CreateFailureMessage_WhenMoreThanTenErrorsExist_ListsOnlyFirstTen()
{
// Pins the display cap so console failure messages stay readable with many issues.
AssemblyDefinitionConsoleError[] errors = new AssemblyDefinitionConsoleError[12];
for (int i = 0; i < errors.Length; i++)
{
errors[i] = new AssemblyDefinitionConsoleError($"issue-{i}", $"Assets/Sample{i}.asmdef", 0);
}

string message = AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors);

Assert.That(message, Does.Contain("issue-9"));
Assert.That(message, Does.Not.Contain("issue-10"));
Assert.That(message, Does.Not.Contain("issue-11"));
}

[Test]
public void AssemblyDefinitionConsoleErrorResult_WhenErrorsExist_ExposesFormattedMessage()
{
// Pins that the result DTO's Message mirrors CreateFailureMessage for compile failure reporting.
AssemblyDefinitionConsoleError[] errors = { new("duplicate references", "Assets/Editor/Sample.asmdef", 0) };

AssemblyDefinitionConsoleErrorResult result = new(errors);

Assert.That(result.Message, Is.EqualTo(AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors)));
}

[Test]
public void AssemblyDefinitionConsoleErrorResult_WhenNoErrorsExist_HasNullMessage()
{
// Pins that an empty result never triggers Message-based failure handling.
AssemblyDefinitionConsoleErrorResult result = new(System.Array.Empty<AssemblyDefinitionConsoleError>());

Assert.That(result.HasErrors, Is.False);
Assert.That(result.Message, Is.Null);
}
}
}
164 changes: 164 additions & 0 deletions Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEditor.Compilation;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Pins the recovery decisions made by CompileLifecycleRecoveryCoordinator when the watchdog
/// reports a start timeout or a missed finish callback, without running real Unity compilation.
/// </summary>
[TestFixture]
public sealed class CompileLifecycleRecoveryCoordinatorTests
{
[Test]
public void HandleCompileStartTimeout_WhenAssemblyDefinitionErrorsExist_AbortsWithAssemblyDefinitionResult()
{
// Verifies assembly definition errors take priority over the generic start-timeout abort message.
const string asmdefPath = "Assets/Tests/EditMode/Sample.EditMode.Tests.asmdef";
AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = new(
new AssemblyDefinitionConsoleError[]
{
new("Assembly has duplicate references", asmdefPath, 0)
});
CompileResult abortedWithResult = null;
string abortedWithMessage = null;
CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator(
findAssemblyDefinitionErrors: () => assemblyDefinitionErrors,
validateNoDuplicateAsmdefNames: ValidationResult.Success,
abortWithResult: result => abortedWithResult = result,
abort: message => abortedWithMessage = message);

coordinator.HandleCompileStartTimeout(1234);

Assert.That(abortedWithResult, Is.Not.Null);
Assert.That(abortedWithResult.Success, Is.False);
Assert.That(abortedWithResult.ErrorCount, Is.EqualTo(1));
Assert.That(abortedWithResult.Errors[0].file, Is.EqualTo(asmdefPath));
Assert.That(abortedWithMessage, Is.Null);
}

[Test]
public void HandleCompileStartTimeout_WhenDuplicateAsmdefNamesExist_AbortsWithValidationMessage()
{
// Verifies duplicate asmdef name validation is checked once assembly definition errors are ruled out.
const string duplicateAsmdefMessage = "Duplicate assembly definition name: Sample";
CompileResult abortedWithResult = null;
string abortedWithMessage = null;
CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator(
findAssemblyDefinitionErrors: () => new AssemblyDefinitionConsoleErrorResult(
new AssemblyDefinitionConsoleError[0]),
validateNoDuplicateAsmdefNames: () => ValidationResult.Failure(duplicateAsmdefMessage),
abortWithResult: result => abortedWithResult = result,
abort: message => abortedWithMessage = message);

coordinator.HandleCompileStartTimeout(1234);

Assert.That(abortedWithResult, Is.Null);
Assert.That(abortedWithMessage, Is.EqualTo(duplicateAsmdefMessage));
}

[Test]
public void HandleCompileStartTimeout_WhenNoKnownCauseExists_AbortsWithGenericStartTimeoutMessage()
{
// Verifies the fallback message is unchanged when neither known recovery cause applies.
CompileResult abortedWithResult = null;
string abortedWithMessage = null;
CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator(
findAssemblyDefinitionErrors: () => new AssemblyDefinitionConsoleErrorResult(
new AssemblyDefinitionConsoleError[0]),
validateNoDuplicateAsmdefNames: ValidationResult.Success,
abortWithResult: result => abortedWithResult = result,
abort: message => abortedWithMessage = message);

coordinator.HandleCompileStartTimeout(1234);

Assert.That(abortedWithResult, Is.Null);
Assert.That(
abortedWithMessage,
Is.EqualTo(
"Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes."));
}

[Test]
public void HandleCompileStoppedWithoutFinishEvent_WhenAssemblyDefinitionErrorsExist_AbortsWithAssemblyDefinitionResult()
{
// Verifies missed-callback recovery reports actionable asmdef errors instead of an indeterminate result.
const string asmdefPath = "Assets/Tests/EditMode/Sample.EditMode.Tests.asmdef";
AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = new(
new AssemblyDefinitionConsoleError[]
{
new("Assembly has duplicate references", asmdefPath, 0)
});
CompileResult abortedWithResult = null;
CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator(
findAssemblyDefinitionErrors: () => assemblyDefinitionErrors,
getCompileMessages: () => new CompilerMessage[0],
getIsForceCompile: () => false,
abortWithResult: result => abortedWithResult = result);

coordinator.HandleCompileStoppedWithoutFinishEvent(500);

Assert.That(abortedWithResult, Is.Not.Null);
Assert.That(abortedWithResult.Success, Is.False);
Assert.That(abortedWithResult.IsIndeterminate, Is.False);
Assert.That(abortedWithResult.Errors[0].file, Is.EqualTo(asmdefPath));
}

[Test]
public void HandleCompileStoppedWithoutFinishEvent_WhenNoAssemblyDefinitionErrorsExist_AbortsWithIndeterminateResult()
{
// Verifies missed-callback recovery keeps indeterminate status when nothing known explains the gap.
CompilerMessage[] compileMessages =
{
new()
{
type = CompilerMessageType.Error,
message = "CS0000: sample compile error",
file = "Assets/Scripts/Sample.cs",
line = 7
}
};
CompileResult abortedWithResult = null;
CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator(
findAssemblyDefinitionErrors: () => new AssemblyDefinitionConsoleErrorResult(
new AssemblyDefinitionConsoleError[0]),
getCompileMessages: () => compileMessages,
getIsForceCompile: () => false,
abortWithResult: result => abortedWithResult = result);

coordinator.HandleCompileStoppedWithoutFinishEvent(500);

Assert.That(abortedWithResult, Is.Not.Null);
Assert.That(abortedWithResult.Success, Is.Null);
Assert.That(abortedWithResult.IsIndeterminate, Is.True);
Assert.That(abortedWithResult.Errors[0].message, Is.EqualTo("CS0000: sample compile error"));
}

private static CompileLifecycleRecoveryCoordinator CreateCoordinator(
Func<AssemblyDefinitionConsoleErrorResult> findAssemblyDefinitionErrors = null,
Func<ValidationResult> validateNoDuplicateAsmdefNames = null,
Func<CompilerMessage[]> getCompileMessages = null,
Func<bool> getIsForceCompile = null,
Action<CompileResult> abortWithResult = null,
Action<string> abort = null)
{
return new CompileLifecycleRecoveryCoordinator(
isEditorCompiling: () => false,
isRequestCompleted: () => false,
getCurrentCompileTask: () => null,
findAssemblyDefinitionErrors: findAssemblyDefinitionErrors ??
(() => new AssemblyDefinitionConsoleErrorResult(new AssemblyDefinitionConsoleError[0])),
validateNoDuplicateAsmdefNames: validateNoDuplicateAsmdefNames ?? ValidationResult.Success,
getIsForceCompile: getIsForceCompile ?? (() => false),
getCompileMessages: getCompileMessages ?? (() => new CompilerMessage[0]),
buildStateContext: context => context,
abortWithResult: abortWithResult ?? (_ => { }),
abort: abort ?? (_ => { }));
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ public void IsCurrentCompileRequest_WhenTaskMatches_ReturnsTrue()
// Verifies watchdog fault recovery accepts the compile request it was created for.
TaskCompletionSource<CompileResult> compileTask = new();

bool isCurrentCompileRequest = CompileController.IsCurrentCompileRequest(compileTask, compileTask);
bool isCurrentCompileRequest =
CompileLifecycleRecoveryCoordinator.IsCurrentCompileRequest(compileTask, compileTask);

Assert.That(isCurrentCompileRequest, Is.True);
}
Expand All @@ -158,7 +159,7 @@ public void IsCurrentCompileRequest_WhenTaskDiffers_ReturnsFalse()
TaskCompletionSource<CompileResult> currentCompileTask = new();
TaskCompletionSource<CompileResult> staleCompileTask = new();

bool isCurrentCompileRequest = CompileController.IsCurrentCompileRequest(
bool isCurrentCompileRequest = CompileLifecycleRecoveryCoordinator.IsCurrentCompileRequest(
currentCompileTask,
staleCompileTask);

Expand Down
60 changes: 57 additions & 3 deletions Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ public void CreatePrefabStageReopenContext_WhenInstanceIsValid_PreservesContext(
try
{
(GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context =
ExternalSceneChangeTracker.CreatePrefabStageReopenContext(
ExternalPrefabStageChangeTracker.CreatePrefabStageReopenContext(
openedFromInstanceObject,
PrefabStage.Mode.InContext,
_ => true);
Expand All @@ -528,7 +528,7 @@ public void CreatePrefabStageReopenContext_WhenInstanceIsInvalid_FallsBackToIsol
try
{
(GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context =
ExternalSceneChangeTracker.CreatePrefabStageReopenContext(
ExternalPrefabStageChangeTracker.CreatePrefabStageReopenContext(
openedFromInstanceObject,
PrefabStage.Mode.InContext,
_ => false);
Expand All @@ -542,12 +542,66 @@ public void CreatePrefabStageReopenContext_WhenInstanceIsInvalid_FallsBackToIsol
}
}

[Test]
public void BuildFingerprintDiffContexts_WhenNoSnapshotExists_ReportsChangedWithoutSnapshot()
{
// Pins first-observation behavior used by focus-return start/end observability logging.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots =
new Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)>(StringComparer.Ordinal);
(string AssetPath, bool IsDirty)[] scenes = { (ScenePath, false) };

object[] diffs = ExternalSceneChangeTracker.BuildFingerprintDiffContexts(
scenes, snapshots, _ => (true, ChangedTime, 20));

Assert.That(diffs, Has.Length.EqualTo(1));
dynamic diff = diffs[0];
Assert.That((string)diff.assetPath, Is.EqualTo(ScenePath));
Assert.That((bool)diff.changed, Is.True);
Assert.That((bool)diff.hasSnapshot, Is.False);
Assert.That((bool)diff.snapshotExists, Is.False);
Assert.That((bool)diff.currentExists, Is.True);
Assert.That((long)diff.currentLength, Is.EqualTo(20));
}

[Test]
public void BuildFingerprintDiffContexts_WhenFingerprintMatchesSnapshot_ReportsUnchanged()
{
// Pins that identical fingerprints are reported as unchanged for observability logging.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
(string AssetPath, bool IsDirty)[] scenes = { (ScenePath, true) };

object[] diffs = ExternalSceneChangeTracker.BuildFingerprintDiffContexts(
scenes, snapshots, _ => (true, SavedTime, 10));

dynamic diff = diffs[0];
Assert.That((bool)diff.hasSnapshot, Is.True);
Assert.That((bool)diff.changed, Is.False);
Assert.That((bool)diff.isDirty, Is.True);
}

[Test]
public void BuildFingerprintDiffContexts_WhenFingerprintDiffersFromSnapshot_ReportsChanged()
{
// Pins that a diverged fingerprint is reported as changed for observability logging.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
(string AssetPath, bool IsDirty)[] scenes = { (ScenePath, false) };

object[] diffs = ExternalSceneChangeTracker.BuildFingerprintDiffContexts(
scenes, snapshots, _ => (true, ChangedTime, 20));

dynamic diff = diffs[0];
Assert.That((bool)diff.hasSnapshot, Is.True);
Assert.That((bool)diff.changed, Is.True);
Assert.That((long)diff.snapshotLength, Is.EqualTo(10));
Assert.That((long)diff.currentLength, Is.EqualTo(20));
}

[Test]
public void CreatePrefabStageReopenContext_WhenInstanceIsMissing_UsesIsolation()
{
// Verifies missing Prefab Stage context reopens without invalid InContext arguments.
(GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context =
ExternalSceneChangeTracker.CreatePrefabStageReopenContext(
ExternalPrefabStageChangeTracker.CreatePrefabStageReopenContext(
null,
PrefabStage.Mode.InContext,
_ => true);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Immutable Console error snapshot for one Assembly Definition or Assembly Reference issue.
/// </summary>
public sealed class AssemblyDefinitionConsoleError
{
public string Message { get; }
public string File { get; }
public int Line { get; }

public AssemblyDefinitionConsoleError(string message, string file, int line)
{
Message = message ?? string.Empty;
File = file ?? string.Empty;
Line = line;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading