diff --git a/.agents/skills/uloop-hot-reload/SKILL.md b/.agents/skills/uloop-hot-reload/SKILL.md index 022e3d8f0..cbe057438 100644 --- a/.agents/skills/uloop-hot-reload/SKILL.md +++ b/.agents/skills/uloop-hot-reload/SKILL.md @@ -373,6 +373,7 @@ Returns JSON with: - `AddedFields` (array): source-level names ("Type.field") of fields this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. `--revert-all` clears both and reports the combined count in `ClearedCount` +- `DroppedByPlayModeEntryCount` (number): Remaining method identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added`), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `ClearedCount` (number): Patches removed by `--revert-all` (0 on apply) - `Message` (string): Short summary. On `--status`, when any `Active` row has `InvocationCount` 0, Message also appends how many such rows there are and points at `Methods[].Reason`; added-member rows are not included in that count - `RecommendedNextAction` (string): Present when any method outcome is `Failed`. A partial apply (some methods patched or added) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. Omitted on success. diff --git a/.claude/skills/uloop-hot-reload/SKILL.md b/.claude/skills/uloop-hot-reload/SKILL.md index 022e3d8f0..cbe057438 100644 --- a/.claude/skills/uloop-hot-reload/SKILL.md +++ b/.claude/skills/uloop-hot-reload/SKILL.md @@ -373,6 +373,7 @@ Returns JSON with: - `AddedFields` (array): source-level names ("Type.field") of fields this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. `--revert-all` clears both and reports the combined count in `ClearedCount` +- `DroppedByPlayModeEntryCount` (number): Remaining method identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added`), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `ClearedCount` (number): Patches removed by `--revert-all` (0 on apply) - `Message` (string): Short summary. On `--status`, when any `Active` row has `InvocationCount` 0, Message also appends how many such rows there are and points at `Methods[].Reason`; added-member rows are not included in that count - `RecommendedNextAction` (string): Present when any method outcome is `Failed`. A partial apply (some methods patched or added) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. Omitted on success. diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerSessionScope.cs b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerSessionScope.cs new file mode 100644 index 000000000..d779e0e03 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerSessionScope.cs @@ -0,0 +1,32 @@ +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Snapshots the live Editor Play-entry drop ledger so tests can restore it after + /// mutating the production SessionState key. + /// + internal sealed class HotReloadPlayModeEntryDropLedgerSessionScope + { + private readonly string _capturedRaw; + + public HotReloadPlayModeEntryDropLedgerSessionScope() + { + _capturedRaw = SessionState.GetString( + HotReloadConstants.PlayModeEntryDropSessionStateKey, + string.Empty); + HotReloadPlayModeEntryDropLedger.Clear(); + HotReloadPlayModeEntryDropRecorder.ResetPendingForTesting(); + } + + public void Restore() + { + HotReloadPlayModeEntryDropRecorder.ResetPendingForTesting(); + SessionState.SetString( + HotReloadConstants.PlayModeEntryDropSessionStateKey, + _capturedRaw); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerSessionScope.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerSessionScope.cs.meta new file mode 100644 index 000000000..d2bb9aab0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerSessionScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52769d1c0a5ff4f108198982dd858a4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerTests.cs new file mode 100644 index 000000000..91133b00d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using NUnit.Framework; + +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies Play-entry drop identities are stored, unioned, removed, and cleared. + /// + [TestFixture] + public sealed class HotReloadPlayModeEntryDropLedgerTests + { + private HotReloadPlayModeEntryDropLedgerSessionScope _ledgerSessionScope; + + [SetUp] + public void SetUp() + { + _ledgerSessionScope = new HotReloadPlayModeEntryDropLedgerSessionScope(); + } + + [TearDown] + public void TearDown() + { + _ledgerSessionScope.Restore(); + } + + /// + /// What: Record unions identities and GetIdentities returns them in ordinal order. + /// + [Test] + public void Record_WhenCalledTwice_UnionsIdentitiesWithoutDuplicates() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.B()", "Type.A()" }); + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.A()", "Type.C()" }); + + IReadOnlyList identities = HotReloadPlayModeEntryDropLedger.GetIdentities(); + + Assert.That(identities, Is.EqualTo(new[] { "Type.A()", "Type.B()", "Type.C()" })); + Assert.That(HotReloadPlayModeEntryDropLedger.Count, Is.EqualTo(3)); + Assert.That( + SessionState.GetString(HotReloadConstants.PlayModeEntryDropSessionStateKey, string.Empty), + Is.EqualTo("Type.A()\nType.B()\nType.C()")); + } + + /// + /// What: Remove deletes only the named identities and leaves the rest. + /// + [Test] + public void Remove_WhenSomeIdentitiesMatch_LeavesTheRest() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.A()", "Type.B()", "Type.C()" }); + + HotReloadPlayModeEntryDropLedger.Remove(new[] { "Type.B()", "Type.Missing()" }); + + Assert.That( + HotReloadPlayModeEntryDropLedger.GetIdentities(), + Is.EqualTo(new[] { "Type.A()", "Type.C()" })); + } + + /// + /// What: Clear empties the SessionState record. + /// + [Test] + public void Clear_WhenIdentitiesExist_RemovesAll() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.A()" }); + + HotReloadPlayModeEntryDropLedger.Clear(); + + Assert.That(HotReloadPlayModeEntryDropLedger.Count, Is.EqualTo(0)); + Assert.That(HotReloadPlayModeEntryDropLedger.GetIdentities(), Is.Empty); + Assert.That( + SessionState.GetString(HotReloadConstants.PlayModeEntryDropSessionStateKey, string.Empty), + Is.EqualTo(string.Empty)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerTests.cs.meta new file mode 100644 index 000000000..035dd7115 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropLedgerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b4643f9787554190b10dede57b6912b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropRecorderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropRecorderTests.cs new file mode 100644 index 000000000..273c64ba0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropRecorderTests.cs @@ -0,0 +1,155 @@ +using System.Collections.Generic; +using NUnit.Framework; + +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies Play-entry drop record/clear decisions without driving Editor events. + /// + [TestFixture] + public sealed class HotReloadPlayModeEntryDropRecorderTests + { + private HotReloadPlayModeEntryDropLedgerSessionScope _ledgerSessionScope; + + [SetUp] + public void SetUp() + { + _ledgerSessionScope = new HotReloadPlayModeEntryDropLedgerSessionScope(); + } + + [TearDown] + public void TearDown() + { + _ledgerSessionScope.Restore(); + } + + /// + /// What: only ExitingEditMode with domain reload and at least one identity records. + /// + [Test] + public void ShouldRecord_RequiresExitingEditModeEnabledReloadAndIdentities() + { + Assert.That( + HotReloadPlayModeEntryDropRecorder.ShouldRecord( + PlayModeStateChange.ExitingEditMode, + isDomainReloadDisabledOnEnterPlayMode: false, + activeIdentityCount: 2), + Is.True); + Assert.That( + HotReloadPlayModeEntryDropRecorder.ShouldRecord( + PlayModeStateChange.EnteredPlayMode, + isDomainReloadDisabledOnEnterPlayMode: false, + activeIdentityCount: 2), + Is.False); + Assert.That( + HotReloadPlayModeEntryDropRecorder.ShouldRecord( + PlayModeStateChange.ExitingEditMode, + isDomainReloadDisabledOnEnterPlayMode: true, + activeIdentityCount: 2), + Is.False); + Assert.That( + HotReloadPlayModeEntryDropRecorder.ShouldRecord( + PlayModeStateChange.ExitingEditMode, + isDomainReloadDisabledOnEnterPlayMode: false, + activeIdentityCount: 0), + Is.False); + } + + /// + /// What: a failed compile keeps the leftover identities. + /// + [Test] + public void NotifyCompilationFinished_WhenErrorCountIsPositive_KeepsIdentities() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.A()", "Type.B()" }); + + HotReloadPlayModeEntryDropRecorder.NotifyCompilationFinished(1); + + Assert.That( + HotReloadPlayModeEntryDropLedger.GetIdentities(), + Is.EqualTo(new[] { "Type.A()", "Type.B()" })); + } + + /// + /// What: a successful compile clears every leftover identity. + /// + [Test] + public void NotifyCompilationFinished_WhenErrorCountIsZero_ClearsIdentities() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.A()" }); + + HotReloadPlayModeEntryDropRecorder.NotifyCompilationFinished(0); + + Assert.That(HotReloadPlayModeEntryDropLedger.Count, Is.EqualTo(0)); + } + + /// + /// What: apply removes only Patched and Added identities from the leftover set. + /// + [Test] + public void NotifyApplyRecovered_RemovesOnlyPatchedAndAddedIdentities() + { + HotReloadPlayModeEntryDropLedger.Record( + new[] { "Type.Patched()", "Type.Added()", "Type.Failed()", "Type.Skipped()" }); + + HotReloadPlayModeEntryDropRecorder.NotifyApplyRecovered( + new List + { + HotReloadMethodOutcome.Patched("Type.Patched()", "Assets/A.cs"), + HotReloadMethodOutcome.Added("Type.Added()", "Assets/A.cs"), + HotReloadMethodOutcome.Failed("Type.Failed()", "reason", "Assets/A.cs"), + HotReloadMethodOutcome.Skipped("Type.Skipped()", "reason", "Assets/A.cs") + }); + + Assert.That( + HotReloadPlayModeEntryDropLedger.GetIdentities(), + Is.EqualTo(new[] { "Type.Failed()", "Type.Skipped()" })); + } + + /// + /// What: revert-all clears every leftover identity. + /// + [Test] + public void NotifyRevertAll_ClearsIdentities() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.A()" }); + + HotReloadPlayModeEntryDropRecorder.NotifyRevertAll(); + + Assert.That(HotReloadPlayModeEntryDropLedger.Count, Is.EqualTo(0)); + } + + /// + /// What: a same-domain EnteredEditMode after ExitingEditMode means Play entry + /// was cancelled, so the just-recorded identities leave the ledger and older + /// leftovers stay. + /// + [Test] + public void NotifyPlayModeStateChanged_WhenEnteredEditModeFollowsExitingEditModeInSameDomain_RemovesOnlyPendingIdentities() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.Old()" }); + + HotReloadPlayModeEntryDropRecorder.NotifyPlayModeStateChanged( + PlayModeStateChange.ExitingEditMode, + new[] { "Type.Active()" }, + isDomainReloadDisabledOnEnterPlayMode: false); + + Assert.That( + HotReloadPlayModeEntryDropLedger.GetIdentities(), + Is.EqualTo(new[] { "Type.Active()", "Type.Old()" })); + + HotReloadPlayModeEntryDropRecorder.NotifyPlayModeStateChanged( + PlayModeStateChange.EnteredEditMode, + new[] { "Type.Active()" }, + isDomainReloadDisabledOnEnterPlayMode: false); + + Assert.That( + HotReloadPlayModeEntryDropLedger.GetIdentities(), + Is.EqualTo(new[] { "Type.Old()" })); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropRecorderTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropRecorderTests.cs.meta new file mode 100644 index 000000000..3d7c8dfa7 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropRecorderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d2e88a3f980f740febd58e253eb4e077 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilderTests.cs new file mode 100644 index 000000000..917e09151 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilderTests.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies the --status drop Message is emitted only when no changes are active + /// and discarded identities remain. + /// + [TestFixture] + public sealed class HotReloadPlayModeEntryDropStatusMessageBuilderTests + { + /// + /// What: zero active changes with leftover identities uses the Play-entry discard wording. + /// + [Test] + public void Build_WhenActiveCountIsZeroAndDroppedCountIsPositive_ReturnsExactDropMessage() + { + string message = HotReloadPlayModeEntryDropStatusMessageBuilder.Build(0, 2); + + Assert.That( + message, + Is.EqualTo( + "0 change(s) currently active. 2 change(s) were discarded by the domain reload when Play Mode was entered — hot-reloaded edits that were never compiled are not in effect. Re-apply 'uloop hot-reload', or edit the files and run 'uloop compile'.")); + } + + /// + /// What: active changes keep the existing --status Message path (no drop sentence). + /// + [Test] + public void Build_WhenActiveCountIsPositive_ReturnsNull() + { + string message = HotReloadPlayModeEntryDropStatusMessageBuilder.Build(1, 2); + + Assert.That(message, Is.Null); + } + + /// + /// What: no leftover identities keep the existing --status Message path. + /// + [Test] + public void Build_WhenDroppedCountIsZero_ReturnsNull() + { + string message = HotReloadPlayModeEntryDropStatusMessageBuilder.Build(0, 0); + + Assert.That(message, Is.Null); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilderTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilderTests.cs.meta new file mode 100644 index 000000000..cc1172aaa --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e9958830c2b243ad86889cb7a8d56c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusTests.cs new file mode 100644 index 000000000..4410dee15 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusTests.cs @@ -0,0 +1,138 @@ +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies --status Message and DroppedByPlayModeEntryCount against leftover Play-entry drops. + /// + [TestFixture] + public sealed class HotReloadPlayModeEntryDropStatusTests + { + private HotReloadPlayModeEntryDropLedgerSessionScope _ledgerSessionScope; + + [SetUp] + public void SetUp() + { + _ledgerSessionScope = new HotReloadPlayModeEntryDropLedgerSessionScope(); + HotReloadPatcher.RevertAll(); + } + + [TearDown] + public void TearDown() + { + HotReloadPatcher.RevertAll(); + _ledgerSessionScope.Restore(); + } + + /// + /// What: --status with no active changes and leftover identities uses the drop Message + /// and serializes DroppedByPlayModeEntryCount. + /// + [Test] + public async Task ExecuteAsync_Status_WhenNoActiveChangesAndDropsRemain_ReturnsExactDropMessage() + { + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.A()", "Type.B()" }); + + HotReloadResponse response = await ExecuteStatusAsync(CancellationToken.None); + JObject json = JObject.Parse( + JsonConvert.SerializeObject( + response, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings)); + + Assert.That( + response.Message, + Is.EqualTo( + "0 change(s) currently active. 2 change(s) were discarded by the domain reload when Play Mode was entered — hot-reloaded edits that were never compiled are not in effect. Re-apply 'uloop hot-reload', or edit the files and run 'uloop compile'.")); + Assert.That(response.DroppedByPlayModeEntryCount, Is.EqualTo(2)); + Assert.That(response.ShouldSerializeDroppedByPlayModeEntryCount(), Is.True); + Assert.That(json.Value("DroppedByPlayModeEntryCount"), Is.EqualTo(2)); + } + + /// + /// What: --status with no leftover identities omits DroppedByPlayModeEntryCount and + /// keeps the active-count Message. + /// + [Test] + public async Task ExecuteAsync_Status_WhenNoDropsRemain_OmitsDroppedCountAndKeepsActiveMessage() + { + HotReloadResponse response = await ExecuteStatusAsync(CancellationToken.None); + JObject json = JObject.Parse( + JsonConvert.SerializeObject( + response, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings)); + + Assert.That(response.Message, Is.EqualTo("0 change(s) currently active.")); + Assert.That(response.DroppedByPlayModeEntryCount, Is.EqualTo(0)); + Assert.That(response.ShouldSerializeDroppedByPlayModeEntryCount(), Is.False); + Assert.That(json.Property("DroppedByPlayModeEntryCount"), Is.Null); + } + + /// + /// What: --status with one never-invoked active change and leftover identities + /// keeps the existing active Message and still serializes DroppedByPlayModeEntryCount. + /// + [Test] + public async Task ExecuteAsync_Status_WhenActiveNeverInvokedAndDropsRemain_KeepsActiveMessageAndSerializesDroppedCount() + { + ApplyCoreFixtureTransplant(); + HotReloadPlayModeEntryDropLedger.Record(new[] { "Type.Dropped()" }); + + HotReloadResponse response = await ExecuteStatusAsync(CancellationToken.None); + JObject json = JObject.Parse( + JsonConvert.SerializeObject( + response, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings)); + + Assert.That( + response.Message, + Is.EqualTo( + "1 change(s) currently active. 1 change(s) have not been invoked since their patch was applied; see Methods[].Reason.")); + Assert.That(response.DroppedByPlayModeEntryCount, Is.EqualTo(1)); + Assert.That(response.ShouldSerializeDroppedByPlayModeEntryCount(), Is.True); + Assert.That(json.Value("DroppedByPlayModeEntryCount"), Is.EqualTo(1)); + } + + private static async Task ExecuteStatusAsync(CancellationToken ct) + { + HotReloadTool tool = new HotReloadTool(); + UnityCliLoopToolResponse baseResponse = await tool.ExecuteAsync( + new JObject { ["Status"] = true }, + ct); + HotReloadResponse response = baseResponse as HotReloadResponse; + Assert.That(response, Is.Not.Null); + return response; + } + + // Applies a handwritten transplant to ReplaceableCompute without invoking it. + private static void ApplyCoreFixtureTransplant() + { + MethodInfo original = typeof(HotReloadCoreFixture).GetMethod( + nameof(HotReloadCoreFixture.ReplaceableCompute), + BindingFlags.Instance | BindingFlags.Public); + MethodInfo shim = typeof(HotReloadHandwrittenShims).GetMethod( + nameof(HotReloadHandwrittenShims.ReplaceableCompute__shim0), + BindingFlags.Static | BindingFlags.Public); + Assert.That(original, Is.Not.Null); + Assert.That(shim, Is.Not.Null); + + HotReloadPatchResult applyResult = HotReloadPatcher.Apply( + original, + shim, + HotReloadPatchShape.Transplant, + "Assets/Tests/Fixture.cs"); + Assert.That(applyResult.Success, Is.True, applyResult.ErrorMessage); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusTests.cs.meta new file mode 100644 index 000000000..bbea3f2c0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlayModeEntryDropStatusTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c55dfb3eb067475f93690819094ec1d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index 0636c04a2..afe4858bc 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -20,6 +20,20 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// public class HotReloadToolTests { + private HotReloadPlayModeEntryDropLedgerSessionScope _ledgerSessionScope; + + [SetUp] + public void SetUp() + { + _ledgerSessionScope = new HotReloadPlayModeEntryDropLedgerSessionScope(); + } + + [TearDown] + public void TearDown() + { + _ledgerSessionScope.Restore(); + } + [Test] public void ToolName_ReturnsHotReload() { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadConstants.cs index 7ccccd04a..2f867e5d6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadConstants.cs @@ -281,6 +281,15 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string FailedWithNoApplyRecommendedNextAction = "Fix the failed methods and rerun, or run 'uloop compile'."; + // SessionState key for method identities discarded by the Play-entry domain reload. + // SessionState survives that reload and is cleared when the Editor process exits. + public const string PlayModeEntryDropSessionStateKey = + "io.github.hatayama.uloop.hot-reload.playModeEntryDroppedIdentities"; + + // Format: remaining discarded identity count. Used only when --status active count is 0. + public const string PlayModeEntryDropStatusMessageFormat = + "0 change(s) currently active. {0} change(s) were discarded by the domain reload when Play Mode was entered — hot-reloaded edits that were never compiled are not in effect. Re-apply 'uloop hot-reload', or edit the files and run 'uloop compile'."; + public const int SiblingConstDriftScanFileLimit = 50; // Format: scan file limit, total changed sibling count. Emitted when the scan diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index ab31ee9d3..f95fd907e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -20,6 +20,7 @@ void CaptureOnFirstUpdateTick() } EditorApplication.update += CaptureOnFirstUpdateTick; + HotReloadPlayModeEntryDropRecorder.Initialize(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropLedger.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropLedger.cs new file mode 100644 index 000000000..f4ed76033 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropLedger.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; + +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Stores method identities discarded by the Play-entry domain reload in SessionState + /// so --status can still name them after the in-memory patch ledger is gone. + /// + internal static class HotReloadPlayModeEntryDropLedger + { + public static void Record(IReadOnlyList identities) + { + Debug.Assert(identities != null, "identities must not be null"); + HashSet stored = ReadSet(); + for (int index = 0; index < identities.Count; index++) + { + string identity = identities[index]; + if (string.IsNullOrEmpty(identity)) + { + continue; + } + + stored.Add(identity); + } + + WriteSet(stored); + } + + public static void Remove(IReadOnlyList identities) + { + Debug.Assert(identities != null, "identities must not be null"); + HashSet stored = ReadSet(); + for (int index = 0; index < identities.Count; index++) + { + stored.Remove(identities[index]); + } + + WriteSet(stored); + } + + public static void Clear() + { + SessionState.SetString(HotReloadConstants.PlayModeEntryDropSessionStateKey, string.Empty); + } + + public static IReadOnlyList GetIdentities() + { + List identities = new List(ReadSet()); + identities.Sort(StringComparer.Ordinal); + return identities; + } + + public static int Count => ReadSet().Count; + + private static HashSet ReadSet() + { + string raw = SessionState.GetString( + HotReloadConstants.PlayModeEntryDropSessionStateKey, + string.Empty); + HashSet stored = new HashSet(StringComparer.Ordinal); + if (string.IsNullOrEmpty(raw)) + { + return stored; + } + + string[] lines = raw.Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + if (string.IsNullOrEmpty(lines[index])) + { + continue; + } + + stored.Add(lines[index]); + } + + return stored; + } + + private static void WriteSet(HashSet stored) + { + Debug.Assert(stored != null, "stored must not be null"); + List identities = new List(stored); + identities.Sort(StringComparer.Ordinal); + SessionState.SetString( + HotReloadConstants.PlayModeEntryDropSessionStateKey, + string.Join("\n", identities)); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropLedger.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropLedger.cs.meta new file mode 100644 index 000000000..e881bd624 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropLedger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 282d9c0b6b6b24500acefed402a3ec51 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropRecorder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropRecorder.cs new file mode 100644 index 000000000..14c3cff3c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropRecorder.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; + +using UnityEditor; +using UnityEditor.Compilation; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Records Play-entry domain-reload drops and clears them on successful compile, + /// revert-all, or recovered apply outcomes. Event handlers stay thin; decisions are + /// tested through the Notify/Should methods. + /// + internal static class HotReloadPlayModeEntryDropRecorder + { + private static int _currentCompilationErrorCount; + + // Why static: a domain reload wipes this list. The next playModeStateChanged + // in the same domain therefore means Play entry was cancelled and the just-recorded + // identities must leave the ledger so live patches are not reported as dropped. + private static List _pendingIdentitiesRecordedInThisDomain; + + public static void Initialize() + { + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; + EditorApplication.playModeStateChanged += HandlePlayModeStateChanged; + CompilationPipeline.compilationStarted -= HandleCompilationStarted; + CompilationPipeline.compilationStarted += HandleCompilationStarted; + CompilationPipeline.assemblyCompilationFinished -= HandleAssemblyCompilationFinished; + CompilationPipeline.assemblyCompilationFinished += HandleAssemblyCompilationFinished; + CompilationPipeline.compilationFinished -= HandleCompilationFinished; + CompilationPipeline.compilationFinished += HandleCompilationFinished; + } + + internal static bool ShouldRecord( + PlayModeStateChange state, + bool isDomainReloadDisabledOnEnterPlayMode, + int activeIdentityCount) + { + if (state != PlayModeStateChange.ExitingEditMode) + { + return false; + } + + if (isDomainReloadDisabledOnEnterPlayMode) + { + return false; + } + + return activeIdentityCount > 0; + } + + internal static bool ShouldClearAfterCompilation(int errorCount) + { + return errorCount == 0; + } + + internal static void NotifyCompilationFinished(int errorCount) + { + if (!ShouldClearAfterCompilation(errorCount)) + { + return; + } + + HotReloadPlayModeEntryDropLedger.Clear(); + } + + internal static void NotifyApplyRecovered(IReadOnlyList methods) + { + Debug.Assert(methods != null, "methods must not be null"); + List recoveredIdentities = new List(); + for (int index = 0; index < methods.Count; index++) + { + HotReloadMethodOutcome outcome = methods[index]; + if (outcome.Kind != HotReloadMethodOutcomeKind.Patched + && outcome.Kind != HotReloadMethodOutcomeKind.Added) + { + continue; + } + + recoveredIdentities.Add(outcome.Method); + } + + HotReloadPlayModeEntryDropLedger.Remove(recoveredIdentities); + } + + internal static void NotifyRevertAll() + { + HotReloadPlayModeEntryDropLedger.Clear(); + } + + internal static void ResetPendingForTesting() + { + _pendingIdentitiesRecordedInThisDomain = null; + } + + internal static void NotifyPlayModeStateChanged( + PlayModeStateChange state, + IReadOnlyList identities, + bool isDomainReloadDisabledOnEnterPlayMode) + { + Debug.Assert(identities != null, "identities must not be null"); + DiscardPendingIfSameDomainSurvived(); + if (!ShouldRecord(state, isDomainReloadDisabledOnEnterPlayMode, identities.Count)) + { + return; + } + + HotReloadPlayModeEntryDropLedger.Record(identities); + RememberPending(identities); + } + + private static void HandlePlayModeStateChanged(PlayModeStateChange state) + { + NotifyPlayModeStateChanged( + state, + CollectActiveIdentities(), + IsDomainReloadDisabledOnEnterPlayMode()); + } + + private static void DiscardPendingIfSameDomainSurvived() + { + if (_pendingIdentitiesRecordedInThisDomain == null + || _pendingIdentitiesRecordedInThisDomain.Count == 0) + { + return; + } + + HotReloadPlayModeEntryDropLedger.Remove(_pendingIdentitiesRecordedInThisDomain); + _pendingIdentitiesRecordedInThisDomain = null; + } + + private static void RememberPending(IReadOnlyList identities) + { + List pending = new List(); + for (int index = 0; index < identities.Count; index++) + { + string identity = identities[index]; + if (string.IsNullOrEmpty(identity)) + { + continue; + } + + pending.Add(identity); + } + + _pendingIdentitiesRecordedInThisDomain = pending; + } + + private static void HandleCompilationStarted(object context) + { + _currentCompilationErrorCount = 0; + } + + private static void HandleAssemblyCompilationFinished( + string assemblyPath, + CompilerMessage[] compilerMessages) + { + if (compilerMessages == null) + { + return; + } + + for (int index = 0; index < compilerMessages.Length; index++) + { + if (compilerMessages[index].type == CompilerMessageType.Error) + { + _currentCompilationErrorCount++; + } + } + } + + private static void HandleCompilationFinished(object context) + { + NotifyCompilationFinished(_currentCompilationErrorCount); + } + + private static IReadOnlyList CollectActiveIdentities() + { + IReadOnlyList patches = HotReloadPatcher.DescribeActivePatches(); + IReadOnlyList addedMembers = HotReloadAddedMemberRegistry.Describe(); + List identities = new List(patches.Count + addedMembers.Count); + for (int index = 0; index < patches.Count; index++) + { + identities.Add(patches[index].MethodKey); + } + + for (int index = 0; index < addedMembers.Count; index++) + { + identities.Add(addedMembers[index].MethodKey); + } + + return identities; + } + + private static bool IsDomainReloadDisabledOnEnterPlayMode() + { + if (!EditorSettings.enterPlayModeOptionsEnabled) + { + return false; + } + + return (EditorSettings.enterPlayModeOptions & EnterPlayModeOptions.DisableDomainReload) != 0; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropRecorder.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropRecorder.cs.meta new file mode 100644 index 000000000..9a35a6076 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropRecorder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0e585aae5dc464bc29edd7147bd88bf6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilder.cs new file mode 100644 index 000000000..927b77bc0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilder.cs @@ -0,0 +1,21 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds the --status Message replacement when Play-entry domain reload leftovers remain + /// and no hot-reload changes are currently active. + /// + internal static class HotReloadPlayModeEntryDropStatusMessageBuilder + { + public static string Build(int activeCount, int droppedCount) + { + if (activeCount != 0 || droppedCount <= 0) + { + return null; + } + + return string.Format( + HotReloadConstants.PlayModeEntryDropStatusMessageFormat, + droppedCount); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilder.cs.meta new file mode 100644 index 000000000..449469b41 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPlayModeEntryDropStatusMessageBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4cad9b98bbf494bd9b56d85559c41c53 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 07272d66e..318102519 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -80,12 +80,23 @@ public class HotReloadResponse : UnityCliLoopToolResponse public string RecommendedNextAction { get; set; } = string.Empty; + /// + /// Remaining method identities discarded by the last Play-entry domain reload + /// that have not been recovered by apply, revert-all, or a successful compile. + /// + public int DroppedByPlayModeEntryCount { get; set; } + // Why omit empty: success and validation-only payloads must not grow a next-action // field that PausePoint-style responses leave blank on the wire. public bool ShouldSerializeRecommendedNextAction() { return !string.IsNullOrEmpty(RecommendedNextAction); } + + public bool ShouldSerializeDroppedByPlayModeEntryCount() + { + return DroppedByPlayModeEntryCount > 0; + } } /// @@ -129,6 +140,9 @@ protected override async Task ExecuteAsync( HotReloadOrchestratorResult result = await HotReloadOrchestrator .RunAsync(parameters.Files, contentPathOverride: null, ct) .ConfigureAwait(false); + // Why switch back: SessionState for Play-entry drop recovery is a Unity Editor API. + await MainThreadSwitcher.SwitchToMainThread(ct); + HotReloadPlayModeEntryDropRecorder.NotifyApplyRecovered(result.Methods); return BuildApplyResponse(result); } @@ -137,6 +151,7 @@ private static HotReloadResponse ExecuteRevertAll() { int clearedCount = HotReloadPatcher.ActiveChangeCount; HotReloadPatcher.RevertAll(); + HotReloadPlayModeEntryDropRecorder.NotifyRevertAll(); return new HotReloadResponse { Success = true, @@ -200,12 +215,22 @@ private static HotReloadResponse ExecuteStatus() neverInvokedCount); } + int droppedCount = HotReloadPlayModeEntryDropLedger.Count; + string dropMessage = HotReloadPlayModeEntryDropStatusMessageBuilder.Build( + count, + droppedCount); + if (dropMessage != null) + { + message = dropMessage; + } + return new HotReloadResponse { Success = true, Methods = methods, ActivePatchTotal = count, - Message = message + Message = message, + DroppedByPlayModeEntryCount = droppedCount }; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md index 022e3d8f0..cbe057438 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md @@ -373,6 +373,7 @@ Returns JSON with: - `AddedFields` (array): source-level names ("Type.field") of fields this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. `--revert-all` clears both and reports the combined count in `ClearedCount` +- `DroppedByPlayModeEntryCount` (number): Remaining method identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added`), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `ClearedCount` (number): Patches removed by `--revert-all` (0 on apply) - `Message` (string): Short summary. On `--status`, when any `Active` row has `InvocationCount` 0, Message also appends how many such rows there are and points at `Methods[].Reason`; added-member rows are not included in that count - `RecommendedNextAction` (string): Present when any method outcome is `Failed`. A partial apply (some methods patched or added) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. Omitted on success.