@atelierai/uco 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetConfig.cs +132 -132
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDependencyResolver.cs +237 -237
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDownloader.cs +82 -82
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/MenuItems.cs +247 -247
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/ToolGroups.cs +153 -153
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/SystemTool/Skills.Create.SkillBody.cs +155 -155
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Console.GetLogs.cs +95 -95
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Docs.HttpFetcher.cs +139 -139
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Instance.ListAll.cs +72 -72
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/PathReadHelper.cs +106 -106
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Script.Validate.cs +252 -252
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests/TestResultCollector.cs +537 -537
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests.cs +400 -400
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Branding/ProductInfo.cs +62 -62
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +1034 -1034
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Skills/Skill_InitialSetup.cs +197 -197
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +201 -201
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.cs +67 -67
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotListWindowBase.cs +393 -393
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotWindowBase.cs +113 -113
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.Connection.cs +3 -3
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotFeatures.cs +195 -195
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotServer.cs +20 -20
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CreateGUI.cs +354 -354
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.cs +78 -78
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowInitializer.cs +47 -47
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/SerializationCheckWindow.cs +190 -190
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Build.cs +91 -91
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Config.cs +298 -298
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Static.cs +296 -296
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.cs +69 -69
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PackageUtils.cs +168 -168
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PrefsKeyMigration.cs +119 -119
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/SafeDefaultsGuard.cs +180 -180
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ScriptUtils.cs +229 -229
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolsManifestGenerator.cs +200 -200
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringAdapters.cs +1467 -1467
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringSafetyReloadGuard.cs +65 -65
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityCopilotUpdateProjectSettings.cs +66 -66
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Converter/Json/Structures/EntityIdConverter.cs +120 -120
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.cs +110 -110
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.pre-Unity.6.5.cs +110 -110
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +547 -547
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Build.cs +243 -243
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Config.cs +3 -3
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +295 -295
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginBuilder.cs +105 -105
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.Static.cs +155 -155
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.cs +59 -59
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Utils/EnvironmentUtils.cs +11 -11
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/AtomicApiTestFixtures.cs +99 -99
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/TryModifyAtTests.cs +227 -227
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AuthoringSafetyPilotTests.cs +1050 -1050
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetLegacyMigrationTests.cs +304 -304
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +1 -1
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/BlacklistTypeTests.cs +416 -416
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.cs +172 -172
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.pre-Unity.6.5.cs +169 -169
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.cs +182 -182
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.pre-Unity.6.5.cs +179 -179
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.cs +175 -175
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.pre-Unity.6.5.cs +174 -174
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.cs +171 -171
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.pre-Unity.6.5.cs +170 -170
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsCreateFolderTests.cs +243 -243
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsMaterialTests.cs +101 -101
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.cs +348 -348
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.pre-Unity.6.5.cs +348 -348
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsShaderTests.cs +194 -194
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.cs +573 -573
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.pre-Unity.6.5.cs +513 -513
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.cs +154 -154
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.pre-Unity.6.5.cs +154 -154
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotGameViewTests.cs +164 -164
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotSceneViewTests.cs +155 -155
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolParameterTests.cs +56 -56
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.cs +651 -651
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.pre-Unity.6.5.cs +651 -651
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ToolBatchExecutionIntegrationTests.cs +407 -407
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs → UcoPluginTests.cs} +105 -105
- package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
- /package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs.meta → UcoPluginTests.cs.meta} +0 -0
|
@@ -1,1050 +1,1050 @@
|
|
|
1
|
-
#nullable enable
|
|
2
|
-
|
|
3
|
-
using System;
|
|
4
|
-
using System.Collections;
|
|
5
|
-
using System.Collections.Generic;
|
|
6
|
-
using System.IO;
|
|
7
|
-
using System.Linq;
|
|
8
|
-
using System.Reflection;
|
|
9
|
-
using System.Text.Json;
|
|
10
|
-
using System.Text.Json.Nodes;
|
|
11
|
-
using System.Text.RegularExpressions;
|
|
12
|
-
using System.Threading;
|
|
13
|
-
using System.Threading.Tasks;
|
|
14
|
-
using com.AtelierAI.Unity.Copilot.Editor.API;
|
|
15
|
-
using com.AtelierAI.Unity.Copilot.Editor.Utils;
|
|
16
|
-
using com.AtelierAI.Uco.Framework;
|
|
17
|
-
using com.AtelierAI.Uco.Framework.Common.Model;
|
|
18
|
-
using com.IvanMurzak.ReflectorNet;
|
|
19
|
-
using com.IvanMurzak.ReflectorNet.Utils;
|
|
20
|
-
using Microsoft.Extensions.Logging;
|
|
21
|
-
using NUnit.Framework;
|
|
22
|
-
using UnityEditor;
|
|
23
|
-
using UnityEditor.SceneManagement;
|
|
24
|
-
using UnityEngine;
|
|
25
|
-
using UnityEngine.SceneManagement;
|
|
26
|
-
using UnityEngine.TestTools;
|
|
27
|
-
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
28
|
-
|
|
29
|
-
namespace com.AtelierAI.Unity.Copilot.Editor.Tests
|
|
30
|
-
{
|
|
31
|
-
/// <summary>
|
|
32
|
-
/// g-005 pilot verification against the real Editor: dry-run invariants
|
|
33
|
-
/// (4.5), one-step Undo and truthful reporting (5.5), failure/abort
|
|
34
|
-
/// behaviour (5.6), the production batch entry point (6.4), plan
|
|
35
|
-
/// staleness/reload invalidation, and no-bypass for direct runner calls.
|
|
36
|
-
/// Every call goes through the production tool manager unless the test
|
|
37
|
-
/// is explicitly proving that a bypass attempt fails.
|
|
38
|
-
/// </summary>
|
|
39
|
-
[TestFixture]
|
|
40
|
-
public sealed class AuthoringSafetyPilotTests : BaseTest
|
|
41
|
-
{
|
|
42
|
-
private const string TmpFolderName = "AuthoringSafetyTests_TMP";
|
|
43
|
-
private const string TmpFolder = "Assets/" + TmpFolderName;
|
|
44
|
-
private static readonly BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static;
|
|
45
|
-
|
|
46
|
-
private bool _ignoreFailingMessages;
|
|
47
|
-
private static UnityCopilotPluginEditor _sessionOriginalEditor = null!;
|
|
48
|
-
|
|
49
|
-
[OneTimeSetUp]
|
|
50
|
-
public void CaptureSessionSingleton()
|
|
51
|
-
{
|
|
52
|
-
_sessionOriginalEditor = UnityCopilotPluginEditor.Instance;
|
|
53
|
-
_sessionOriginalEditor.
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
[OneTimeTearDown]
|
|
57
|
-
public void RestoreSessionSingleton()
|
|
58
|
-
{
|
|
59
|
-
// An abandoned coroutine (the runner fails the test on an
|
|
60
|
-
// unexpected error log, e.g. the replacement's hub handshake
|
|
61
|
-
// failing ~10 s after build) never reaches the replacing tests'
|
|
62
|
-
// `finally { RestoreEditorSingleton }`, so a broken double stays
|
|
63
|
-
// installed and every later fixture resolves it instead of the
|
|
64
|
-
// real editor. Fixture-level teardown runs after every test of
|
|
65
|
-
// this fixture regardless of failure or abandonment — restore
|
|
66
|
-
// the captured session singleton whenever one of this fixture's
|
|
67
|
-
// doubles is still installed.
|
|
68
|
-
if (UnityCopilotPluginEditor.Instance is TestUnityCopilotPluginEditor leaked)
|
|
69
|
-
RestoreEditorSingleton(
|
|
70
|
-
_sessionOriginalEditor, _sessionOriginalEditor.UcoPluginInstance, leaked);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
public override IEnumerator SetUp()
|
|
74
|
-
{
|
|
75
|
-
yield return base.SetUp();
|
|
76
|
-
UnityCopilotPluginEditor.Instance.
|
|
77
|
-
// Rejections are logged as errors by the manager ("Error Response
|
|
78
|
-
// to AI"); the assertions here are on responses and Editor state.
|
|
79
|
-
_ignoreFailingMessages = LogAssert.ignoreFailingMessages;
|
|
80
|
-
LogAssert.ignoreFailingMessages = true;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
public override IEnumerator TearDown()
|
|
84
|
-
{
|
|
85
|
-
LogAssert.ignoreFailingMessages = _ignoreFailingMessages;
|
|
86
|
-
if (AssetDatabase.IsValidFolder(TmpFolder))
|
|
87
|
-
AssetDatabase.DeleteAsset(TmpFolder);
|
|
88
|
-
yield return base.TearDown();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ------------------------------------------------------------------
|
|
92
|
-
// 4.5 dry-run invariants
|
|
93
|
-
// ------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
[UnityTest]
|
|
96
|
-
public IEnumerator DryRun_ValidateAndPlan_LeaveEditorUntouched()
|
|
97
|
-
{
|
|
98
|
-
EnsureTmpFolder();
|
|
99
|
-
var host = new GameObject("dry-run-host");
|
|
100
|
-
host.AddComponent<BoxCollider>();
|
|
101
|
-
var parent = new GameObject("dry-run-parent");
|
|
102
|
-
var hostId = ObjectIdJsonValue(host);
|
|
103
|
-
var parentId = ObjectIdJsonValue(parent);
|
|
104
|
-
|
|
105
|
-
var calls = new (string Tool, string Json)[]
|
|
106
|
-
{
|
|
107
|
-
(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""dry-run-created""}"),
|
|
108
|
-
(Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
|
|
109
|
-
(Tool_GameObject.GameObjectComponentAddToolId, $@"{{""componentNames"":[""UnityEngine.SphereCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
|
|
110
|
-
(Tool_GameObject.GameObjectComponentDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}"),
|
|
111
|
-
(Tool_GameObject.GameObjectSetParentToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{parentId}}}}}"),
|
|
112
|
-
(Tool_GameObject.GameObjectDuplicateToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}]}}"),
|
|
113
|
-
(Tool_Scene.SceneCreateToolId, $@"{{""path"":""{TmpFolder}/dry-run.unity""}}"),
|
|
114
|
-
(Tool_Scene.SceneSaveToolId, $@"{{""path"":""{TmpFolder}/dry-run-save.unity""}}"),
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
var before = EditorSnapshot.Capture(TmpFolder);
|
|
118
|
-
foreach (var (tool, json) in calls)
|
|
119
|
-
{
|
|
120
|
-
foreach (var mode in new[] { "validate", "plan" })
|
|
121
|
-
{
|
|
122
|
-
ResponseData<ResponseCallTool>? response = null;
|
|
123
|
-
yield return Call(tool, json, Control(tool + "-" + mode, dryRun: mode), r => response = r);
|
|
124
|
-
Assert.IsNotNull(response, tool);
|
|
125
|
-
Assert.AreEqual(ResponseStatus.Success, response!.Status, $"{tool} {mode}: {response.Message}");
|
|
126
|
-
var result = response.Value!.StructuredContent!["result"]!;
|
|
127
|
-
Assert.AreEqual(mode, result["dryRun"]!.GetValue<string>(), tool);
|
|
128
|
-
Assert.IsTrue(result["valid"]!.GetValue<bool>(), $"{tool} {mode} should validate");
|
|
129
|
-
Assert.IsNull(response.Value.Transaction, $"{tool} {mode} must not open a transaction");
|
|
130
|
-
if (mode == "plan")
|
|
131
|
-
{
|
|
132
|
-
var plan = result["confirmationPlan"]!;
|
|
133
|
-
StringAssert.StartsWith("plan-", plan["planId"]!.GetValue<string>());
|
|
134
|
-
StringAssert.StartsWith("sha256-", plan["planHash"]!.GetValue<string>());
|
|
135
|
-
var targets = plan["targets"]!.AsArray();
|
|
136
|
-
Assert.Greater(targets.Count, 0, $"{tool} plan should list targets");
|
|
137
|
-
foreach (var target in targets)
|
|
138
|
-
StringAssert.StartsWith("sha256-", target!["fingerprint"]!.GetValue<string>(), tool);
|
|
139
|
-
Assert.Greater(plan["predictedEffects"]!.AsArray().Count, 0, tool);
|
|
140
|
-
}
|
|
141
|
-
else
|
|
142
|
-
{
|
|
143
|
-
Assert.IsNull(result["confirmationPlan"], $"{tool} validate must not issue a plan");
|
|
144
|
-
}
|
|
145
|
-
EditorSnapshot.Capture(TmpFolder).AssertEquals(before, $"{tool} {mode}");
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
[UnityTest]
|
|
151
|
-
public IEnumerator Validate_ReportsMissingTargetWithoutMutation()
|
|
152
|
-
{
|
|
153
|
-
var before = EditorSnapshot.Capture(null);
|
|
154
|
-
ResponseData<ResponseCallTool>? response = null;
|
|
155
|
-
yield return Call(
|
|
156
|
-
Tool_GameObject.GameObjectDestroyToolId,
|
|
157
|
-
@"{""gameObjectRef"":{""instanceID"":123456789}}",
|
|
158
|
-
Control("missing-target", dryRun: "validate"),
|
|
159
|
-
r => response = r);
|
|
160
|
-
|
|
161
|
-
Assert.AreEqual(ResponseStatus.Error, response!.Status);
|
|
162
|
-
var error = response.StructuredError ?? response.Value?.StructuredError;
|
|
163
|
-
Assert.AreEqual("validation_failed", error!.Code);
|
|
164
|
-
Assert.AreEqual("target_not_found", error.Details!["reason"]!.GetValue<string>());
|
|
165
|
-
EditorSnapshot.Capture(null).AssertEquals(before, "invalid validate");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ------------------------------------------------------------------
|
|
169
|
-
// Confirmation lifecycle against real targets
|
|
170
|
-
// ------------------------------------------------------------------
|
|
171
|
-
|
|
172
|
-
[UnityTest]
|
|
173
|
-
public IEnumerator Destroy_LegacyIsRejected_PlanIsStaleAfterTargetChange_ThenConfirmedDestroyIsUndoable()
|
|
174
|
-
{
|
|
175
|
-
var victim = new GameObject("victim");
|
|
176
|
-
var id = ObjectIdJsonValue(victim);
|
|
177
|
-
var json = $@"{{""gameObjectRef"":{{""instanceID"":{id}}}}}";
|
|
178
|
-
|
|
179
|
-
// Legacy destructive call: no mutation, copyable retry instruction.
|
|
180
|
-
ResponseData<ResponseCallTool>? legacy = null;
|
|
181
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, null, r => legacy = r);
|
|
182
|
-
Assert.AreEqual(ResponseStatus.Error, legacy!.Status);
|
|
183
|
-
StringAssert.Contains("[" + ToolCallErrorCodes.ConfirmationRequired + "]", legacy.Value!.GetMessage());
|
|
184
|
-
StringAssert.Contains("confirmationPlan", legacy.Value.GetMessage());
|
|
185
|
-
StringAssert.Contains("retryWith", legacy.Value.GetMessage());
|
|
186
|
-
Assert.IsNotNull(GameObject.Find("victim"), "a rejected legacy destroy must not mutate");
|
|
187
|
-
|
|
188
|
-
// Plan, then change the target: the approval is stale.
|
|
189
|
-
ResponseData<ResponseCallTool>? planned = null;
|
|
190
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", dryRun: "plan"), r => planned = r);
|
|
191
|
-
var token = ReadPlan(planned!);
|
|
192
|
-
victim.name = "victim-renamed";
|
|
193
|
-
ResponseData<ResponseCallTool>? stale = null;
|
|
194
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", confirmation: token), r => stale = r);
|
|
195
|
-
Assert.AreEqual(ToolCallErrorCodes.ConfirmationStale, ErrorCode(stale!));
|
|
196
|
-
Assert.IsNotNull(GameObject.Find("victim-renamed"));
|
|
197
|
-
|
|
198
|
-
// Fresh plan on the current target executes once and is undoable.
|
|
199
|
-
ResponseData<ResponseCallTool>? replanned = null;
|
|
200
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", dryRun: "plan"), r => replanned = r);
|
|
201
|
-
var freshToken = ReadPlan(replanned!);
|
|
202
|
-
ResponseData<ResponseCallTool>? executed = null;
|
|
203
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", confirmation: freshToken), r => executed = r);
|
|
204
|
-
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
205
|
-
Assert.IsNull(GameObject.Find("victim-renamed"), "confirmed destroy must destroy");
|
|
206
|
-
var transaction = executed.Value!.Transaction!;
|
|
207
|
-
Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
|
|
208
|
-
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
209
|
-
Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
|
|
210
|
-
Assert.AreEqual("
|
|
211
|
-
|
|
212
|
-
// A replayed token is not a standing capability.
|
|
213
|
-
var again = new GameObject("victim-again");
|
|
214
|
-
ResponseData<ResponseCallTool>? replay = null;
|
|
215
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(again)}}}}}", Control("destroy-2", confirmation: freshToken), r => replay = r);
|
|
216
|
-
Assert.AreEqual(ResponseStatus.Error, replay!.Status);
|
|
217
|
-
Assert.IsNotNull(GameObject.Find("victim-again"));
|
|
218
|
-
|
|
219
|
-
Undo.PerformUndo();
|
|
220
|
-
Assert.IsNotNull(GameObject.Find("victim-renamed"), "one Undo step must restore the destroyed object");
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
[UnityTest]
|
|
224
|
-
public IEnumerator ReloadGuard_InvalidatesIssuedPlans()
|
|
225
|
-
{
|
|
226
|
-
var victim = new GameObject("reload-victim");
|
|
227
|
-
var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(victim)}}}}}";
|
|
228
|
-
ResponseData<ResponseCallTool>? planned = null;
|
|
229
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", dryRun: "plan"), r => planned = r);
|
|
230
|
-
var token = ReadPlan(planned!);
|
|
231
|
-
|
|
232
|
-
Assert.GreaterOrEqual(UnityAuthoringSafetyReloadGuard.InvalidateConfirmationPlans(), 1,
|
|
233
|
-
"the live pipeline must expose at least one confirmation store");
|
|
234
|
-
|
|
235
|
-
ResponseData<ResponseCallTool>? afterReload = null;
|
|
236
|
-
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", confirmation: token), r => afterReload = r);
|
|
237
|
-
Assert.AreEqual(ToolCallErrorCodes.ConfirmationInvalid, ErrorCode(afterReload!));
|
|
238
|
-
Assert.IsNotNull(GameObject.Find("reload-victim"));
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ------------------------------------------------------------------
|
|
242
|
-
// 5.5 one-step Undo, dirty state, full versus partial
|
|
243
|
-
// ------------------------------------------------------------------
|
|
244
|
-
|
|
245
|
-
[UnityTest]
|
|
246
|
-
public IEnumerator Create_IsOneUndoStepWithFullReport()
|
|
247
|
-
{
|
|
248
|
-
var scene = SceneManager.GetActiveScene();
|
|
249
|
-
var groupBefore = Undo.GetCurrentGroup();
|
|
250
|
-
ResponseData<ResponseCallTool>? response = null;
|
|
251
|
-
yield return Call(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""undo-created""}", Control("create"), r => response = r);
|
|
252
|
-
|
|
253
|
-
Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
|
|
254
|
-
var created = GameObject.Find("undo-created");
|
|
255
|
-
Assert.IsNotNull(created);
|
|
256
|
-
Assert.IsTrue(scene.isDirty || !scene.IsValid() || string.IsNullOrEmpty(scene.path),
|
|
257
|
-
"an executing create marks the active scene dirty");
|
|
258
|
-
var transaction = response.Value!.Transaction!;
|
|
259
|
-
Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
|
|
260
|
-
Assert.AreEqual("
|
|
261
|
-
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
262
|
-
Assert.AreEqual("none", transaction["rollback"]!.GetValue<string>());
|
|
263
|
-
Assert.Greater(transaction["affectedObjects"]!.AsArray().Count, 0);
|
|
264
|
-
Assert.Greater(Undo.GetCurrentGroup(), groupBefore, "the call must own a new Undo group");
|
|
265
|
-
|
|
266
|
-
Undo.PerformUndo();
|
|
267
|
-
Assert.IsNull(GameObject.Find("undo-created"), "one Undo step removes the created object");
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
[UnityTest]
|
|
271
|
-
public IEnumerator ComponentAddAndSetParent_AreOneUndoStepEach()
|
|
272
|
-
{
|
|
273
|
-
var host = new GameObject("undo-host");
|
|
274
|
-
var parent = new GameObject("undo-parent");
|
|
275
|
-
var hostId = ObjectIdJsonValue(host);
|
|
276
|
-
|
|
277
|
-
ResponseData<ResponseCallTool>? add = null;
|
|
278
|
-
yield return Call(
|
|
279
|
-
Tool_GameObject.GameObjectComponentAddToolId,
|
|
280
|
-
$@"{{""componentNames"":[""UnityEngine.BoxCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}",
|
|
281
|
-
Control("add"),
|
|
282
|
-
r => add = r);
|
|
283
|
-
Assert.AreEqual(ResponseStatus.Success, add!.Status, add.Message);
|
|
284
|
-
Assert.IsNotNull(host.GetComponent<BoxCollider>());
|
|
285
|
-
Assert.AreEqual("full", add.Value!.Transaction!["undo"]!.GetValue<string>());
|
|
286
|
-
Undo.PerformUndo();
|
|
287
|
-
Assert.IsNull(host.GetComponent<BoxCollider>(), "one Undo step removes the added component");
|
|
288
|
-
|
|
289
|
-
ResponseData<ResponseCallTool>? reparent = null;
|
|
290
|
-
yield return Call(
|
|
291
|
-
Tool_GameObject.GameObjectSetParentToolId,
|
|
292
|
-
$@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(parent)}}}}}",
|
|
293
|
-
Control("parent"),
|
|
294
|
-
r => reparent = r);
|
|
295
|
-
Assert.AreEqual(ResponseStatus.Success, reparent!.Status, reparent.Message);
|
|
296
|
-
Assert.AreEqual(parent.transform, host.transform.parent);
|
|
297
|
-
Assert.AreEqual("full", reparent.Value!.Transaction!["undo"]!.GetValue<string>());
|
|
298
|
-
Undo.PerformUndo();
|
|
299
|
-
Assert.IsNull(host.transform.parent, "one Undo step restores the previous parent");
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
[UnityTest]
|
|
303
|
-
public IEnumerator ComponentDestroy_UsesUndoAwareDestroyAndIsUndoable()
|
|
304
|
-
{
|
|
305
|
-
var host = new GameObject("component-destroy-host");
|
|
306
|
-
host.AddComponent<BoxCollider>();
|
|
307
|
-
ResponseData<ResponseCallTool>? planned = null;
|
|
308
|
-
var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(host)}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}";
|
|
309
|
-
yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", dryRun: "plan"), r => planned = r);
|
|
310
|
-
ResponseData<ResponseCallTool>? executed = null;
|
|
311
|
-
yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", confirmation: ReadPlan(planned!)), r => executed = r);
|
|
312
|
-
|
|
313
|
-
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
314
|
-
Assert.IsNull(host.GetComponent<BoxCollider>());
|
|
315
|
-
Assert.AreEqual("full", executed.Value!.Transaction!["undo"]!.GetValue<string>());
|
|
316
|
-
Undo.PerformUndo();
|
|
317
|
-
Assert.IsNotNull(host.GetComponent<BoxCollider>(), "Undo.DestroyObjectImmediate makes the deletion undoable");
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
[UnityTest]
|
|
321
|
-
public IEnumerator Modify_ReportsPartialUndoTruthfully()
|
|
322
|
-
{
|
|
323
|
-
var target = new GameObject("modify-me");
|
|
324
|
-
var arguments = new Dictionary<string, JsonElement>
|
|
325
|
-
{
|
|
326
|
-
["gameObjectRefs"] = JsonSerializer.SerializeToElement(new[] { new { instanceID = ObjectIdWireValue(target) } }),
|
|
327
|
-
["jsonPatchesPerGameObject"] = JsonSerializer.SerializeToElement(new[] { "{\"name\":\"modified-name\"}" }),
|
|
328
|
-
};
|
|
329
|
-
ResponseData<ResponseCallTool>? response = null;
|
|
330
|
-
yield return Call(Tool_GameObject.GameObjectModifyToolId, arguments, Control("modify"), r => response = r);
|
|
331
|
-
|
|
332
|
-
Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
|
|
333
|
-
Assert.AreEqual("modified-name", target.name);
|
|
334
|
-
var transaction = response.Value!.Transaction!;
|
|
335
|
-
Assert.AreEqual("partial", transaction["undo"]!.GetValue<string>(), "reflected patches are advertised as partial");
|
|
336
|
-
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
337
|
-
Undo.PerformUndo();
|
|
338
|
-
Assert.AreEqual("modify-me", target.name, "the recorded complete snapshot restores the name");
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// ------------------------------------------------------------------
|
|
342
|
-
// 5.6 failure/abort and no-bypass
|
|
343
|
-
// ------------------------------------------------------------------
|
|
344
|
-
|
|
345
|
-
[UnityTest]
|
|
346
|
-
public IEnumerator DirectRunnerInvocation_FailsClosedWithoutMutation()
|
|
347
|
-
{
|
|
348
|
-
var manager = UnityCopilotPluginEditor.Instance.Tools!;
|
|
349
|
-
var runner = manager.GetAllTools().First(tool => tool.Name == Tool_GameObject.GameObjectCreateToolId);
|
|
350
|
-
var before = EditorSnapshot.Capture(null);
|
|
351
|
-
|
|
352
|
-
LogAssert.ignoreFailingMessages = true;
|
|
353
|
-
var direct = runner.Run("direct-bypass", new Dictionary<string, JsonElement>
|
|
354
|
-
{
|
|
355
|
-
["name"] = JsonSerializer.SerializeToElement("bypass-created"),
|
|
356
|
-
});
|
|
357
|
-
yield return WaitForTask(direct);
|
|
358
|
-
|
|
359
|
-
Assert.AreEqual(ResponseStatus.Error, direct.Result.Status, "a direct runner call must fail closed");
|
|
360
|
-
Assert.IsNull(GameObject.Find("bypass-created"), "no object may be created outside the policy pipeline");
|
|
361
|
-
EditorSnapshot.Capture(null).AssertEquals(before, "direct bypass");
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
[UnityTest]
|
|
365
|
-
public IEnumerator RunnerFailureAfterMutation_RevertsOnlyItsGroup()
|
|
366
|
-
{
|
|
367
|
-
var originalInstance = UnityCopilotPluginEditor.Instance;
|
|
368
|
-
originalInstance.
|
|
369
|
-
var originalPlugin = originalInstance.UcoPluginInstance;
|
|
370
|
-
var failing = new FailingCreateRunner();
|
|
371
|
-
var replacement = new TestUnityCopilotPluginEditor(failing);
|
|
372
|
-
SetEditorSingleton(replacement);
|
|
373
|
-
try
|
|
374
|
-
{
|
|
375
|
-
replacement.
|
|
376
|
-
var manager = replacement.Tools!;
|
|
377
|
-
|
|
378
|
-
// Unrelated earlier history that must survive the abort.
|
|
379
|
-
var unrelated = new GameObject("unrelated-history");
|
|
380
|
-
Undo.RegisterCreatedObjectUndo(unrelated, "unrelated");
|
|
381
|
-
Undo.IncrementCurrentGroup();
|
|
382
|
-
|
|
383
|
-
var request = new RequestCallTool("failing-request", failing.Name, new Dictionary<string, JsonElement>(), Control("failing"));
|
|
384
|
-
LogAssert.ignoreFailingMessages = true;
|
|
385
|
-
var execution = manager.RunCallTool(request);
|
|
386
|
-
yield return WaitForTask(execution);
|
|
387
|
-
var response = execution.Result;
|
|
388
|
-
|
|
389
|
-
Assert.AreEqual(ResponseStatus.Error, response.Status);
|
|
390
|
-
Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName), "the aborted group reverts the created object");
|
|
391
|
-
Assert.IsNotNull(GameObject.Find("unrelated-history"), "unrelated Undo history is preserved");
|
|
392
|
-
var transaction = response.Value!.Transaction!;
|
|
393
|
-
Assert.IsTrue(transaction["aborted"]!.GetValue<bool>());
|
|
394
|
-
Assert.IsFalse(transaction["completed"]!.GetValue<bool>());
|
|
395
|
-
Assert.AreEqual("complete", transaction["rollback"]!.GetValue<string>());
|
|
396
|
-
Assert.AreEqual(1, failing.Invocations);
|
|
397
|
-
}
|
|
398
|
-
finally
|
|
399
|
-
{
|
|
400
|
-
RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// ------------------------------------------------------------------
|
|
405
|
-
// 6.4 production batch with pilot children
|
|
406
|
-
// ------------------------------------------------------------------
|
|
407
|
-
|
|
408
|
-
[UnityTest]
|
|
409
|
-
public IEnumerator Batch_PilotChildren_CollapseToOneUndoStep()
|
|
410
|
-
{
|
|
411
|
-
var commands = new[]
|
|
412
|
-
{
|
|
413
|
-
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-a")) },
|
|
414
|
-
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-b")) },
|
|
415
|
-
};
|
|
416
|
-
var arguments = new Dictionary<string, JsonElement>
|
|
417
|
-
{
|
|
418
|
-
["commands"] = JsonSerializer.SerializeToElement(commands),
|
|
419
|
-
["failFast"] = JsonSerializer.SerializeToElement(true),
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
ResponseData<ResponseCallTool>? planned = null;
|
|
423
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", dryRun: "plan"), r => planned = r);
|
|
424
|
-
Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
|
|
425
|
-
Assert.IsNull(GameObject.Find("batch-a"), "planning a batch must not create children");
|
|
426
|
-
var plan = planned.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
|
|
427
|
-
Assert.GreaterOrEqual(plan["targets"]!.AsArray().Count, 2);
|
|
428
|
-
|
|
429
|
-
ResponseData<ResponseCallTool>? executed = null;
|
|
430
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", confirmation: ReadPlan(planned)), r => executed = r);
|
|
431
|
-
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
432
|
-
var batchResult = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
|
|
433
|
-
executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
|
|
434
|
-
Assert.AreEqual(2, batchResult.Succeeded, string.Join("; ", batchResult.Results.Select(r => r.Error)));
|
|
435
|
-
Assert.IsNotNull(GameObject.Find("batch-a"));
|
|
436
|
-
Assert.IsNotNull(GameObject.Find("batch-b"));
|
|
437
|
-
var transaction = executed.Value.Transaction!;
|
|
438
|
-
Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
|
|
439
|
-
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
440
|
-
Assert.AreEqual("full", transaction["undo"]!.GetValue<string>(), "two fully recorded children keep aggregate full Undo");
|
|
441
|
-
Assert.AreEqual(2, transaction["affectedObjects"]!.AsArray().Count, "both children attach to the parent group");
|
|
442
|
-
for (var index = 0; index < batchResult.Results.Length; index++)
|
|
443
|
-
{
|
|
444
|
-
var child = batchResult.Results[index];
|
|
445
|
-
Assert.IsTrue(child.Ok);
|
|
446
|
-
Assert.IsNotNull(child.Transaction, "each shared child reports its own transaction contribution");
|
|
447
|
-
Assert.IsTrue(child.Transaction!["shared"]!.GetValue<bool>());
|
|
448
|
-
Assert.IsFalse(child.Transaction["pending"]!.GetValue<bool>());
|
|
449
|
-
Assert.IsTrue(child.Transaction["completed"]!.GetValue<bool>());
|
|
450
|
-
Assert.IsFalse(child.Transaction["aborted"]!.GetValue<bool>());
|
|
451
|
-
Assert.AreEqual("none", child.Transaction["rollback"]!.GetValue<string>());
|
|
452
|
-
Assert.AreEqual("full", child.Transaction["undo"]!.GetValue<string>());
|
|
453
|
-
Assert.AreEqual(transaction["groupId"]!.GetValue<int>(), child.Transaction["groupId"]!.GetValue<int>());
|
|
454
|
-
Assert.AreEqual(transaction["groupLabel"]!.GetValue<string>(), child.Transaction["groupLabel"]!.GetValue<string>());
|
|
455
|
-
var affectedNames = child.Transaction["affectedObjects"]!.AsArray()
|
|
456
|
-
.Select(value => value!["name"]!.GetValue<string>())
|
|
457
|
-
.Distinct(StringComparer.Ordinal)
|
|
458
|
-
.ToArray();
|
|
459
|
-
CollectionAssert.AreEqual(new[] { index == 0 ? "batch-a" : "batch-b" }, affectedNames,
|
|
460
|
-
"a child report may retain multiple recording events, but only for that child's object");
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
Undo.PerformUndo();
|
|
464
|
-
Assert.IsNull(GameObject.Find("batch-a"), "one Undo step reverts the whole batch");
|
|
465
|
-
Assert.IsNull(GameObject.Find("batch-b"));
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
[UnityTest]
|
|
469
|
-
public IEnumerator Batch_SharedChildFailure_RevertsPriorChildAndFinalizesTruthfulResults()
|
|
470
|
-
{
|
|
471
|
-
var originalInstance = UnityCopilotPluginEditor.Instance;
|
|
472
|
-
originalInstance.
|
|
473
|
-
var originalPlugin = originalInstance.UcoPluginInstance;
|
|
474
|
-
var failing = new FailingCreateRunner();
|
|
475
|
-
var replacement = new TestUnityCopilotPluginEditor(failing);
|
|
476
|
-
SetEditorSingleton(replacement);
|
|
477
|
-
try
|
|
478
|
-
{
|
|
479
|
-
replacement.
|
|
480
|
-
var unrelated = new GameObject("batch-unrelated-history");
|
|
481
|
-
Undo.RegisterCreatedObjectUndo(unrelated, "batch unrelated");
|
|
482
|
-
Undo.IncrementCurrentGroup();
|
|
483
|
-
|
|
484
|
-
var commands = new[]
|
|
485
|
-
{
|
|
486
|
-
new Tool_Batch.BatchCommand
|
|
487
|
-
{
|
|
488
|
-
Tool = Tool_GameObject.GameObjectCreateToolId,
|
|
489
|
-
Params = Params(("name", "batch-shared-restored")),
|
|
490
|
-
},
|
|
491
|
-
new Tool_Batch.BatchCommand
|
|
492
|
-
{
|
|
493
|
-
Tool = failing.Name,
|
|
494
|
-
Params = new Dictionary<string, JsonElement>(),
|
|
495
|
-
},
|
|
496
|
-
new Tool_Batch.BatchCommand
|
|
497
|
-
{
|
|
498
|
-
Tool = Tool_GameObject.GameObjectCreateToolId,
|
|
499
|
-
Params = Params(("name", "batch-shared-skipped")),
|
|
500
|
-
},
|
|
501
|
-
};
|
|
502
|
-
var arguments = new Dictionary<string, JsonElement>
|
|
503
|
-
{
|
|
504
|
-
["commands"] = JsonSerializer.SerializeToElement(commands),
|
|
505
|
-
["failFast"] = JsonSerializer.SerializeToElement(true),
|
|
506
|
-
};
|
|
507
|
-
|
|
508
|
-
ResponseData<ResponseCallTool>? planned = null;
|
|
509
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
510
|
-
Control("batch-shared-failure", dryRun: "plan"), r => planned = r);
|
|
511
|
-
Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
|
|
512
|
-
|
|
513
|
-
ResponseData<ResponseCallTool>? executed = null;
|
|
514
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
515
|
-
Control("batch-shared-failure", confirmation: ReadPlan(planned)), r => executed = r);
|
|
516
|
-
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
517
|
-
var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
|
|
518
|
-
executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
|
|
519
|
-
|
|
520
|
-
Assert.AreEqual(0, result.Succeeded);
|
|
521
|
-
Assert.AreEqual(2, result.Failed);
|
|
522
|
-
Assert.IsTrue(result.Aborted);
|
|
523
|
-
Assert.IsNull(GameObject.Find("batch-shared-restored"),
|
|
524
|
-
"the parent abort restores a prior successful shared child");
|
|
525
|
-
Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName),
|
|
526
|
-
"the failing child's mutation is restored by the same parent group");
|
|
527
|
-
Assert.IsNull(GameObject.Find("batch-shared-skipped"));
|
|
528
|
-
Assert.IsNotNull(GameObject.Find("batch-unrelated-history"),
|
|
529
|
-
"aborting the batch must preserve unrelated Undo history");
|
|
530
|
-
|
|
531
|
-
var prior = result.Results[0];
|
|
532
|
-
Assert.IsFalse(prior.Ok, "a rolled-back child must not remain successful");
|
|
533
|
-
Assert.IsFalse(prior.Skipped);
|
|
534
|
-
Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
|
|
535
|
-
Assert.IsNull(prior.Data);
|
|
536
|
-
Assert.IsNotNull(prior.Transaction);
|
|
537
|
-
Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
|
|
538
|
-
Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
|
|
539
|
-
Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
|
|
540
|
-
Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
|
|
541
|
-
Assert.AreEqual("complete", prior.Transaction["rollback"]!.GetValue<string>());
|
|
542
|
-
Assert.AreEqual("full", prior.Transaction["undo"]!.GetValue<string>());
|
|
543
|
-
var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
|
|
544
|
-
.Select(value => value!["name"]!.GetValue<string>())
|
|
545
|
-
.Distinct(StringComparer.Ordinal)
|
|
546
|
-
.ToArray();
|
|
547
|
-
CollectionAssert.AreEqual(new[] { "batch-shared-restored" }, priorAffectedNames,
|
|
548
|
-
"the rolled-back child report may contain multiple record events, but only for its object");
|
|
549
|
-
|
|
550
|
-
var failed = result.Results[1];
|
|
551
|
-
Assert.IsFalse(failed.Ok);
|
|
552
|
-
Assert.IsFalse(failed.Skipped);
|
|
553
|
-
Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
|
|
554
|
-
"the failing child retains its original runtime error");
|
|
555
|
-
Assert.IsNotNull(failed.Transaction);
|
|
556
|
-
Assert.IsTrue(failed.Transaction!["aborted"]!.GetValue<bool>());
|
|
557
|
-
Assert.AreEqual("complete", failed.Transaction["rollback"]!.GetValue<string>());
|
|
558
|
-
Assert.IsTrue(result.Results[2].Skipped);
|
|
559
|
-
Assert.IsFalse(result.Results[2].Ok);
|
|
560
|
-
}
|
|
561
|
-
finally
|
|
562
|
-
{
|
|
563
|
-
RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
[UnityTest]
|
|
568
|
-
public IEnumerator Batch_PartialSharedChildFailure_ReportsUncertainFinalStateAndRetainsContext()
|
|
569
|
-
{
|
|
570
|
-
var originalInstance = UnityCopilotPluginEditor.Instance;
|
|
571
|
-
originalInstance.
|
|
572
|
-
var originalPlugin = originalInstance.UcoPluginInstance;
|
|
573
|
-
var failing = new FailingCreateRunner();
|
|
574
|
-
var replacement = new TestUnityCopilotPluginEditor(failing);
|
|
575
|
-
SetEditorSingleton(replacement);
|
|
576
|
-
try
|
|
577
|
-
{
|
|
578
|
-
replacement.
|
|
579
|
-
var target = new GameObject("batch-shared-partial");
|
|
580
|
-
var unrelated = new GameObject("batch-partial-unrelated-history");
|
|
581
|
-
Undo.RegisterCreatedObjectUndo(unrelated, "batch partial unrelated");
|
|
582
|
-
Undo.IncrementCurrentGroup();
|
|
583
|
-
|
|
584
|
-
var commands = new[]
|
|
585
|
-
{
|
|
586
|
-
new Tool_Batch.BatchCommand
|
|
587
|
-
{
|
|
588
|
-
Tool = Tool_GameObject.GameObjectModifyToolId,
|
|
589
|
-
Params = Params(
|
|
590
|
-
("gameObjectRefs", new[] { new { instanceID = ObjectIdWireValue(target) } }),
|
|
591
|
-
("jsonPatchesPerGameObject", new[] { "{\"name\":\"batch-shared-partial-modified\"}" })),
|
|
592
|
-
},
|
|
593
|
-
new Tool_Batch.BatchCommand
|
|
594
|
-
{
|
|
595
|
-
Tool = failing.Name,
|
|
596
|
-
Params = new Dictionary<string, JsonElement>(),
|
|
597
|
-
},
|
|
598
|
-
new Tool_Batch.BatchCommand
|
|
599
|
-
{
|
|
600
|
-
Tool = Tool_GameObject.GameObjectCreateToolId,
|
|
601
|
-
Params = Params(("name", "batch-partial-skipped")),
|
|
602
|
-
},
|
|
603
|
-
};
|
|
604
|
-
var arguments = new Dictionary<string, JsonElement>
|
|
605
|
-
{
|
|
606
|
-
["commands"] = JsonSerializer.SerializeToElement(commands),
|
|
607
|
-
["failFast"] = JsonSerializer.SerializeToElement(true),
|
|
608
|
-
};
|
|
609
|
-
|
|
610
|
-
ResponseData<ResponseCallTool>? planned = null;
|
|
611
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
612
|
-
Control("batch-partial-failure", dryRun: "plan"), r => planned = r);
|
|
613
|
-
Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
|
|
614
|
-
|
|
615
|
-
ResponseData<ResponseCallTool>? executed = null;
|
|
616
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
617
|
-
Control("batch-partial-failure", confirmation: ReadPlan(planned)), r => executed = r);
|
|
618
|
-
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
619
|
-
var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
|
|
620
|
-
executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
|
|
621
|
-
|
|
622
|
-
Assert.AreEqual(0, result.Succeeded);
|
|
623
|
-
Assert.AreEqual(2, result.Failed);
|
|
624
|
-
Assert.IsTrue(result.Aborted);
|
|
625
|
-
Assert.IsNotNull(GameObject.Find("batch-partial-unrelated-history"),
|
|
626
|
-
"aborting a partial batch must preserve unrelated Undo history");
|
|
627
|
-
Assert.IsNull(GameObject.Find("batch-partial-skipped"));
|
|
628
|
-
|
|
629
|
-
var parentTransaction = executed.Value.Transaction!;
|
|
630
|
-
Assert.IsTrue(parentTransaction["aborted"]!.GetValue<bool>());
|
|
631
|
-
Assert.IsFalse(parentTransaction["completed"]!.GetValue<bool>());
|
|
632
|
-
Assert.AreEqual("partial", parentTransaction["rollback"]!.GetValue<string>());
|
|
633
|
-
|
|
634
|
-
var prior = result.Results[0];
|
|
635
|
-
Assert.IsFalse(prior.Ok, "an aborted prior mutation must not remain successful");
|
|
636
|
-
Assert.IsFalse(prior.Skipped);
|
|
637
|
-
Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
|
|
638
|
-
StringAssert.Contains("final mutation state is uncertain", prior.Error);
|
|
639
|
-
StringAssert.DoesNotContain("did not remain applied", prior.Error);
|
|
640
|
-
Assert.IsNotNull(prior.Data, "uncertain rollback retains bounded child diagnostics");
|
|
641
|
-
Assert.LessOrEqual(prior.Data!.ToString()!.Length, 1024);
|
|
642
|
-
Assert.IsNotNull(prior.Transaction);
|
|
643
|
-
Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
|
|
644
|
-
Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
|
|
645
|
-
Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
|
|
646
|
-
Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
|
|
647
|
-
Assert.AreEqual("partial", prior.Transaction["rollback"]!.GetValue<string>());
|
|
648
|
-
Assert.AreEqual("partial", prior.Transaction["undo"]!.GetValue<string>());
|
|
649
|
-
var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
|
|
650
|
-
.Select(value => value!["name"]!.GetValue<string>())
|
|
651
|
-
.Distinct(StringComparer.Ordinal)
|
|
652
|
-
.ToArray();
|
|
653
|
-
CollectionAssert.Contains(priorAffectedNames, "batch-shared-partial");
|
|
654
|
-
|
|
655
|
-
var failed = result.Results[1];
|
|
656
|
-
Assert.IsFalse(failed.Ok);
|
|
657
|
-
Assert.IsFalse(failed.Skipped);
|
|
658
|
-
Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
|
|
659
|
-
"the failing child retains its original runtime error");
|
|
660
|
-
Assert.AreEqual("Tool execution failed.", failed.Error,
|
|
661
|
-
"parent finalization must not replace the failing child's original safe error");
|
|
662
|
-
Assert.IsNotNull(failed.Transaction);
|
|
663
|
-
Assert.IsTrue(failed.Transaction!["shared"]!.GetValue<bool>());
|
|
664
|
-
Assert.IsFalse(failed.Transaction["pending"]!.GetValue<bool>());
|
|
665
|
-
Assert.IsTrue(failed.Transaction["aborted"]!.GetValue<bool>());
|
|
666
|
-
Assert.AreEqual("partial", failed.Transaction["rollback"]!.GetValue<string>());
|
|
667
|
-
|
|
668
|
-
Assert.IsTrue(result.Results[2].Skipped);
|
|
669
|
-
Assert.IsFalse(result.Results[2].Ok);
|
|
670
|
-
}
|
|
671
|
-
finally
|
|
672
|
-
{
|
|
673
|
-
RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
[UnityTest]
|
|
678
|
-
public IEnumerator Batch_AllowsUndoNoneAndRejectsInvalidChildBeforeAnyMutation()
|
|
679
|
-
{
|
|
680
|
-
EnsureTmpFolder();
|
|
681
|
-
var undoNone = new Dictionary<string, JsonElement>
|
|
682
|
-
{
|
|
683
|
-
["commands"] = JsonSerializer.SerializeToElement(new[]
|
|
684
|
-
{
|
|
685
|
-
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-undo-none")) },
|
|
686
|
-
new Tool_Batch.BatchCommand { Tool = Tool_Scene.SceneSaveToolId, Params = Params(("path", $"{TmpFolder}/batch-undo-none.unity")) },
|
|
687
|
-
}),
|
|
688
|
-
};
|
|
689
|
-
ResponseData<ResponseCallTool>? undoNonePlan = null;
|
|
690
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, undoNone, Control("batch-undo-none", dryRun: "plan"), r => undoNonePlan = r);
|
|
691
|
-
Assert.AreEqual(ResponseStatus.Success, undoNonePlan!.Status, undoNonePlan.Message);
|
|
692
|
-
var aggregatePlan = undoNonePlan.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
|
|
693
|
-
Assert.AreEqual("none", aggregatePlan["undo"]!.GetValue<string>());
|
|
694
|
-
Assert.IsNull(GameObject.Find("batch-undo-none"), "planning must not execute the first child");
|
|
695
|
-
|
|
696
|
-
var before = EditorSnapshot.Capture(null);
|
|
697
|
-
var missingTarget = new Dictionary<string, JsonElement>
|
|
698
|
-
{
|
|
699
|
-
["commands"] = JsonSerializer.SerializeToElement(new[]
|
|
700
|
-
{
|
|
701
|
-
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-2")) },
|
|
702
|
-
new Tool_Batch.BatchCommand
|
|
703
|
-
{
|
|
704
|
-
Tool = Tool_GameObject.GameObjectDestroyToolId,
|
|
705
|
-
Params = new Dictionary<string, JsonElement>
|
|
706
|
-
{
|
|
707
|
-
["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = 123456789 }),
|
|
708
|
-
},
|
|
709
|
-
},
|
|
710
|
-
}),
|
|
711
|
-
};
|
|
712
|
-
ResponseData<ResponseCallTool>? missingPlan = null;
|
|
713
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, missingTarget, Control("batch-missing", dryRun: "plan"), r => missingPlan = r);
|
|
714
|
-
Assert.AreEqual(ResponseStatus.Error, missingPlan!.Status);
|
|
715
|
-
Assert.IsNull(GameObject.Find("batch-rejected-2"));
|
|
716
|
-
|
|
717
|
-
// A confirmed batch is still rejected by preflight when a child
|
|
718
|
-
// cannot be planned at execution time (its target vanished).
|
|
719
|
-
var victim = new GameObject("batch-victim");
|
|
720
|
-
var vanishing = new Dictionary<string, JsonElement>
|
|
721
|
-
{
|
|
722
|
-
["commands"] = JsonSerializer.SerializeToElement(new[]
|
|
723
|
-
{
|
|
724
|
-
new Tool_Batch.BatchCommand
|
|
725
|
-
{
|
|
726
|
-
Tool = Tool_GameObject.GameObjectDestroyToolId,
|
|
727
|
-
Params = new Dictionary<string, JsonElement>
|
|
728
|
-
{
|
|
729
|
-
["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = ObjectIdWireValue(victim) }),
|
|
730
|
-
},
|
|
731
|
-
},
|
|
732
|
-
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-3")) },
|
|
733
|
-
}),
|
|
734
|
-
};
|
|
735
|
-
ResponseData<ResponseCallTool>? vanishingPlan = null;
|
|
736
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", dryRun: "plan"), r => vanishingPlan = r);
|
|
737
|
-
Assert.AreEqual(ResponseStatus.Success, vanishingPlan!.Status, vanishingPlan.Message);
|
|
738
|
-
UnityEngine.Object.DestroyImmediate(victim);
|
|
739
|
-
ResponseData<ResponseCallTool>? vanishingExec = null;
|
|
740
|
-
yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", confirmation: ReadPlan(vanishingPlan)), r => vanishingExec = r);
|
|
741
|
-
Assert.AreEqual(ResponseStatus.Error, vanishingExec!.Status, "the parent plan is stale once a child target vanished");
|
|
742
|
-
Assert.IsNull(GameObject.Find("batch-rejected-3"), "no child may run when the aggregate approval fails");
|
|
743
|
-
|
|
744
|
-
EditorSnapshot.Capture(null).AssertEquals(before, "rejected batches");
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// ------------------------------------------------------------------
|
|
748
|
-
// 5.5 static regressions: direct DestroyImmediate / mutation-before-recording
|
|
749
|
-
// ------------------------------------------------------------------
|
|
750
|
-
|
|
751
|
-
[Test]
|
|
752
|
-
public void PilotSources_NeverUseDirectDestroyImmediateOrMutateBeforeRecording()
|
|
753
|
-
{
|
|
754
|
-
var toolDirectory = Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "API", "Tool"));
|
|
755
|
-
Assert.IsTrue(Directory.Exists(toolDirectory), toolDirectory);
|
|
756
|
-
var pilotFiles = Directory.GetFiles(toolDirectory, "*.cs")
|
|
757
|
-
.Where(file =>
|
|
758
|
-
{
|
|
759
|
-
var name = Path.GetFileName(file);
|
|
760
|
-
return name.StartsWith("GameObject.", StringComparison.Ordinal)
|
|
761
|
-
|| name.StartsWith("Scene.", StringComparison.Ordinal);
|
|
762
|
-
})
|
|
763
|
-
.Where(file => File.ReadAllText(file).Contains("[AuthoringCapability("))
|
|
764
|
-
.ToArray();
|
|
765
|
-
Assert.GreaterOrEqual(pilotFiles.Length, 13, "expected the Scene/GameObject/Component pilot files");
|
|
766
|
-
|
|
767
|
-
var directDestroy = new Regex(@"(?<!Undo\.)\bDestroyImmediate\s*\(");
|
|
768
|
-
foreach (var file in pilotFiles)
|
|
769
|
-
{
|
|
770
|
-
var source = File.ReadAllText(file);
|
|
771
|
-
Assert.IsFalse(directDestroy.IsMatch(source), $"{Path.GetFileName(file)} must not call a non-Undo DestroyImmediate");
|
|
772
|
-
StringAssert.Contains("UnityAuthoringUndo.RequireAuthoringScope()", source, Path.GetFileName(file));
|
|
773
|
-
var scopeIndex = source.IndexOf("UnityAuthoringUndo.RequireAuthoringScope()", StringComparison.Ordinal);
|
|
774
|
-
var markIndex = source.IndexOf("UnityAuthoringUndo.MarkMutated()", StringComparison.Ordinal);
|
|
775
|
-
if (markIndex >= 0)
|
|
776
|
-
{
|
|
777
|
-
Assert.Less(scopeIndex, markIndex, $"{Path.GetFileName(file)}: scope check must precede the mutation mark");
|
|
778
|
-
var recordIndex = new[]
|
|
779
|
-
{
|
|
780
|
-
source.IndexOf("UnityAuthoringUndo.RecordCreated(", StringComparison.Ordinal),
|
|
781
|
-
source.IndexOf("UnityAuthoringUndo.RecordModified(", StringComparison.Ordinal),
|
|
782
|
-
source.IndexOf("UnityAuthoringUndo.RecordHostRegistered(", StringComparison.Ordinal),
|
|
783
|
-
source.IndexOf("UnityAuthoringUndo.AddComponent(", StringComparison.Ordinal),
|
|
784
|
-
source.IndexOf("UnityAuthoringUndo.SetParent(", StringComparison.Ordinal),
|
|
785
|
-
source.IndexOf("UnityAuthoringUndo.Destroy(", StringComparison.Ordinal),
|
|
786
|
-
}.Where(index => index >= 0).DefaultIfEmpty(-1).Min();
|
|
787
|
-
var declaresUndo = source.Contains("UndoLevel = AuthoringUndoLevel.Full") || source.Contains("UndoLevel = AuthoringUndoLevel.Partial");
|
|
788
|
-
if (declaresUndo)
|
|
789
|
-
Assert.IsTrue(recordIndex >= 0 && recordIndex < markIndex, $"{Path.GetFileName(file)}: an undoable pilot tool must record before marking a mutation");
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// The read-only inspection region must not reach a side-effecting Unity API.
|
|
794
|
-
var adapters = File.ReadAllText(Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "Utils", "UnityAuthoringAdapters.cs")));
|
|
795
|
-
var start = adapters.IndexOf("internal static class UnityPilotAuthoringInspection", StringComparison.Ordinal);
|
|
796
|
-
var end = adapters.IndexOf("public sealed class UnityBatchAuthoringValidator", StringComparison.Ordinal);
|
|
797
|
-
Assert.IsTrue(start >= 0 && end > start, "inspection region not found");
|
|
798
|
-
var inspection = adapters.Substring(start, end - start);
|
|
799
|
-
foreach (var forbidden in new[] { "SaveAssets", "CreateAsset", "DestroyImmediate", "new GameObject(", "AddComponent<", "SetDirty", "MarkSceneDirty", "SaveScene", "NewScene(", "OpenScene(", "Undo." })
|
|
800
|
-
Assert.IsFalse(inspection.Contains(forbidden), $"read-only inspection must not use {forbidden}");
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// ------------------------------------------------------------------
|
|
804
|
-
// helpers
|
|
805
|
-
// ------------------------------------------------------------------
|
|
806
|
-
|
|
807
|
-
private static void EnsureTmpFolder()
|
|
808
|
-
{
|
|
809
|
-
if (!AssetDatabase.IsValidFolder(TmpFolder))
|
|
810
|
-
AssetDatabase.CreateFolder("Assets", TmpFolderName);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
private static Dictionary<string, JsonElement> Params(params (string Name, object Value)[] values)
|
|
814
|
-
{
|
|
815
|
-
var result = new Dictionary<string, JsonElement>();
|
|
816
|
-
foreach (var (name, value) in values)
|
|
817
|
-
result[name] = JsonSerializer.SerializeToElement(value);
|
|
818
|
-
return result;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
private static object ObjectIdWireValue(UnityEngine.Object value)
|
|
822
|
-
{
|
|
823
|
-
#if UNITY_6000_5_OR_NEWER
|
|
824
|
-
return UnityEngine.EntityId.ToULong(value.GetEntityId())
|
|
825
|
-
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
826
|
-
#else
|
|
827
|
-
return value.GetInstanceID();
|
|
828
|
-
#endif
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
private static string ObjectIdJsonValue(UnityEngine.Object value)
|
|
832
|
-
=> JsonSerializer.Serialize(ObjectIdWireValue(value));
|
|
833
|
-
|
|
834
|
-
private static string ObjectIdText(UnityEngine.Object value)
|
|
835
|
-
=> Convert.ToString(ObjectIdWireValue(value), System.Globalization.CultureInfo.InvariantCulture)
|
|
836
|
-
?? "0";
|
|
837
|
-
|
|
838
|
-
private static ToolCallControl Control(string callId, string? dryRun = null, ToolCallConfirmation? confirmation = null)
|
|
839
|
-
=> new ToolCallControl
|
|
840
|
-
{
|
|
841
|
-
Version = ToolCallControl.CurrentVersion,
|
|
842
|
-
CallId = callId,
|
|
843
|
-
CorrelationId = callId + "-trace",
|
|
844
|
-
DryRun = dryRun,
|
|
845
|
-
Confirm = confirmation == null ? (bool?)null : true,
|
|
846
|
-
Confirmation = confirmation,
|
|
847
|
-
};
|
|
848
|
-
|
|
849
|
-
private static ToolCallConfirmation ReadPlan(ResponseData<ResponseCallTool> response)
|
|
850
|
-
{
|
|
851
|
-
Assert.AreEqual(ResponseStatus.Success, response.Status, "plan: " + response.Message);
|
|
852
|
-
var plan = response.Value!.StructuredContent!["result"]!["confirmationPlan"]!.AsObject();
|
|
853
|
-
return new ToolCallConfirmation
|
|
854
|
-
{
|
|
855
|
-
PlanId = plan["planId"]!.GetValue<string>(),
|
|
856
|
-
PlanHash = plan["planHash"]!.GetValue<string>(),
|
|
857
|
-
ExpiresAtUnixMs = plan["expiresAtUnixMs"]!.GetValue<long>(),
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
private static string? ErrorCode(ResponseData<ResponseCallTool> response)
|
|
862
|
-
=> response.StructuredError?.Code ?? response.Value?.StructuredError?.Code;
|
|
863
|
-
|
|
864
|
-
private IEnumerator Call(string tool, string json, ToolCallControl? control, Action<ResponseData<ResponseCallTool>> onDone)
|
|
865
|
-
=> Call(tool, JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!, control, onDone);
|
|
866
|
-
|
|
867
|
-
private IEnumerator Call(
|
|
868
|
-
string tool,
|
|
869
|
-
Dictionary<string, JsonElement> arguments,
|
|
870
|
-
ToolCallControl? control,
|
|
871
|
-
Action<ResponseData<ResponseCallTool>> onDone)
|
|
872
|
-
{
|
|
873
|
-
// requestID is part of the confirmation binding: derive it from the
|
|
874
|
-
// call id so plan and execution share one logical identity.
|
|
875
|
-
var requestId = (control?.CallId ?? "legacy-" + Guid.NewGuid().ToString("N")) + "-request";
|
|
876
|
-
var request = control == null
|
|
877
|
-
? new RequestCallTool(requestId, tool, arguments)
|
|
878
|
-
: new RequestCallTool(requestId, tool, arguments, control);
|
|
879
|
-
// Unity resets this between SetUp and the test body; rejections
|
|
880
|
-
// are logged as errors by the manager and asserted on the response.
|
|
881
|
-
LogAssert.ignoreFailingMessages = true;
|
|
882
|
-
var task = UnityCopilotPluginEditor.Instance.Tools!.RunCallTool(request);
|
|
883
|
-
yield return WaitForTask(task);
|
|
884
|
-
onDone(task.Result);
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
private static void SetEditorSingleton(UnityCopilotPluginEditor replacement)
|
|
888
|
-
{
|
|
889
|
-
var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
|
|
890
|
-
Assert.IsNotNull(instanceField, "Editor singleton field was not found.");
|
|
891
|
-
instanceField!.SetValue(null, replacement);
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
private static void RestoreEditorSingleton(
|
|
895
|
-
UnityCopilotPluginEditor original,
|
|
896
|
-
IUcoPlugin? originalPlugin,
|
|
897
|
-
TestUnityCopilotPluginEditor replacement)
|
|
898
|
-
{
|
|
899
|
-
replacement.
|
|
900
|
-
replacement.Dispose();
|
|
901
|
-
var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
|
|
902
|
-
instanceField!.SetValue(null, original);
|
|
903
|
-
var setCurrentPlugin = typeof(UnityCopilotPluginEditor).GetMethod("SetCurrentPlugin", PrivateStatic);
|
|
904
|
-
setCurrentPlugin!.Invoke(null, new object?[] { originalPlugin });
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
/// <summary>Bounded view of Editor state that a dry-run must not change.</summary>
|
|
908
|
-
private sealed class EditorSnapshot
|
|
909
|
-
{
|
|
910
|
-
public string Objects = string.Empty;
|
|
911
|
-
public string Dirty = string.Empty;
|
|
912
|
-
public int UndoGroup;
|
|
913
|
-
public string Files = string.Empty;
|
|
914
|
-
|
|
915
|
-
public static EditorSnapshot Capture(string? watchFolder)
|
|
916
|
-
{
|
|
917
|
-
var ids = new List<string>();
|
|
918
|
-
var dirty = new List<string>();
|
|
919
|
-
for (var index = 0; index < SceneManager.sceneCount; index++)
|
|
920
|
-
{
|
|
921
|
-
var scene = SceneManager.GetSceneAt(index);
|
|
922
|
-
if (!scene.IsValid() || !scene.isLoaded)
|
|
923
|
-
continue;
|
|
924
|
-
dirty.Add(scene.name + "=" + scene.isDirty);
|
|
925
|
-
foreach (var root in scene.GetRootGameObjects())
|
|
926
|
-
{
|
|
927
|
-
foreach (var transform in root.GetComponentsInChildren<Transform>(true))
|
|
928
|
-
{
|
|
929
|
-
var go = transform.gameObject;
|
|
930
|
-
ids.Add(ObjectIdText(go) + ":" + go.name + ":" + go.GetComponents<Component>().Length + ":" + (go.transform.parent == null ? "0" : ObjectIdText(go.transform.parent)));
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
ids.Sort(StringComparer.Ordinal);
|
|
935
|
-
|
|
936
|
-
var files = string.Empty;
|
|
937
|
-
if (watchFolder != null)
|
|
938
|
-
{
|
|
939
|
-
var full = Path.GetFullPath(watchFolder);
|
|
940
|
-
files = Directory.Exists(full)
|
|
941
|
-
? string.Join("|", Directory.GetFiles(full, "*", SearchOption.AllDirectories)
|
|
942
|
-
.OrderBy(path => path, StringComparer.Ordinal)
|
|
943
|
-
.Select(path => Path.GetFileName(path) + ":" + new FileInfo(path).Length))
|
|
944
|
-
: "<missing>";
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
return new EditorSnapshot
|
|
948
|
-
{
|
|
949
|
-
Objects = string.Join("|", ids),
|
|
950
|
-
Dirty = string.Join("|", dirty),
|
|
951
|
-
UndoGroup = Undo.GetCurrentGroup(),
|
|
952
|
-
Files = files,
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
public void AssertEquals(EditorSnapshot expected, string context)
|
|
957
|
-
{
|
|
958
|
-
Assert.AreEqual(expected.Objects, Objects, context + ": object set/state changed");
|
|
959
|
-
Assert.AreEqual(expected.Dirty, Dirty, context + ": scene dirty flags changed");
|
|
960
|
-
Assert.AreEqual(expected.UndoGroup, UndoGroup, context + ": Undo group changed");
|
|
961
|
-
Assert.AreEqual(expected.Files, Files, context + ": project files changed");
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
private sealed class TestUnityCopilotPluginEditor : UnityCopilotPluginEditor
|
|
966
|
-
{
|
|
967
|
-
private readonly IReadOnlyList<IRunTool> _runners;
|
|
968
|
-
|
|
969
|
-
public TestUnityCopilotPluginEditor(params IRunTool[] runners)
|
|
970
|
-
{
|
|
971
|
-
_runners = runners;
|
|
972
|
-
ConnectionConfigForTests.KeepConnected = false;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
protected override IUcoPlugin
|
|
976
|
-
com.AtelierAI.Uco.Framework.Common.Version version,
|
|
977
|
-
Reflector reflector,
|
|
978
|
-
ILoggerProvider? loggerProvider = null,
|
|
979
|
-
Action<
|
|
980
|
-
{
|
|
981
|
-
return base.
|
|
982
|
-
version,
|
|
983
|
-
reflector,
|
|
984
|
-
loggerProvider,
|
|
985
|
-
builder =>
|
|
986
|
-
{
|
|
987
|
-
configure?.Invoke(builder);
|
|
988
|
-
foreach (var runner in _runners)
|
|
989
|
-
builder.AddTool(runner.Name, runner);
|
|
990
|
-
});
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
/// <summary>
|
|
995
|
-
/// Test-only mutating runner: creates a real GameObject inside the
|
|
996
|
-
/// ambient transaction and then throws, modelling a tool that fails
|
|
997
|
-
/// after Unity accepted a mutation.
|
|
998
|
-
/// </summary>
|
|
999
|
-
private sealed class FailingCreateRunner : IRunTool
|
|
1000
|
-
{
|
|
1001
|
-
public const string VictimName = "tx-victim";
|
|
1002
|
-
|
|
1003
|
-
public string Name => "test-failing-create";
|
|
1004
|
-
public bool Enabled { get; set; } = true;
|
|
1005
|
-
public string? Title => Name;
|
|
1006
|
-
public string? Description => "Test-only failing authoring runner.";
|
|
1007
|
-
public MethodInfo? Method => null;
|
|
1008
|
-
public string? SkillDescription => null;
|
|
1009
|
-
public string? SkillBody => null;
|
|
1010
|
-
public JsonNode? InputSchema => new JsonObject();
|
|
1011
|
-
public JsonNode? OutputSchema => null;
|
|
1012
|
-
public UcoToolType ToolType => UcoToolType.Standard;
|
|
1013
|
-
public bool? ReadOnlyHint => false;
|
|
1014
|
-
public bool? DestructiveHint => null;
|
|
1015
|
-
public bool? IdempotentHint => null;
|
|
1016
|
-
public bool? OpenWorldHint => null;
|
|
1017
|
-
public AuthoringCapabilityDescriptor? AuthoringCapability { get; } = new AuthoringCapabilityDescriptor
|
|
1018
|
-
{
|
|
1019
|
-
MutationKind = AuthoringMutationKind.Create,
|
|
1020
|
-
UndoLevel = AuthoringUndoLevel.Full,
|
|
1021
|
-
SupportsValidation = true,
|
|
1022
|
-
SupportsPlanning = true,
|
|
1023
|
-
Validator = new UnityPilotAuthoringValidator(),
|
|
1024
|
-
Planner = new UnityPilotAuthoringPlanner(),
|
|
1025
|
-
TransactionFactory = new UnityAuthoringTransactionFactory(),
|
|
1026
|
-
};
|
|
1027
|
-
public int TokenCount => 0;
|
|
1028
|
-
public int Invocations { get; private set; }
|
|
1029
|
-
|
|
1030
|
-
public Task<ResponseCallTool> Run(
|
|
1031
|
-
string requestId,
|
|
1032
|
-
IReadOnlyDictionary<string, JsonElement>? namedParameters,
|
|
1033
|
-
CancellationToken cancellationToken = default)
|
|
1034
|
-
{
|
|
1035
|
-
Invocations++;
|
|
1036
|
-
return Task.FromResult(MainThread.Instance.Run(() =>
|
|
1037
|
-
{
|
|
1038
|
-
UnityAuthoringUndo.RequireAuthoringScope();
|
|
1039
|
-
var victim = new GameObject(VictimName);
|
|
1040
|
-
UnityAuthoringUndo.RecordCreated(victim);
|
|
1041
|
-
UnityAuthoringUndo.MarkMutated();
|
|
1042
|
-
throw new InvalidOperationException("failure after mutation");
|
|
1043
|
-
#pragma warning disable CS0162
|
|
1044
|
-
return ResponseCallTool.Success().SetRequestID(requestId);
|
|
1045
|
-
#pragma warning restore CS0162
|
|
1046
|
-
}));
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1
|
+
#nullable enable
|
|
2
|
+
|
|
3
|
+
using System;
|
|
4
|
+
using System.Collections;
|
|
5
|
+
using System.Collections.Generic;
|
|
6
|
+
using System.IO;
|
|
7
|
+
using System.Linq;
|
|
8
|
+
using System.Reflection;
|
|
9
|
+
using System.Text.Json;
|
|
10
|
+
using System.Text.Json.Nodes;
|
|
11
|
+
using System.Text.RegularExpressions;
|
|
12
|
+
using System.Threading;
|
|
13
|
+
using System.Threading.Tasks;
|
|
14
|
+
using com.AtelierAI.Unity.Copilot.Editor.API;
|
|
15
|
+
using com.AtelierAI.Unity.Copilot.Editor.Utils;
|
|
16
|
+
using com.AtelierAI.Uco.Framework;
|
|
17
|
+
using com.AtelierAI.Uco.Framework.Common.Model;
|
|
18
|
+
using com.IvanMurzak.ReflectorNet;
|
|
19
|
+
using com.IvanMurzak.ReflectorNet.Utils;
|
|
20
|
+
using Microsoft.Extensions.Logging;
|
|
21
|
+
using NUnit.Framework;
|
|
22
|
+
using UnityEditor;
|
|
23
|
+
using UnityEditor.SceneManagement;
|
|
24
|
+
using UnityEngine;
|
|
25
|
+
using UnityEngine.SceneManagement;
|
|
26
|
+
using UnityEngine.TestTools;
|
|
27
|
+
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
28
|
+
|
|
29
|
+
namespace com.AtelierAI.Unity.Copilot.Editor.Tests
|
|
30
|
+
{
|
|
31
|
+
/// <summary>
|
|
32
|
+
/// g-005 pilot verification against the real Editor: dry-run invariants
|
|
33
|
+
/// (4.5), one-step Undo and truthful reporting (5.5), failure/abort
|
|
34
|
+
/// behaviour (5.6), the production batch entry point (6.4), plan
|
|
35
|
+
/// staleness/reload invalidation, and no-bypass for direct runner calls.
|
|
36
|
+
/// Every call goes through the production tool manager unless the test
|
|
37
|
+
/// is explicitly proving that a bypass attempt fails.
|
|
38
|
+
/// </summary>
|
|
39
|
+
[TestFixture]
|
|
40
|
+
public sealed class AuthoringSafetyPilotTests : BaseTest
|
|
41
|
+
{
|
|
42
|
+
private const string TmpFolderName = "AuthoringSafetyTests_TMP";
|
|
43
|
+
private const string TmpFolder = "Assets/" + TmpFolderName;
|
|
44
|
+
private static readonly BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static;
|
|
45
|
+
|
|
46
|
+
private bool _ignoreFailingMessages;
|
|
47
|
+
private static UnityCopilotPluginEditor _sessionOriginalEditor = null!;
|
|
48
|
+
|
|
49
|
+
[OneTimeSetUp]
|
|
50
|
+
public void CaptureSessionSingleton()
|
|
51
|
+
{
|
|
52
|
+
_sessionOriginalEditor = UnityCopilotPluginEditor.Instance;
|
|
53
|
+
_sessionOriginalEditor.BuildUcoPluginIfNeeded();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
[OneTimeTearDown]
|
|
57
|
+
public void RestoreSessionSingleton()
|
|
58
|
+
{
|
|
59
|
+
// An abandoned coroutine (the runner fails the test on an
|
|
60
|
+
// unexpected error log, e.g. the replacement's hub handshake
|
|
61
|
+
// failing ~10 s after build) never reaches the replacing tests'
|
|
62
|
+
// `finally { RestoreEditorSingleton }`, so a broken double stays
|
|
63
|
+
// installed and every later fixture resolves it instead of the
|
|
64
|
+
// real editor. Fixture-level teardown runs after every test of
|
|
65
|
+
// this fixture regardless of failure or abandonment — restore
|
|
66
|
+
// the captured session singleton whenever one of this fixture's
|
|
67
|
+
// doubles is still installed.
|
|
68
|
+
if (UnityCopilotPluginEditor.Instance is TestUnityCopilotPluginEditor leaked)
|
|
69
|
+
RestoreEditorSingleton(
|
|
70
|
+
_sessionOriginalEditor, _sessionOriginalEditor.UcoPluginInstance, leaked);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public override IEnumerator SetUp()
|
|
74
|
+
{
|
|
75
|
+
yield return base.SetUp();
|
|
76
|
+
UnityCopilotPluginEditor.Instance.BuildUcoPluginIfNeeded();
|
|
77
|
+
// Rejections are logged as errors by the manager ("Error Response
|
|
78
|
+
// to AI"); the assertions here are on responses and Editor state.
|
|
79
|
+
_ignoreFailingMessages = LogAssert.ignoreFailingMessages;
|
|
80
|
+
LogAssert.ignoreFailingMessages = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public override IEnumerator TearDown()
|
|
84
|
+
{
|
|
85
|
+
LogAssert.ignoreFailingMessages = _ignoreFailingMessages;
|
|
86
|
+
if (AssetDatabase.IsValidFolder(TmpFolder))
|
|
87
|
+
AssetDatabase.DeleteAsset(TmpFolder);
|
|
88
|
+
yield return base.TearDown();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ------------------------------------------------------------------
|
|
92
|
+
// 4.5 dry-run invariants
|
|
93
|
+
// ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
[UnityTest]
|
|
96
|
+
public IEnumerator DryRun_ValidateAndPlan_LeaveEditorUntouched()
|
|
97
|
+
{
|
|
98
|
+
EnsureTmpFolder();
|
|
99
|
+
var host = new GameObject("dry-run-host");
|
|
100
|
+
host.AddComponent<BoxCollider>();
|
|
101
|
+
var parent = new GameObject("dry-run-parent");
|
|
102
|
+
var hostId = ObjectIdJsonValue(host);
|
|
103
|
+
var parentId = ObjectIdJsonValue(parent);
|
|
104
|
+
|
|
105
|
+
var calls = new (string Tool, string Json)[]
|
|
106
|
+
{
|
|
107
|
+
(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""dry-run-created""}"),
|
|
108
|
+
(Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
|
|
109
|
+
(Tool_GameObject.GameObjectComponentAddToolId, $@"{{""componentNames"":[""UnityEngine.SphereCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
|
|
110
|
+
(Tool_GameObject.GameObjectComponentDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}"),
|
|
111
|
+
(Tool_GameObject.GameObjectSetParentToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{parentId}}}}}"),
|
|
112
|
+
(Tool_GameObject.GameObjectDuplicateToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}]}}"),
|
|
113
|
+
(Tool_Scene.SceneCreateToolId, $@"{{""path"":""{TmpFolder}/dry-run.unity""}}"),
|
|
114
|
+
(Tool_Scene.SceneSaveToolId, $@"{{""path"":""{TmpFolder}/dry-run-save.unity""}}"),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
var before = EditorSnapshot.Capture(TmpFolder);
|
|
118
|
+
foreach (var (tool, json) in calls)
|
|
119
|
+
{
|
|
120
|
+
foreach (var mode in new[] { "validate", "plan" })
|
|
121
|
+
{
|
|
122
|
+
ResponseData<ResponseCallTool>? response = null;
|
|
123
|
+
yield return Call(tool, json, Control(tool + "-" + mode, dryRun: mode), r => response = r);
|
|
124
|
+
Assert.IsNotNull(response, tool);
|
|
125
|
+
Assert.AreEqual(ResponseStatus.Success, response!.Status, $"{tool} {mode}: {response.Message}");
|
|
126
|
+
var result = response.Value!.StructuredContent!["result"]!;
|
|
127
|
+
Assert.AreEqual(mode, result["dryRun"]!.GetValue<string>(), tool);
|
|
128
|
+
Assert.IsTrue(result["valid"]!.GetValue<bool>(), $"{tool} {mode} should validate");
|
|
129
|
+
Assert.IsNull(response.Value.Transaction, $"{tool} {mode} must not open a transaction");
|
|
130
|
+
if (mode == "plan")
|
|
131
|
+
{
|
|
132
|
+
var plan = result["confirmationPlan"]!;
|
|
133
|
+
StringAssert.StartsWith("plan-", plan["planId"]!.GetValue<string>());
|
|
134
|
+
StringAssert.StartsWith("sha256-", plan["planHash"]!.GetValue<string>());
|
|
135
|
+
var targets = plan["targets"]!.AsArray();
|
|
136
|
+
Assert.Greater(targets.Count, 0, $"{tool} plan should list targets");
|
|
137
|
+
foreach (var target in targets)
|
|
138
|
+
StringAssert.StartsWith("sha256-", target!["fingerprint"]!.GetValue<string>(), tool);
|
|
139
|
+
Assert.Greater(plan["predictedEffects"]!.AsArray().Count, 0, tool);
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
{
|
|
143
|
+
Assert.IsNull(result["confirmationPlan"], $"{tool} validate must not issue a plan");
|
|
144
|
+
}
|
|
145
|
+
EditorSnapshot.Capture(TmpFolder).AssertEquals(before, $"{tool} {mode}");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
[UnityTest]
|
|
151
|
+
public IEnumerator Validate_ReportsMissingTargetWithoutMutation()
|
|
152
|
+
{
|
|
153
|
+
var before = EditorSnapshot.Capture(null);
|
|
154
|
+
ResponseData<ResponseCallTool>? response = null;
|
|
155
|
+
yield return Call(
|
|
156
|
+
Tool_GameObject.GameObjectDestroyToolId,
|
|
157
|
+
@"{""gameObjectRef"":{""instanceID"":123456789}}",
|
|
158
|
+
Control("missing-target", dryRun: "validate"),
|
|
159
|
+
r => response = r);
|
|
160
|
+
|
|
161
|
+
Assert.AreEqual(ResponseStatus.Error, response!.Status);
|
|
162
|
+
var error = response.StructuredError ?? response.Value?.StructuredError;
|
|
163
|
+
Assert.AreEqual("validation_failed", error!.Code);
|
|
164
|
+
Assert.AreEqual("target_not_found", error.Details!["reason"]!.GetValue<string>());
|
|
165
|
+
EditorSnapshot.Capture(null).AssertEquals(before, "invalid validate");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ------------------------------------------------------------------
|
|
169
|
+
// Confirmation lifecycle against real targets
|
|
170
|
+
// ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
[UnityTest]
|
|
173
|
+
public IEnumerator Destroy_LegacyIsRejected_PlanIsStaleAfterTargetChange_ThenConfirmedDestroyIsUndoable()
|
|
174
|
+
{
|
|
175
|
+
var victim = new GameObject("victim");
|
|
176
|
+
var id = ObjectIdJsonValue(victim);
|
|
177
|
+
var json = $@"{{""gameObjectRef"":{{""instanceID"":{id}}}}}";
|
|
178
|
+
|
|
179
|
+
// Legacy destructive call: no mutation, copyable retry instruction.
|
|
180
|
+
ResponseData<ResponseCallTool>? legacy = null;
|
|
181
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, null, r => legacy = r);
|
|
182
|
+
Assert.AreEqual(ResponseStatus.Error, legacy!.Status);
|
|
183
|
+
StringAssert.Contains("[" + ToolCallErrorCodes.ConfirmationRequired + "]", legacy.Value!.GetMessage());
|
|
184
|
+
StringAssert.Contains("confirmationPlan", legacy.Value.GetMessage());
|
|
185
|
+
StringAssert.Contains("retryWith", legacy.Value.GetMessage());
|
|
186
|
+
Assert.IsNotNull(GameObject.Find("victim"), "a rejected legacy destroy must not mutate");
|
|
187
|
+
|
|
188
|
+
// Plan, then change the target: the approval is stale.
|
|
189
|
+
ResponseData<ResponseCallTool>? planned = null;
|
|
190
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", dryRun: "plan"), r => planned = r);
|
|
191
|
+
var token = ReadPlan(planned!);
|
|
192
|
+
victim.name = "victim-renamed";
|
|
193
|
+
ResponseData<ResponseCallTool>? stale = null;
|
|
194
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", confirmation: token), r => stale = r);
|
|
195
|
+
Assert.AreEqual(ToolCallErrorCodes.ConfirmationStale, ErrorCode(stale!));
|
|
196
|
+
Assert.IsNotNull(GameObject.Find("victim-renamed"));
|
|
197
|
+
|
|
198
|
+
// Fresh plan on the current target executes once and is undoable.
|
|
199
|
+
ResponseData<ResponseCallTool>? replanned = null;
|
|
200
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", dryRun: "plan"), r => replanned = r);
|
|
201
|
+
var freshToken = ReadPlan(replanned!);
|
|
202
|
+
ResponseData<ResponseCallTool>? executed = null;
|
|
203
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", confirmation: freshToken), r => executed = r);
|
|
204
|
+
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
205
|
+
Assert.IsNull(GameObject.Find("victim-renamed"), "confirmed destroy must destroy");
|
|
206
|
+
var transaction = executed.Value!.Transaction!;
|
|
207
|
+
Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
|
|
208
|
+
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
209
|
+
Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
|
|
210
|
+
Assert.AreEqual("Uco: " + Tool_GameObject.GameObjectDestroyToolId, transaction["groupLabel"]!.GetValue<string>());
|
|
211
|
+
|
|
212
|
+
// A replayed token is not a standing capability.
|
|
213
|
+
var again = new GameObject("victim-again");
|
|
214
|
+
ResponseData<ResponseCallTool>? replay = null;
|
|
215
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(again)}}}}}", Control("destroy-2", confirmation: freshToken), r => replay = r);
|
|
216
|
+
Assert.AreEqual(ResponseStatus.Error, replay!.Status);
|
|
217
|
+
Assert.IsNotNull(GameObject.Find("victim-again"));
|
|
218
|
+
|
|
219
|
+
Undo.PerformUndo();
|
|
220
|
+
Assert.IsNotNull(GameObject.Find("victim-renamed"), "one Undo step must restore the destroyed object");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
[UnityTest]
|
|
224
|
+
public IEnumerator ReloadGuard_InvalidatesIssuedPlans()
|
|
225
|
+
{
|
|
226
|
+
var victim = new GameObject("reload-victim");
|
|
227
|
+
var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(victim)}}}}}";
|
|
228
|
+
ResponseData<ResponseCallTool>? planned = null;
|
|
229
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", dryRun: "plan"), r => planned = r);
|
|
230
|
+
var token = ReadPlan(planned!);
|
|
231
|
+
|
|
232
|
+
Assert.GreaterOrEqual(UnityAuthoringSafetyReloadGuard.InvalidateConfirmationPlans(), 1,
|
|
233
|
+
"the live pipeline must expose at least one confirmation store");
|
|
234
|
+
|
|
235
|
+
ResponseData<ResponseCallTool>? afterReload = null;
|
|
236
|
+
yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", confirmation: token), r => afterReload = r);
|
|
237
|
+
Assert.AreEqual(ToolCallErrorCodes.ConfirmationInvalid, ErrorCode(afterReload!));
|
|
238
|
+
Assert.IsNotNull(GameObject.Find("reload-victim"));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ------------------------------------------------------------------
|
|
242
|
+
// 5.5 one-step Undo, dirty state, full versus partial
|
|
243
|
+
// ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
[UnityTest]
|
|
246
|
+
public IEnumerator Create_IsOneUndoStepWithFullReport()
|
|
247
|
+
{
|
|
248
|
+
var scene = SceneManager.GetActiveScene();
|
|
249
|
+
var groupBefore = Undo.GetCurrentGroup();
|
|
250
|
+
ResponseData<ResponseCallTool>? response = null;
|
|
251
|
+
yield return Call(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""undo-created""}", Control("create"), r => response = r);
|
|
252
|
+
|
|
253
|
+
Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
|
|
254
|
+
var created = GameObject.Find("undo-created");
|
|
255
|
+
Assert.IsNotNull(created);
|
|
256
|
+
Assert.IsTrue(scene.isDirty || !scene.IsValid() || string.IsNullOrEmpty(scene.path),
|
|
257
|
+
"an executing create marks the active scene dirty");
|
|
258
|
+
var transaction = response.Value!.Transaction!;
|
|
259
|
+
Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
|
|
260
|
+
Assert.AreEqual("Uco: " + Tool_GameObject.GameObjectCreateToolId, transaction["groupLabel"]!.GetValue<string>());
|
|
261
|
+
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
262
|
+
Assert.AreEqual("none", transaction["rollback"]!.GetValue<string>());
|
|
263
|
+
Assert.Greater(transaction["affectedObjects"]!.AsArray().Count, 0);
|
|
264
|
+
Assert.Greater(Undo.GetCurrentGroup(), groupBefore, "the call must own a new Undo group");
|
|
265
|
+
|
|
266
|
+
Undo.PerformUndo();
|
|
267
|
+
Assert.IsNull(GameObject.Find("undo-created"), "one Undo step removes the created object");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
[UnityTest]
|
|
271
|
+
public IEnumerator ComponentAddAndSetParent_AreOneUndoStepEach()
|
|
272
|
+
{
|
|
273
|
+
var host = new GameObject("undo-host");
|
|
274
|
+
var parent = new GameObject("undo-parent");
|
|
275
|
+
var hostId = ObjectIdJsonValue(host);
|
|
276
|
+
|
|
277
|
+
ResponseData<ResponseCallTool>? add = null;
|
|
278
|
+
yield return Call(
|
|
279
|
+
Tool_GameObject.GameObjectComponentAddToolId,
|
|
280
|
+
$@"{{""componentNames"":[""UnityEngine.BoxCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}",
|
|
281
|
+
Control("add"),
|
|
282
|
+
r => add = r);
|
|
283
|
+
Assert.AreEqual(ResponseStatus.Success, add!.Status, add.Message);
|
|
284
|
+
Assert.IsNotNull(host.GetComponent<BoxCollider>());
|
|
285
|
+
Assert.AreEqual("full", add.Value!.Transaction!["undo"]!.GetValue<string>());
|
|
286
|
+
Undo.PerformUndo();
|
|
287
|
+
Assert.IsNull(host.GetComponent<BoxCollider>(), "one Undo step removes the added component");
|
|
288
|
+
|
|
289
|
+
ResponseData<ResponseCallTool>? reparent = null;
|
|
290
|
+
yield return Call(
|
|
291
|
+
Tool_GameObject.GameObjectSetParentToolId,
|
|
292
|
+
$@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(parent)}}}}}",
|
|
293
|
+
Control("parent"),
|
|
294
|
+
r => reparent = r);
|
|
295
|
+
Assert.AreEqual(ResponseStatus.Success, reparent!.Status, reparent.Message);
|
|
296
|
+
Assert.AreEqual(parent.transform, host.transform.parent);
|
|
297
|
+
Assert.AreEqual("full", reparent.Value!.Transaction!["undo"]!.GetValue<string>());
|
|
298
|
+
Undo.PerformUndo();
|
|
299
|
+
Assert.IsNull(host.transform.parent, "one Undo step restores the previous parent");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
[UnityTest]
|
|
303
|
+
public IEnumerator ComponentDestroy_UsesUndoAwareDestroyAndIsUndoable()
|
|
304
|
+
{
|
|
305
|
+
var host = new GameObject("component-destroy-host");
|
|
306
|
+
host.AddComponent<BoxCollider>();
|
|
307
|
+
ResponseData<ResponseCallTool>? planned = null;
|
|
308
|
+
var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(host)}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}";
|
|
309
|
+
yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", dryRun: "plan"), r => planned = r);
|
|
310
|
+
ResponseData<ResponseCallTool>? executed = null;
|
|
311
|
+
yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", confirmation: ReadPlan(planned!)), r => executed = r);
|
|
312
|
+
|
|
313
|
+
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
314
|
+
Assert.IsNull(host.GetComponent<BoxCollider>());
|
|
315
|
+
Assert.AreEqual("full", executed.Value!.Transaction!["undo"]!.GetValue<string>());
|
|
316
|
+
Undo.PerformUndo();
|
|
317
|
+
Assert.IsNotNull(host.GetComponent<BoxCollider>(), "Undo.DestroyObjectImmediate makes the deletion undoable");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
[UnityTest]
|
|
321
|
+
public IEnumerator Modify_ReportsPartialUndoTruthfully()
|
|
322
|
+
{
|
|
323
|
+
var target = new GameObject("modify-me");
|
|
324
|
+
var arguments = new Dictionary<string, JsonElement>
|
|
325
|
+
{
|
|
326
|
+
["gameObjectRefs"] = JsonSerializer.SerializeToElement(new[] { new { instanceID = ObjectIdWireValue(target) } }),
|
|
327
|
+
["jsonPatchesPerGameObject"] = JsonSerializer.SerializeToElement(new[] { "{\"name\":\"modified-name\"}" }),
|
|
328
|
+
};
|
|
329
|
+
ResponseData<ResponseCallTool>? response = null;
|
|
330
|
+
yield return Call(Tool_GameObject.GameObjectModifyToolId, arguments, Control("modify"), r => response = r);
|
|
331
|
+
|
|
332
|
+
Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
|
|
333
|
+
Assert.AreEqual("modified-name", target.name);
|
|
334
|
+
var transaction = response.Value!.Transaction!;
|
|
335
|
+
Assert.AreEqual("partial", transaction["undo"]!.GetValue<string>(), "reflected patches are advertised as partial");
|
|
336
|
+
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
337
|
+
Undo.PerformUndo();
|
|
338
|
+
Assert.AreEqual("modify-me", target.name, "the recorded complete snapshot restores the name");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ------------------------------------------------------------------
|
|
342
|
+
// 5.6 failure/abort and no-bypass
|
|
343
|
+
// ------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
[UnityTest]
|
|
346
|
+
public IEnumerator DirectRunnerInvocation_FailsClosedWithoutMutation()
|
|
347
|
+
{
|
|
348
|
+
var manager = UnityCopilotPluginEditor.Instance.Tools!;
|
|
349
|
+
var runner = manager.GetAllTools().First(tool => tool.Name == Tool_GameObject.GameObjectCreateToolId);
|
|
350
|
+
var before = EditorSnapshot.Capture(null);
|
|
351
|
+
|
|
352
|
+
LogAssert.ignoreFailingMessages = true;
|
|
353
|
+
var direct = runner.Run("direct-bypass", new Dictionary<string, JsonElement>
|
|
354
|
+
{
|
|
355
|
+
["name"] = JsonSerializer.SerializeToElement("bypass-created"),
|
|
356
|
+
});
|
|
357
|
+
yield return WaitForTask(direct);
|
|
358
|
+
|
|
359
|
+
Assert.AreEqual(ResponseStatus.Error, direct.Result.Status, "a direct runner call must fail closed");
|
|
360
|
+
Assert.IsNull(GameObject.Find("bypass-created"), "no object may be created outside the policy pipeline");
|
|
361
|
+
EditorSnapshot.Capture(null).AssertEquals(before, "direct bypass");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
[UnityTest]
|
|
365
|
+
public IEnumerator RunnerFailureAfterMutation_RevertsOnlyItsGroup()
|
|
366
|
+
{
|
|
367
|
+
var originalInstance = UnityCopilotPluginEditor.Instance;
|
|
368
|
+
originalInstance.BuildUcoPluginIfNeeded();
|
|
369
|
+
var originalPlugin = originalInstance.UcoPluginInstance;
|
|
370
|
+
var failing = new FailingCreateRunner();
|
|
371
|
+
var replacement = new TestUnityCopilotPluginEditor(failing);
|
|
372
|
+
SetEditorSingleton(replacement);
|
|
373
|
+
try
|
|
374
|
+
{
|
|
375
|
+
replacement.BuildUcoPluginIfNeeded();
|
|
376
|
+
var manager = replacement.Tools!;
|
|
377
|
+
|
|
378
|
+
// Unrelated earlier history that must survive the abort.
|
|
379
|
+
var unrelated = new GameObject("unrelated-history");
|
|
380
|
+
Undo.RegisterCreatedObjectUndo(unrelated, "unrelated");
|
|
381
|
+
Undo.IncrementCurrentGroup();
|
|
382
|
+
|
|
383
|
+
var request = new RequestCallTool("failing-request", failing.Name, new Dictionary<string, JsonElement>(), Control("failing"));
|
|
384
|
+
LogAssert.ignoreFailingMessages = true;
|
|
385
|
+
var execution = manager.RunCallTool(request);
|
|
386
|
+
yield return WaitForTask(execution);
|
|
387
|
+
var response = execution.Result;
|
|
388
|
+
|
|
389
|
+
Assert.AreEqual(ResponseStatus.Error, response.Status);
|
|
390
|
+
Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName), "the aborted group reverts the created object");
|
|
391
|
+
Assert.IsNotNull(GameObject.Find("unrelated-history"), "unrelated Undo history is preserved");
|
|
392
|
+
var transaction = response.Value!.Transaction!;
|
|
393
|
+
Assert.IsTrue(transaction["aborted"]!.GetValue<bool>());
|
|
394
|
+
Assert.IsFalse(transaction["completed"]!.GetValue<bool>());
|
|
395
|
+
Assert.AreEqual("complete", transaction["rollback"]!.GetValue<string>());
|
|
396
|
+
Assert.AreEqual(1, failing.Invocations);
|
|
397
|
+
}
|
|
398
|
+
finally
|
|
399
|
+
{
|
|
400
|
+
RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ------------------------------------------------------------------
|
|
405
|
+
// 6.4 production batch with pilot children
|
|
406
|
+
// ------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
[UnityTest]
|
|
409
|
+
public IEnumerator Batch_PilotChildren_CollapseToOneUndoStep()
|
|
410
|
+
{
|
|
411
|
+
var commands = new[]
|
|
412
|
+
{
|
|
413
|
+
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-a")) },
|
|
414
|
+
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-b")) },
|
|
415
|
+
};
|
|
416
|
+
var arguments = new Dictionary<string, JsonElement>
|
|
417
|
+
{
|
|
418
|
+
["commands"] = JsonSerializer.SerializeToElement(commands),
|
|
419
|
+
["failFast"] = JsonSerializer.SerializeToElement(true),
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
ResponseData<ResponseCallTool>? planned = null;
|
|
423
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", dryRun: "plan"), r => planned = r);
|
|
424
|
+
Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
|
|
425
|
+
Assert.IsNull(GameObject.Find("batch-a"), "planning a batch must not create children");
|
|
426
|
+
var plan = planned.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
|
|
427
|
+
Assert.GreaterOrEqual(plan["targets"]!.AsArray().Count, 2);
|
|
428
|
+
|
|
429
|
+
ResponseData<ResponseCallTool>? executed = null;
|
|
430
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", confirmation: ReadPlan(planned)), r => executed = r);
|
|
431
|
+
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
432
|
+
var batchResult = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
|
|
433
|
+
executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
|
|
434
|
+
Assert.AreEqual(2, batchResult.Succeeded, string.Join("; ", batchResult.Results.Select(r => r.Error)));
|
|
435
|
+
Assert.IsNotNull(GameObject.Find("batch-a"));
|
|
436
|
+
Assert.IsNotNull(GameObject.Find("batch-b"));
|
|
437
|
+
var transaction = executed.Value.Transaction!;
|
|
438
|
+
Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
|
|
439
|
+
Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
|
|
440
|
+
Assert.AreEqual("full", transaction["undo"]!.GetValue<string>(), "two fully recorded children keep aggregate full Undo");
|
|
441
|
+
Assert.AreEqual(2, transaction["affectedObjects"]!.AsArray().Count, "both children attach to the parent group");
|
|
442
|
+
for (var index = 0; index < batchResult.Results.Length; index++)
|
|
443
|
+
{
|
|
444
|
+
var child = batchResult.Results[index];
|
|
445
|
+
Assert.IsTrue(child.Ok);
|
|
446
|
+
Assert.IsNotNull(child.Transaction, "each shared child reports its own transaction contribution");
|
|
447
|
+
Assert.IsTrue(child.Transaction!["shared"]!.GetValue<bool>());
|
|
448
|
+
Assert.IsFalse(child.Transaction["pending"]!.GetValue<bool>());
|
|
449
|
+
Assert.IsTrue(child.Transaction["completed"]!.GetValue<bool>());
|
|
450
|
+
Assert.IsFalse(child.Transaction["aborted"]!.GetValue<bool>());
|
|
451
|
+
Assert.AreEqual("none", child.Transaction["rollback"]!.GetValue<string>());
|
|
452
|
+
Assert.AreEqual("full", child.Transaction["undo"]!.GetValue<string>());
|
|
453
|
+
Assert.AreEqual(transaction["groupId"]!.GetValue<int>(), child.Transaction["groupId"]!.GetValue<int>());
|
|
454
|
+
Assert.AreEqual(transaction["groupLabel"]!.GetValue<string>(), child.Transaction["groupLabel"]!.GetValue<string>());
|
|
455
|
+
var affectedNames = child.Transaction["affectedObjects"]!.AsArray()
|
|
456
|
+
.Select(value => value!["name"]!.GetValue<string>())
|
|
457
|
+
.Distinct(StringComparer.Ordinal)
|
|
458
|
+
.ToArray();
|
|
459
|
+
CollectionAssert.AreEqual(new[] { index == 0 ? "batch-a" : "batch-b" }, affectedNames,
|
|
460
|
+
"a child report may retain multiple recording events, but only for that child's object");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
Undo.PerformUndo();
|
|
464
|
+
Assert.IsNull(GameObject.Find("batch-a"), "one Undo step reverts the whole batch");
|
|
465
|
+
Assert.IsNull(GameObject.Find("batch-b"));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
[UnityTest]
|
|
469
|
+
public IEnumerator Batch_SharedChildFailure_RevertsPriorChildAndFinalizesTruthfulResults()
|
|
470
|
+
{
|
|
471
|
+
var originalInstance = UnityCopilotPluginEditor.Instance;
|
|
472
|
+
originalInstance.BuildUcoPluginIfNeeded();
|
|
473
|
+
var originalPlugin = originalInstance.UcoPluginInstance;
|
|
474
|
+
var failing = new FailingCreateRunner();
|
|
475
|
+
var replacement = new TestUnityCopilotPluginEditor(failing);
|
|
476
|
+
SetEditorSingleton(replacement);
|
|
477
|
+
try
|
|
478
|
+
{
|
|
479
|
+
replacement.BuildUcoPluginIfNeeded();
|
|
480
|
+
var unrelated = new GameObject("batch-unrelated-history");
|
|
481
|
+
Undo.RegisterCreatedObjectUndo(unrelated, "batch unrelated");
|
|
482
|
+
Undo.IncrementCurrentGroup();
|
|
483
|
+
|
|
484
|
+
var commands = new[]
|
|
485
|
+
{
|
|
486
|
+
new Tool_Batch.BatchCommand
|
|
487
|
+
{
|
|
488
|
+
Tool = Tool_GameObject.GameObjectCreateToolId,
|
|
489
|
+
Params = Params(("name", "batch-shared-restored")),
|
|
490
|
+
},
|
|
491
|
+
new Tool_Batch.BatchCommand
|
|
492
|
+
{
|
|
493
|
+
Tool = failing.Name,
|
|
494
|
+
Params = new Dictionary<string, JsonElement>(),
|
|
495
|
+
},
|
|
496
|
+
new Tool_Batch.BatchCommand
|
|
497
|
+
{
|
|
498
|
+
Tool = Tool_GameObject.GameObjectCreateToolId,
|
|
499
|
+
Params = Params(("name", "batch-shared-skipped")),
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
var arguments = new Dictionary<string, JsonElement>
|
|
503
|
+
{
|
|
504
|
+
["commands"] = JsonSerializer.SerializeToElement(commands),
|
|
505
|
+
["failFast"] = JsonSerializer.SerializeToElement(true),
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
ResponseData<ResponseCallTool>? planned = null;
|
|
509
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
510
|
+
Control("batch-shared-failure", dryRun: "plan"), r => planned = r);
|
|
511
|
+
Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
|
|
512
|
+
|
|
513
|
+
ResponseData<ResponseCallTool>? executed = null;
|
|
514
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
515
|
+
Control("batch-shared-failure", confirmation: ReadPlan(planned)), r => executed = r);
|
|
516
|
+
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
517
|
+
var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
|
|
518
|
+
executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
|
|
519
|
+
|
|
520
|
+
Assert.AreEqual(0, result.Succeeded);
|
|
521
|
+
Assert.AreEqual(2, result.Failed);
|
|
522
|
+
Assert.IsTrue(result.Aborted);
|
|
523
|
+
Assert.IsNull(GameObject.Find("batch-shared-restored"),
|
|
524
|
+
"the parent abort restores a prior successful shared child");
|
|
525
|
+
Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName),
|
|
526
|
+
"the failing child's mutation is restored by the same parent group");
|
|
527
|
+
Assert.IsNull(GameObject.Find("batch-shared-skipped"));
|
|
528
|
+
Assert.IsNotNull(GameObject.Find("batch-unrelated-history"),
|
|
529
|
+
"aborting the batch must preserve unrelated Undo history");
|
|
530
|
+
|
|
531
|
+
var prior = result.Results[0];
|
|
532
|
+
Assert.IsFalse(prior.Ok, "a rolled-back child must not remain successful");
|
|
533
|
+
Assert.IsFalse(prior.Skipped);
|
|
534
|
+
Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
|
|
535
|
+
Assert.IsNull(prior.Data);
|
|
536
|
+
Assert.IsNotNull(prior.Transaction);
|
|
537
|
+
Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
|
|
538
|
+
Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
|
|
539
|
+
Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
|
|
540
|
+
Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
|
|
541
|
+
Assert.AreEqual("complete", prior.Transaction["rollback"]!.GetValue<string>());
|
|
542
|
+
Assert.AreEqual("full", prior.Transaction["undo"]!.GetValue<string>());
|
|
543
|
+
var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
|
|
544
|
+
.Select(value => value!["name"]!.GetValue<string>())
|
|
545
|
+
.Distinct(StringComparer.Ordinal)
|
|
546
|
+
.ToArray();
|
|
547
|
+
CollectionAssert.AreEqual(new[] { "batch-shared-restored" }, priorAffectedNames,
|
|
548
|
+
"the rolled-back child report may contain multiple record events, but only for its object");
|
|
549
|
+
|
|
550
|
+
var failed = result.Results[1];
|
|
551
|
+
Assert.IsFalse(failed.Ok);
|
|
552
|
+
Assert.IsFalse(failed.Skipped);
|
|
553
|
+
Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
|
|
554
|
+
"the failing child retains its original runtime error");
|
|
555
|
+
Assert.IsNotNull(failed.Transaction);
|
|
556
|
+
Assert.IsTrue(failed.Transaction!["aborted"]!.GetValue<bool>());
|
|
557
|
+
Assert.AreEqual("complete", failed.Transaction["rollback"]!.GetValue<string>());
|
|
558
|
+
Assert.IsTrue(result.Results[2].Skipped);
|
|
559
|
+
Assert.IsFalse(result.Results[2].Ok);
|
|
560
|
+
}
|
|
561
|
+
finally
|
|
562
|
+
{
|
|
563
|
+
RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
[UnityTest]
|
|
568
|
+
public IEnumerator Batch_PartialSharedChildFailure_ReportsUncertainFinalStateAndRetainsContext()
|
|
569
|
+
{
|
|
570
|
+
var originalInstance = UnityCopilotPluginEditor.Instance;
|
|
571
|
+
originalInstance.BuildUcoPluginIfNeeded();
|
|
572
|
+
var originalPlugin = originalInstance.UcoPluginInstance;
|
|
573
|
+
var failing = new FailingCreateRunner();
|
|
574
|
+
var replacement = new TestUnityCopilotPluginEditor(failing);
|
|
575
|
+
SetEditorSingleton(replacement);
|
|
576
|
+
try
|
|
577
|
+
{
|
|
578
|
+
replacement.BuildUcoPluginIfNeeded();
|
|
579
|
+
var target = new GameObject("batch-shared-partial");
|
|
580
|
+
var unrelated = new GameObject("batch-partial-unrelated-history");
|
|
581
|
+
Undo.RegisterCreatedObjectUndo(unrelated, "batch partial unrelated");
|
|
582
|
+
Undo.IncrementCurrentGroup();
|
|
583
|
+
|
|
584
|
+
var commands = new[]
|
|
585
|
+
{
|
|
586
|
+
new Tool_Batch.BatchCommand
|
|
587
|
+
{
|
|
588
|
+
Tool = Tool_GameObject.GameObjectModifyToolId,
|
|
589
|
+
Params = Params(
|
|
590
|
+
("gameObjectRefs", new[] { new { instanceID = ObjectIdWireValue(target) } }),
|
|
591
|
+
("jsonPatchesPerGameObject", new[] { "{\"name\":\"batch-shared-partial-modified\"}" })),
|
|
592
|
+
},
|
|
593
|
+
new Tool_Batch.BatchCommand
|
|
594
|
+
{
|
|
595
|
+
Tool = failing.Name,
|
|
596
|
+
Params = new Dictionary<string, JsonElement>(),
|
|
597
|
+
},
|
|
598
|
+
new Tool_Batch.BatchCommand
|
|
599
|
+
{
|
|
600
|
+
Tool = Tool_GameObject.GameObjectCreateToolId,
|
|
601
|
+
Params = Params(("name", "batch-partial-skipped")),
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
var arguments = new Dictionary<string, JsonElement>
|
|
605
|
+
{
|
|
606
|
+
["commands"] = JsonSerializer.SerializeToElement(commands),
|
|
607
|
+
["failFast"] = JsonSerializer.SerializeToElement(true),
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
ResponseData<ResponseCallTool>? planned = null;
|
|
611
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
612
|
+
Control("batch-partial-failure", dryRun: "plan"), r => planned = r);
|
|
613
|
+
Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
|
|
614
|
+
|
|
615
|
+
ResponseData<ResponseCallTool>? executed = null;
|
|
616
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
|
|
617
|
+
Control("batch-partial-failure", confirmation: ReadPlan(planned)), r => executed = r);
|
|
618
|
+
Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
|
|
619
|
+
var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
|
|
620
|
+
executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
|
|
621
|
+
|
|
622
|
+
Assert.AreEqual(0, result.Succeeded);
|
|
623
|
+
Assert.AreEqual(2, result.Failed);
|
|
624
|
+
Assert.IsTrue(result.Aborted);
|
|
625
|
+
Assert.IsNotNull(GameObject.Find("batch-partial-unrelated-history"),
|
|
626
|
+
"aborting a partial batch must preserve unrelated Undo history");
|
|
627
|
+
Assert.IsNull(GameObject.Find("batch-partial-skipped"));
|
|
628
|
+
|
|
629
|
+
var parentTransaction = executed.Value.Transaction!;
|
|
630
|
+
Assert.IsTrue(parentTransaction["aborted"]!.GetValue<bool>());
|
|
631
|
+
Assert.IsFalse(parentTransaction["completed"]!.GetValue<bool>());
|
|
632
|
+
Assert.AreEqual("partial", parentTransaction["rollback"]!.GetValue<string>());
|
|
633
|
+
|
|
634
|
+
var prior = result.Results[0];
|
|
635
|
+
Assert.IsFalse(prior.Ok, "an aborted prior mutation must not remain successful");
|
|
636
|
+
Assert.IsFalse(prior.Skipped);
|
|
637
|
+
Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
|
|
638
|
+
StringAssert.Contains("final mutation state is uncertain", prior.Error);
|
|
639
|
+
StringAssert.DoesNotContain("did not remain applied", prior.Error);
|
|
640
|
+
Assert.IsNotNull(prior.Data, "uncertain rollback retains bounded child diagnostics");
|
|
641
|
+
Assert.LessOrEqual(prior.Data!.ToString()!.Length, 1024);
|
|
642
|
+
Assert.IsNotNull(prior.Transaction);
|
|
643
|
+
Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
|
|
644
|
+
Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
|
|
645
|
+
Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
|
|
646
|
+
Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
|
|
647
|
+
Assert.AreEqual("partial", prior.Transaction["rollback"]!.GetValue<string>());
|
|
648
|
+
Assert.AreEqual("partial", prior.Transaction["undo"]!.GetValue<string>());
|
|
649
|
+
var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
|
|
650
|
+
.Select(value => value!["name"]!.GetValue<string>())
|
|
651
|
+
.Distinct(StringComparer.Ordinal)
|
|
652
|
+
.ToArray();
|
|
653
|
+
CollectionAssert.Contains(priorAffectedNames, "batch-shared-partial");
|
|
654
|
+
|
|
655
|
+
var failed = result.Results[1];
|
|
656
|
+
Assert.IsFalse(failed.Ok);
|
|
657
|
+
Assert.IsFalse(failed.Skipped);
|
|
658
|
+
Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
|
|
659
|
+
"the failing child retains its original runtime error");
|
|
660
|
+
Assert.AreEqual("Tool execution failed.", failed.Error,
|
|
661
|
+
"parent finalization must not replace the failing child's original safe error");
|
|
662
|
+
Assert.IsNotNull(failed.Transaction);
|
|
663
|
+
Assert.IsTrue(failed.Transaction!["shared"]!.GetValue<bool>());
|
|
664
|
+
Assert.IsFalse(failed.Transaction["pending"]!.GetValue<bool>());
|
|
665
|
+
Assert.IsTrue(failed.Transaction["aborted"]!.GetValue<bool>());
|
|
666
|
+
Assert.AreEqual("partial", failed.Transaction["rollback"]!.GetValue<string>());
|
|
667
|
+
|
|
668
|
+
Assert.IsTrue(result.Results[2].Skipped);
|
|
669
|
+
Assert.IsFalse(result.Results[2].Ok);
|
|
670
|
+
}
|
|
671
|
+
finally
|
|
672
|
+
{
|
|
673
|
+
RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
[UnityTest]
|
|
678
|
+
public IEnumerator Batch_AllowsUndoNoneAndRejectsInvalidChildBeforeAnyMutation()
|
|
679
|
+
{
|
|
680
|
+
EnsureTmpFolder();
|
|
681
|
+
var undoNone = new Dictionary<string, JsonElement>
|
|
682
|
+
{
|
|
683
|
+
["commands"] = JsonSerializer.SerializeToElement(new[]
|
|
684
|
+
{
|
|
685
|
+
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-undo-none")) },
|
|
686
|
+
new Tool_Batch.BatchCommand { Tool = Tool_Scene.SceneSaveToolId, Params = Params(("path", $"{TmpFolder}/batch-undo-none.unity")) },
|
|
687
|
+
}),
|
|
688
|
+
};
|
|
689
|
+
ResponseData<ResponseCallTool>? undoNonePlan = null;
|
|
690
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, undoNone, Control("batch-undo-none", dryRun: "plan"), r => undoNonePlan = r);
|
|
691
|
+
Assert.AreEqual(ResponseStatus.Success, undoNonePlan!.Status, undoNonePlan.Message);
|
|
692
|
+
var aggregatePlan = undoNonePlan.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
|
|
693
|
+
Assert.AreEqual("none", aggregatePlan["undo"]!.GetValue<string>());
|
|
694
|
+
Assert.IsNull(GameObject.Find("batch-undo-none"), "planning must not execute the first child");
|
|
695
|
+
|
|
696
|
+
var before = EditorSnapshot.Capture(null);
|
|
697
|
+
var missingTarget = new Dictionary<string, JsonElement>
|
|
698
|
+
{
|
|
699
|
+
["commands"] = JsonSerializer.SerializeToElement(new[]
|
|
700
|
+
{
|
|
701
|
+
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-2")) },
|
|
702
|
+
new Tool_Batch.BatchCommand
|
|
703
|
+
{
|
|
704
|
+
Tool = Tool_GameObject.GameObjectDestroyToolId,
|
|
705
|
+
Params = new Dictionary<string, JsonElement>
|
|
706
|
+
{
|
|
707
|
+
["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = 123456789 }),
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
}),
|
|
711
|
+
};
|
|
712
|
+
ResponseData<ResponseCallTool>? missingPlan = null;
|
|
713
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, missingTarget, Control("batch-missing", dryRun: "plan"), r => missingPlan = r);
|
|
714
|
+
Assert.AreEqual(ResponseStatus.Error, missingPlan!.Status);
|
|
715
|
+
Assert.IsNull(GameObject.Find("batch-rejected-2"));
|
|
716
|
+
|
|
717
|
+
// A confirmed batch is still rejected by preflight when a child
|
|
718
|
+
// cannot be planned at execution time (its target vanished).
|
|
719
|
+
var victim = new GameObject("batch-victim");
|
|
720
|
+
var vanishing = new Dictionary<string, JsonElement>
|
|
721
|
+
{
|
|
722
|
+
["commands"] = JsonSerializer.SerializeToElement(new[]
|
|
723
|
+
{
|
|
724
|
+
new Tool_Batch.BatchCommand
|
|
725
|
+
{
|
|
726
|
+
Tool = Tool_GameObject.GameObjectDestroyToolId,
|
|
727
|
+
Params = new Dictionary<string, JsonElement>
|
|
728
|
+
{
|
|
729
|
+
["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = ObjectIdWireValue(victim) }),
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-3")) },
|
|
733
|
+
}),
|
|
734
|
+
};
|
|
735
|
+
ResponseData<ResponseCallTool>? vanishingPlan = null;
|
|
736
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", dryRun: "plan"), r => vanishingPlan = r);
|
|
737
|
+
Assert.AreEqual(ResponseStatus.Success, vanishingPlan!.Status, vanishingPlan.Message);
|
|
738
|
+
UnityEngine.Object.DestroyImmediate(victim);
|
|
739
|
+
ResponseData<ResponseCallTool>? vanishingExec = null;
|
|
740
|
+
yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", confirmation: ReadPlan(vanishingPlan)), r => vanishingExec = r);
|
|
741
|
+
Assert.AreEqual(ResponseStatus.Error, vanishingExec!.Status, "the parent plan is stale once a child target vanished");
|
|
742
|
+
Assert.IsNull(GameObject.Find("batch-rejected-3"), "no child may run when the aggregate approval fails");
|
|
743
|
+
|
|
744
|
+
EditorSnapshot.Capture(null).AssertEquals(before, "rejected batches");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ------------------------------------------------------------------
|
|
748
|
+
// 5.5 static regressions: direct DestroyImmediate / mutation-before-recording
|
|
749
|
+
// ------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
[Test]
|
|
752
|
+
public void PilotSources_NeverUseDirectDestroyImmediateOrMutateBeforeRecording()
|
|
753
|
+
{
|
|
754
|
+
var toolDirectory = Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "API", "Tool"));
|
|
755
|
+
Assert.IsTrue(Directory.Exists(toolDirectory), toolDirectory);
|
|
756
|
+
var pilotFiles = Directory.GetFiles(toolDirectory, "*.cs")
|
|
757
|
+
.Where(file =>
|
|
758
|
+
{
|
|
759
|
+
var name = Path.GetFileName(file);
|
|
760
|
+
return name.StartsWith("GameObject.", StringComparison.Ordinal)
|
|
761
|
+
|| name.StartsWith("Scene.", StringComparison.Ordinal);
|
|
762
|
+
})
|
|
763
|
+
.Where(file => File.ReadAllText(file).Contains("[AuthoringCapability("))
|
|
764
|
+
.ToArray();
|
|
765
|
+
Assert.GreaterOrEqual(pilotFiles.Length, 13, "expected the Scene/GameObject/Component pilot files");
|
|
766
|
+
|
|
767
|
+
var directDestroy = new Regex(@"(?<!Undo\.)\bDestroyImmediate\s*\(");
|
|
768
|
+
foreach (var file in pilotFiles)
|
|
769
|
+
{
|
|
770
|
+
var source = File.ReadAllText(file);
|
|
771
|
+
Assert.IsFalse(directDestroy.IsMatch(source), $"{Path.GetFileName(file)} must not call a non-Undo DestroyImmediate");
|
|
772
|
+
StringAssert.Contains("UnityAuthoringUndo.RequireAuthoringScope()", source, Path.GetFileName(file));
|
|
773
|
+
var scopeIndex = source.IndexOf("UnityAuthoringUndo.RequireAuthoringScope()", StringComparison.Ordinal);
|
|
774
|
+
var markIndex = source.IndexOf("UnityAuthoringUndo.MarkMutated()", StringComparison.Ordinal);
|
|
775
|
+
if (markIndex >= 0)
|
|
776
|
+
{
|
|
777
|
+
Assert.Less(scopeIndex, markIndex, $"{Path.GetFileName(file)}: scope check must precede the mutation mark");
|
|
778
|
+
var recordIndex = new[]
|
|
779
|
+
{
|
|
780
|
+
source.IndexOf("UnityAuthoringUndo.RecordCreated(", StringComparison.Ordinal),
|
|
781
|
+
source.IndexOf("UnityAuthoringUndo.RecordModified(", StringComparison.Ordinal),
|
|
782
|
+
source.IndexOf("UnityAuthoringUndo.RecordHostRegistered(", StringComparison.Ordinal),
|
|
783
|
+
source.IndexOf("UnityAuthoringUndo.AddComponent(", StringComparison.Ordinal),
|
|
784
|
+
source.IndexOf("UnityAuthoringUndo.SetParent(", StringComparison.Ordinal),
|
|
785
|
+
source.IndexOf("UnityAuthoringUndo.Destroy(", StringComparison.Ordinal),
|
|
786
|
+
}.Where(index => index >= 0).DefaultIfEmpty(-1).Min();
|
|
787
|
+
var declaresUndo = source.Contains("UndoLevel = AuthoringUndoLevel.Full") || source.Contains("UndoLevel = AuthoringUndoLevel.Partial");
|
|
788
|
+
if (declaresUndo)
|
|
789
|
+
Assert.IsTrue(recordIndex >= 0 && recordIndex < markIndex, $"{Path.GetFileName(file)}: an undoable pilot tool must record before marking a mutation");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// The read-only inspection region must not reach a side-effecting Unity API.
|
|
794
|
+
var adapters = File.ReadAllText(Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "Utils", "UnityAuthoringAdapters.cs")));
|
|
795
|
+
var start = adapters.IndexOf("internal static class UnityPilotAuthoringInspection", StringComparison.Ordinal);
|
|
796
|
+
var end = adapters.IndexOf("public sealed class UnityBatchAuthoringValidator", StringComparison.Ordinal);
|
|
797
|
+
Assert.IsTrue(start >= 0 && end > start, "inspection region not found");
|
|
798
|
+
var inspection = adapters.Substring(start, end - start);
|
|
799
|
+
foreach (var forbidden in new[] { "SaveAssets", "CreateAsset", "DestroyImmediate", "new GameObject(", "AddComponent<", "SetDirty", "MarkSceneDirty", "SaveScene", "NewScene(", "OpenScene(", "Undo." })
|
|
800
|
+
Assert.IsFalse(inspection.Contains(forbidden), $"read-only inspection must not use {forbidden}");
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ------------------------------------------------------------------
|
|
804
|
+
// helpers
|
|
805
|
+
// ------------------------------------------------------------------
|
|
806
|
+
|
|
807
|
+
private static void EnsureTmpFolder()
|
|
808
|
+
{
|
|
809
|
+
if (!AssetDatabase.IsValidFolder(TmpFolder))
|
|
810
|
+
AssetDatabase.CreateFolder("Assets", TmpFolderName);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private static Dictionary<string, JsonElement> Params(params (string Name, object Value)[] values)
|
|
814
|
+
{
|
|
815
|
+
var result = new Dictionary<string, JsonElement>();
|
|
816
|
+
foreach (var (name, value) in values)
|
|
817
|
+
result[name] = JsonSerializer.SerializeToElement(value);
|
|
818
|
+
return result;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private static object ObjectIdWireValue(UnityEngine.Object value)
|
|
822
|
+
{
|
|
823
|
+
#if UNITY_6000_5_OR_NEWER
|
|
824
|
+
return UnityEngine.EntityId.ToULong(value.GetEntityId())
|
|
825
|
+
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
826
|
+
#else
|
|
827
|
+
return value.GetInstanceID();
|
|
828
|
+
#endif
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private static string ObjectIdJsonValue(UnityEngine.Object value)
|
|
832
|
+
=> JsonSerializer.Serialize(ObjectIdWireValue(value));
|
|
833
|
+
|
|
834
|
+
private static string ObjectIdText(UnityEngine.Object value)
|
|
835
|
+
=> Convert.ToString(ObjectIdWireValue(value), System.Globalization.CultureInfo.InvariantCulture)
|
|
836
|
+
?? "0";
|
|
837
|
+
|
|
838
|
+
private static ToolCallControl Control(string callId, string? dryRun = null, ToolCallConfirmation? confirmation = null)
|
|
839
|
+
=> new ToolCallControl
|
|
840
|
+
{
|
|
841
|
+
Version = ToolCallControl.CurrentVersion,
|
|
842
|
+
CallId = callId,
|
|
843
|
+
CorrelationId = callId + "-trace",
|
|
844
|
+
DryRun = dryRun,
|
|
845
|
+
Confirm = confirmation == null ? (bool?)null : true,
|
|
846
|
+
Confirmation = confirmation,
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
private static ToolCallConfirmation ReadPlan(ResponseData<ResponseCallTool> response)
|
|
850
|
+
{
|
|
851
|
+
Assert.AreEqual(ResponseStatus.Success, response.Status, "plan: " + response.Message);
|
|
852
|
+
var plan = response.Value!.StructuredContent!["result"]!["confirmationPlan"]!.AsObject();
|
|
853
|
+
return new ToolCallConfirmation
|
|
854
|
+
{
|
|
855
|
+
PlanId = plan["planId"]!.GetValue<string>(),
|
|
856
|
+
PlanHash = plan["planHash"]!.GetValue<string>(),
|
|
857
|
+
ExpiresAtUnixMs = plan["expiresAtUnixMs"]!.GetValue<long>(),
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private static string? ErrorCode(ResponseData<ResponseCallTool> response)
|
|
862
|
+
=> response.StructuredError?.Code ?? response.Value?.StructuredError?.Code;
|
|
863
|
+
|
|
864
|
+
private IEnumerator Call(string tool, string json, ToolCallControl? control, Action<ResponseData<ResponseCallTool>> onDone)
|
|
865
|
+
=> Call(tool, JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!, control, onDone);
|
|
866
|
+
|
|
867
|
+
private IEnumerator Call(
|
|
868
|
+
string tool,
|
|
869
|
+
Dictionary<string, JsonElement> arguments,
|
|
870
|
+
ToolCallControl? control,
|
|
871
|
+
Action<ResponseData<ResponseCallTool>> onDone)
|
|
872
|
+
{
|
|
873
|
+
// requestID is part of the confirmation binding: derive it from the
|
|
874
|
+
// call id so plan and execution share one logical identity.
|
|
875
|
+
var requestId = (control?.CallId ?? "legacy-" + Guid.NewGuid().ToString("N")) + "-request";
|
|
876
|
+
var request = control == null
|
|
877
|
+
? new RequestCallTool(requestId, tool, arguments)
|
|
878
|
+
: new RequestCallTool(requestId, tool, arguments, control);
|
|
879
|
+
// Unity resets this between SetUp and the test body; rejections
|
|
880
|
+
// are logged as errors by the manager and asserted on the response.
|
|
881
|
+
LogAssert.ignoreFailingMessages = true;
|
|
882
|
+
var task = UnityCopilotPluginEditor.Instance.Tools!.RunCallTool(request);
|
|
883
|
+
yield return WaitForTask(task);
|
|
884
|
+
onDone(task.Result);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private static void SetEditorSingleton(UnityCopilotPluginEditor replacement)
|
|
888
|
+
{
|
|
889
|
+
var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
|
|
890
|
+
Assert.IsNotNull(instanceField, "Editor singleton field was not found.");
|
|
891
|
+
instanceField!.SetValue(null, replacement);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private static void RestoreEditorSingleton(
|
|
895
|
+
UnityCopilotPluginEditor original,
|
|
896
|
+
IUcoPlugin? originalPlugin,
|
|
897
|
+
TestUnityCopilotPluginEditor replacement)
|
|
898
|
+
{
|
|
899
|
+
replacement.DisposeUcoPluginInstance();
|
|
900
|
+
replacement.Dispose();
|
|
901
|
+
var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
|
|
902
|
+
instanceField!.SetValue(null, original);
|
|
903
|
+
var setCurrentPlugin = typeof(UnityCopilotPluginEditor).GetMethod("SetCurrentPlugin", PrivateStatic);
|
|
904
|
+
setCurrentPlugin!.Invoke(null, new object?[] { originalPlugin });
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/// <summary>Bounded view of Editor state that a dry-run must not change.</summary>
|
|
908
|
+
private sealed class EditorSnapshot
|
|
909
|
+
{
|
|
910
|
+
public string Objects = string.Empty;
|
|
911
|
+
public string Dirty = string.Empty;
|
|
912
|
+
public int UndoGroup;
|
|
913
|
+
public string Files = string.Empty;
|
|
914
|
+
|
|
915
|
+
public static EditorSnapshot Capture(string? watchFolder)
|
|
916
|
+
{
|
|
917
|
+
var ids = new List<string>();
|
|
918
|
+
var dirty = new List<string>();
|
|
919
|
+
for (var index = 0; index < SceneManager.sceneCount; index++)
|
|
920
|
+
{
|
|
921
|
+
var scene = SceneManager.GetSceneAt(index);
|
|
922
|
+
if (!scene.IsValid() || !scene.isLoaded)
|
|
923
|
+
continue;
|
|
924
|
+
dirty.Add(scene.name + "=" + scene.isDirty);
|
|
925
|
+
foreach (var root in scene.GetRootGameObjects())
|
|
926
|
+
{
|
|
927
|
+
foreach (var transform in root.GetComponentsInChildren<Transform>(true))
|
|
928
|
+
{
|
|
929
|
+
var go = transform.gameObject;
|
|
930
|
+
ids.Add(ObjectIdText(go) + ":" + go.name + ":" + go.GetComponents<Component>().Length + ":" + (go.transform.parent == null ? "0" : ObjectIdText(go.transform.parent)));
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
ids.Sort(StringComparer.Ordinal);
|
|
935
|
+
|
|
936
|
+
var files = string.Empty;
|
|
937
|
+
if (watchFolder != null)
|
|
938
|
+
{
|
|
939
|
+
var full = Path.GetFullPath(watchFolder);
|
|
940
|
+
files = Directory.Exists(full)
|
|
941
|
+
? string.Join("|", Directory.GetFiles(full, "*", SearchOption.AllDirectories)
|
|
942
|
+
.OrderBy(path => path, StringComparer.Ordinal)
|
|
943
|
+
.Select(path => Path.GetFileName(path) + ":" + new FileInfo(path).Length))
|
|
944
|
+
: "<missing>";
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return new EditorSnapshot
|
|
948
|
+
{
|
|
949
|
+
Objects = string.Join("|", ids),
|
|
950
|
+
Dirty = string.Join("|", dirty),
|
|
951
|
+
UndoGroup = Undo.GetCurrentGroup(),
|
|
952
|
+
Files = files,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
public void AssertEquals(EditorSnapshot expected, string context)
|
|
957
|
+
{
|
|
958
|
+
Assert.AreEqual(expected.Objects, Objects, context + ": object set/state changed");
|
|
959
|
+
Assert.AreEqual(expected.Dirty, Dirty, context + ": scene dirty flags changed");
|
|
960
|
+
Assert.AreEqual(expected.UndoGroup, UndoGroup, context + ": Undo group changed");
|
|
961
|
+
Assert.AreEqual(expected.Files, Files, context + ": project files changed");
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private sealed class TestUnityCopilotPluginEditor : UnityCopilotPluginEditor
|
|
966
|
+
{
|
|
967
|
+
private readonly IReadOnlyList<IRunTool> _runners;
|
|
968
|
+
|
|
969
|
+
public TestUnityCopilotPluginEditor(params IRunTool[] runners)
|
|
970
|
+
{
|
|
971
|
+
_runners = runners;
|
|
972
|
+
ConnectionConfigForTests.KeepConnected = false;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
protected override IUcoPlugin BuildUcoPlugin(
|
|
976
|
+
com.AtelierAI.Uco.Framework.Common.Version version,
|
|
977
|
+
Reflector reflector,
|
|
978
|
+
ILoggerProvider? loggerProvider = null,
|
|
979
|
+
Action<IUcoPluginBuilder>? configure = null)
|
|
980
|
+
{
|
|
981
|
+
return base.BuildUcoPlugin(
|
|
982
|
+
version,
|
|
983
|
+
reflector,
|
|
984
|
+
loggerProvider,
|
|
985
|
+
builder =>
|
|
986
|
+
{
|
|
987
|
+
configure?.Invoke(builder);
|
|
988
|
+
foreach (var runner in _runners)
|
|
989
|
+
builder.AddTool(runner.Name, runner);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/// <summary>
|
|
995
|
+
/// Test-only mutating runner: creates a real GameObject inside the
|
|
996
|
+
/// ambient transaction and then throws, modelling a tool that fails
|
|
997
|
+
/// after Unity accepted a mutation.
|
|
998
|
+
/// </summary>
|
|
999
|
+
private sealed class FailingCreateRunner : IRunTool
|
|
1000
|
+
{
|
|
1001
|
+
public const string VictimName = "tx-victim";
|
|
1002
|
+
|
|
1003
|
+
public string Name => "test-failing-create";
|
|
1004
|
+
public bool Enabled { get; set; } = true;
|
|
1005
|
+
public string? Title => Name;
|
|
1006
|
+
public string? Description => "Test-only failing authoring runner.";
|
|
1007
|
+
public MethodInfo? Method => null;
|
|
1008
|
+
public string? SkillDescription => null;
|
|
1009
|
+
public string? SkillBody => null;
|
|
1010
|
+
public JsonNode? InputSchema => new JsonObject();
|
|
1011
|
+
public JsonNode? OutputSchema => null;
|
|
1012
|
+
public UcoToolType ToolType => UcoToolType.Standard;
|
|
1013
|
+
public bool? ReadOnlyHint => false;
|
|
1014
|
+
public bool? DestructiveHint => null;
|
|
1015
|
+
public bool? IdempotentHint => null;
|
|
1016
|
+
public bool? OpenWorldHint => null;
|
|
1017
|
+
public AuthoringCapabilityDescriptor? AuthoringCapability { get; } = new AuthoringCapabilityDescriptor
|
|
1018
|
+
{
|
|
1019
|
+
MutationKind = AuthoringMutationKind.Create,
|
|
1020
|
+
UndoLevel = AuthoringUndoLevel.Full,
|
|
1021
|
+
SupportsValidation = true,
|
|
1022
|
+
SupportsPlanning = true,
|
|
1023
|
+
Validator = new UnityPilotAuthoringValidator(),
|
|
1024
|
+
Planner = new UnityPilotAuthoringPlanner(),
|
|
1025
|
+
TransactionFactory = new UnityAuthoringTransactionFactory(),
|
|
1026
|
+
};
|
|
1027
|
+
public int TokenCount => 0;
|
|
1028
|
+
public int Invocations { get; private set; }
|
|
1029
|
+
|
|
1030
|
+
public Task<ResponseCallTool> Run(
|
|
1031
|
+
string requestId,
|
|
1032
|
+
IReadOnlyDictionary<string, JsonElement>? namedParameters,
|
|
1033
|
+
CancellationToken cancellationToken = default)
|
|
1034
|
+
{
|
|
1035
|
+
Invocations++;
|
|
1036
|
+
return Task.FromResult(MainThread.Instance.Run(() =>
|
|
1037
|
+
{
|
|
1038
|
+
UnityAuthoringUndo.RequireAuthoringScope();
|
|
1039
|
+
var victim = new GameObject(VictimName);
|
|
1040
|
+
UnityAuthoringUndo.RecordCreated(victim);
|
|
1041
|
+
UnityAuthoringUndo.MarkMutated();
|
|
1042
|
+
throw new InvalidOperationException("failure after mutation");
|
|
1043
|
+
#pragma warning disable CS0162
|
|
1044
|
+
return ResponseCallTool.Success().SetRequestID(requestId);
|
|
1045
|
+
#pragma warning restore CS0162
|
|
1046
|
+
}));
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|