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
31 changes: 31 additions & 0 deletions Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

using HarmonyLib;

using Newtonsoft.Json.Linq;

using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;
Expand Down Expand Up @@ -101,6 +103,35 @@ public void UninstallInstalledServices_LeavesTheSiblingToolsReadingNoDomainInsta
Assert.That(HotReloadPausePointCoordination.HotReloadSide, Is.Not.SameAs(installedPort));
}

/// <summary>
/// The pause point side the composition root installs captures the source snapshot
/// through the installed gate, under the pause_point trigger, before it reads the snapshot.
/// </summary>
[Test]
public void Install_PausePointSide_CapturesThroughTheInstalledGateWithThePausePointTrigger()
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
{
captureCount++;
return true;
});
string source;

using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
source = HotReloadPausePointCoordination.HotReloadSide.GetVerifiedSnapshotSource(
"Assets/NoSuchSource.cs",
"Library/ScriptAssemblies/NoSuchAssembly.dll");
}

Assert.That(captureCount, Is.EqualTo(1), "captures");
JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured);
Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint));
Assert.That(source, Is.Null, "no snapshot exists for a dll that does not exist");
}

/// <summary>
/// The copies that swap one stage keep the same wired-value persistence and restore
/// refresh, so a wiring recorded before the swap can still be restored after it and an
Expand Down
53 changes: 52 additions & 1 deletion Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,52 @@ public void FindUnappliedRowForMethod_MethodWithAConstructedGenericParameter_Fin
Assert.That(row.Label, Is.EqualTo(workerLabel));
}

/// <summary>
/// What: reading a verified snapshot source asks the gate to capture the snapshot first, so
/// a pause point set after both tools were disabled at domain load still has one to read.
/// </summary>
[Test]
public void GetVerifiedSnapshotSource_EnsuresTheSnapshotIsCaptured()
{
int ensureCount = 0;
HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++);

port.GetVerifiedSnapshotSource(FileOne, typeof(HotReloadDomainTests).Assembly.Location);

Assert.That(ensureCount, Is.EqualTo(1));
}

/// <summary>
/// What: an empty path is answered with no source before the gate is asked to capture.
/// </summary>
[Test]
public void GetVerifiedSnapshotSource_WithAnEmptyPath_DoesNotEnsureTheCapture()
{
int ensureCount = 0;
HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++);

string source = port.GetVerifiedSnapshotSource(string.Empty, typeof(HotReloadDomainTests).Assembly.Location);

Assert.That(source, Is.Null);
Assert.That(ensureCount, Is.EqualTo(0));
}

/// <summary>
/// What: a capture that throws reaches the pause point command instead of being turned into
/// a missing snapshot.
/// </summary>
[Test]
public void GetVerifiedSnapshotSource_WhenTheCaptureThrows_PassesTheExceptionOn()
{
InvalidOperationException captureFailure = new InvalidOperationException("The capture failed.");
HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => throw captureFailure);

InvalidOperationException thrown = Assert.Throws<InvalidOperationException>(
() => port.GetVerifiedSnapshotSource(FileOne, typeof(HotReloadDomainTests).Assembly.Location));

Assert.That(thrown, Is.SameAs(captureFailure));
}

// Why a generated name: an artifact assembly is compiled under a name of its own, so a
// fixture that reused a project assembly's name would not resolve the way production does.
private static Assembly CreateIntroducedTypeAssembly()
Expand All @@ -1025,7 +1071,12 @@ private static void AssertRow(

private HotReloadPausePointPort CreatePort(Func<string, string> readSourceContentHashOrNull)
{
return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull);
return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull, () => { });
}

private HotReloadPausePointPort CreatePortWithSnapshotCapture(Action ensureSnapshotCaptured)
{
return new HotReloadPausePointPort(_access.Domain, _ => RecordedSourceHash, ensureSnapshotCaptured);
}

// Records a partially applied reload of FileOne that read RecordedWorkerSourcePath.
Expand Down
160 changes: 158 additions & 2 deletions Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;

using Newtonsoft.Json.Linq;

Expand Down Expand Up @@ -31,7 +32,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn

using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands();
HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled);
}

Assert.That(captureCount, Is.EqualTo(1), "captures");
Expand Down Expand Up @@ -62,13 +63,168 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro
using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
Assert.Throws<InvalidOperationException>(
() => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands());
() => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled));

HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(
HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
}

Assert.That(captureCount, Is.EqualTo(2), "captures");
}

/// <summary>
/// What: with both tools that read the snapshot disabled, the capture before serving
/// commands is skipped and logged, and the skip leaves the capture undone, so the first
/// reader that runs later captures through the gate.
/// </summary>
[Test]
public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader()
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++);

using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(NoToolEnabled);

Assert.That(captureCount, Is.EqualTo(0), "captures while both readers are disabled");
JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped);
Assert.That((string)skipped["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad));
Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled));
Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0), "captured entries");

HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(
HotReloadConstants.SourceSnapshotCaptureTriggerApply);
}

Assert.That(captureCount, Is.EqualTo(1), "captures once a reader asks the gate");
}

/// <summary>
/// What: one enabled tool that reads the snapshot is enough for the capture before serving
/// commands to run.
/// </summary>
[TestCase(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)]
[TestCase(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT)]
public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool)
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++);

using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(name => name == enabledTool);
}

Assert.That(captureCount, Is.EqualTo(1), "captures");
JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured);
Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad));
}

/// <summary>
/// What: the startup capture asks about both reader tools by the names the tool settings
/// store them under, hot-reload first.
/// </summary>
[Test]
public void CaptureAtStartupUnlessReadersAreDisabled_WhenNeitherIsEnabled_AsksBothReaderToolsBySettingsName()
{
VibeLogger.ClearMemoryLogs();
List<string> askedNames = new List<string>();
HotReloadSourceSnapshotCapture capture = CountingCapture(() => { });

using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled(
HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad,
name =>
{
askedNames.Add(name);
return false;
});
}

Assert.That(
askedNames,
Is.EqualTo(new[] { UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT }));
}

/// <summary>
/// What: the first update tick's capture is skipped the same way when both reader tools are
/// disabled, and the skip names that trigger.
/// </summary>
[Test]
public void CaptureAtStartupUnlessReadersAreDisabled_AtTheFirstUpdateTick_WhenNeitherIsEnabled_SkipsWithThatTrigger()
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++);

using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled(
HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick,
NoToolEnabled);
}

Assert.That(captureCount, Is.EqualTo(0), "captures");
JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped);
Assert.That((string)skipped["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick));
}

/// <summary>
/// What: with the hot-reload tool disabled the warm-up is not started, and the skip is logged
/// with its reason.
/// </summary>
[Test]
public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsDisabled_DoesNotStartAndLogsTheReason()
{
VibeLogger.ClearMemoryLogs();
int startCount = 0;

HotReloadEditorStartup.StartWarmUpUnlessHotReloadIsDisabled(
name => name != UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD,
() => startCount++);

Assert.That(startCount, Is.EqualTo(0), "warm-up starts");
JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpSkipped);
Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.WarmUpSkipReasonToolDisabled));
}

/// <summary>
/// What: with the hot-reload tool enabled the warm-up is started once and no skip is logged.
/// </summary>
[Test]
public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsEnabled_StartsOnce()
{
VibeLogger.ClearMemoryLogs();
int startCount = 0;

HotReloadEditorStartup.StartWarmUpUnlessHotReloadIsDisabled(
name => name == UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD,
() => startCount++);

Assert.That(startCount, Is.EqualTo(1), "warm-up starts");
Assert.That(CountVibeEntries(HotReloadConstants.VibeLogWarmUpSkipped), Is.EqualTo(0), "skipped entries");
}

private static bool AllToolsEnabled(string toolName) => true;

private static bool NoToolEnabled(string toolName) => false;

private static HotReloadSourceSnapshotCapture CountingCapture(Action onCapture)
{
return new HotReloadSourceSnapshotCapture(() =>
{
onCapture();
return true;
});
}

private static int CountVibeEntries(string operation)
{
return JArray.Parse(VibeLogger.GetLogsForAi(operation)).Count;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;

using io.github.hatayama.UnityCliLoop.Application;
using io.github.hatayama.UnityCliLoop.FirstPartyTools;
using io.github.hatayama.UnityCliLoop.Infrastructure;
Expand Down Expand Up @@ -27,7 +29,8 @@ internal void Initialize()
UnityCliLoopApplicationServices applicationServices = _applicationRegistration.Register();
ApplicationEditorStartup.Initialize(applicationServices.DomainReloadDetectionService);
EditorRuntimeStateSnapshotSubscriber.InitializeForEditorStartup();
FirstPartyToolsEditorStartup.Initialize();
Func<string, bool> isToolEnabled = applicationServices.ToolSettingsUseCase.IsToolEnabled;
FirstPartyToolsEditorStartup.Initialize(isToolEnabled);
InfrastructureEditorStartup.Initialize(applicationServices.EditorSettingsPort);
PresentationEditorStartup.Initialize(
applicationServices.EditorSettingsPort,
Expand All @@ -45,8 +48,9 @@ internal void Initialize()
// afterAssemblyReload, and every command except get-editor-status runs on the main
// thread's update or tick, so no command of this domain is answered before this
// returns — an edit made after `uloop compile` returns can no longer be captured as
// compiled source.
FirstPartyToolsEditorStartup.PrepareBeforeServingCommands();
// compiled source. When both the hot-reload and pause-point tools are disabled, the
// capture is skipped here and runs on the first use of either tool instead.
FirstPartyToolsEditorStartup.PrepareBeforeServingCommands(isToolEnabled);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;

using UnityEditor;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
Expand All @@ -8,14 +10,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
/// </summary>
public static class FirstPartyToolsEditorStartup
{
public static void Initialize()
/// <summary>
/// Initializes every first-party tool's Editor startup work. <paramref name="isToolEnabled"/>
/// answers whether the user enabled a tool in the tool settings, by its settings name.
/// </summary>
public static void Initialize(Func<string, bool> isToolEnabled)
{
DomainReloadDisableScopeRecovery.RestoreForEditorStartup();
ExternalSceneChangeTracker.Initialize();
ControlPlayModeEditorStartup.Initialize();
PausePointEditorStartup.Initialize();
CompileEditorStartup.Initialize();
HotReloadEditorStartup.Initialize();
HotReloadEditorStartup.Initialize(isToolEnabled);
WatchEditorStartup.Initialize();
ExecuteDynamicCodeEditorStartup.Initialize();
GetLogsEditorStartup.Initialize();
Expand All @@ -35,9 +41,10 @@ public static void Initialize()

/// <summary>
/// Runs the first-party tool work that has to finish before this domain answers its first
/// uloop command. Called once, at the end of the Editor startup.
/// uloop command. Called once, at the end of the Editor startup. <paramref name="isToolEnabled"/>
/// answers whether the user enabled a tool in the tool settings, by its settings name.
/// </summary>
public static void PrepareBeforeServingCommands()
public static void PrepareBeforeServingCommands(Func<string, bool> isToolEnabled)
{
// Why not in an import worker: it never serves commands (the server does not start
// there) and it shares Library with the main Editor, so a capture there would only
Expand All @@ -47,7 +54,7 @@ public static void PrepareBeforeServingCommands()
return;
}

HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands();
HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(isToolEnabled);
}

public static void ResetServerScopedServices()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,9 @@ private static void Install(HotReloadServices services)
HotReloadPausePointCoordination.HotReloadSide =
new HotReloadPausePointPort(
domain,
path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path));
path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path),
() => services.SourceSnapshotCapture.EnsureCaptured(
HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint));
// Attaching last keeps the invariant across the gap: the resolver only starts
// answering binds once every gateway already points at the domain behind it.
domain.IntroducedTypeResolver.Resume();
Expand Down
Loading
Loading